From 71dcbdec8ffe735488e9f1bb44f8be15bb5353b8 Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Mon, 12 Oct 2026 15:35:40 +0300 Subject: [PATCH 1/2] Move the eight remaining message groups into the register HRN, AGT, TSK, VAL, ARC, WSP, KEY and NET now say every message by its OSW identifier from the one register in core. The said-in-place ratchet is widened from four carriers to every carrier, and docs/messages.md is regenerated. One change is one pull request. --- ...remaining-groups-move-into-the-register.md | 9 + .gitignore | 5 + docs/messages.md | 2641 +++++++++- .../.openspec.yaml | 2 + .../design.md | 102 + .../harness.json | 20 + .../proposal.md | 67 + .../specs/messages/spec.md | 67 + .../tasks.md | 179 + openspec/specs/messages/spec.md | 2 +- packages/cli/src/doctor-command.test.ts | 1 + packages/cli/src/enrol-command.ts | 3 +- packages/cli/src/lease-command.test.ts | 1 + packages/cli/src/lease-command.ts | 3 +- .../cli/src/openspec-validate.gate.test.ts | 4 +- packages/cli/src/operator-commands.test.ts | 6 +- packages/cli/src/rollback-command.ts | 7 +- packages/cli/src/run-change.ts | 9 +- packages/cli/src/status-command.ts | 3 +- packages/cli/src/team-command.test.ts | 6 +- packages/cli/src/team-command.ts | 6 +- packages/cli/src/update-plan.ts | 4 +- packages/cli/src/worktree-command.ts | 3 +- packages/core/src/agent-roster.test.ts | 6 +- packages/core/src/agent-roster.ts | 46 +- packages/core/src/agent-status.test.ts | 7 +- packages/core/src/agent-status.ts | 57 +- .../src/agents/acp-session-driver.test.ts | 21 + .../core/src/agents/acp-session-driver.ts | 11 +- packages/core/src/agents/local-llm.ts | 5 +- packages/core/src/archive-guard.test.ts | 8 +- packages/core/src/chain-preflight.test.ts | 2 + packages/core/src/chain-preflight.ts | 68 +- .../core/src/change-relations-file.test.ts | 17 + packages/core/src/change-relations-file.ts | 56 +- packages/core/src/change-worktrees.test.ts | 17 +- packages/core/src/change-worktrees.ts | 51 +- packages/core/src/delegated-item-run.test.ts | 15 +- packages/core/src/delegated-item-run.ts | 75 +- packages/core/src/delegated-items.test.ts | 4 +- packages/core/src/enrolment.test.ts | 4 + packages/core/src/enrolment.ts | 4 +- packages/core/src/failure-diagnosis.test.ts | 4 +- packages/core/src/failure-diagnosis.ts | 95 +- packages/core/src/forge.ts | 4 +- packages/core/src/gh-pr-gateway.test.ts | 4 +- packages/core/src/gh-pr-gateway.ts | 64 +- packages/core/src/gh-pr-list.test.ts | 8 +- packages/core/src/github-api-forge.test.ts | 4 +- packages/core/src/harness-chain-runner.ts | 4682 ++++++++--------- .../core/src/harness-config-findings.test.ts | 3 + packages/core/src/harness-config-findings.ts | 79 +- packages/core/src/harness-config.test.ts | 17 + packages/core/src/harness-config.ts | 287 +- packages/core/src/http-forges.test.ts | 4 +- packages/core/src/http-forges.ts | 67 +- packages/core/src/human-only-inbox-view.ts | 121 +- packages/core/src/human-only-inbox.test.ts | 46 +- packages/core/src/landed-archive.test.ts | 12 +- packages/core/src/landed-archive.ts | 85 +- packages/core/src/lease-release.test.ts | 2 + packages/core/src/lease-release.ts | 19 +- packages/core/src/machine-key.test.ts | 15 +- packages/core/src/machine-key.ts | 5 +- packages/core/src/mechanical-checks.test.ts | 19 +- packages/core/src/mechanical-checks.ts | 85 +- packages/core/src/message-register.test.ts | 431 +- packages/core/src/message-register.ts | 1936 +++++++ packages/core/src/openspec.test.ts | 37 +- packages/core/src/openspec.ts | 40 +- packages/core/src/own-worktree-tasks.ts | 5 +- packages/core/src/people.test.ts | 16 +- packages/core/src/people.ts | 78 +- packages/core/src/process-scheduler.test.ts | 2 + packages/core/src/process-scheduler.ts | 10 +- packages/core/src/security.test.ts | 33 +- packages/core/src/security.ts | 38 +- packages/core/src/spec-delta-check.test.ts | 12 +- packages/core/src/spec-delta-check.ts | 27 +- .../core/src/stop-request-handlers.test.ts | 4 +- packages/core/src/successor-check.test.ts | 1 + packages/core/src/successor-check.ts | 9 +- packages/core/src/supervisor.test.ts | 11 +- packages/core/src/supervisor.ts | 48 +- packages/core/src/task-checklist.test.ts | 30 +- packages/core/src/task-checklist.ts | 26 +- packages/core/src/verify-quality.test.ts | 23 +- packages/core/src/verify-quality.ts | 35 +- packages/core/src/workspace-lease.test.ts | 29 +- packages/core/src/workspace-lease.ts | 37 +- packages/core/src/workspace-leftovers.test.ts | 6 +- packages/core/src/workspace-leftovers.ts | 24 +- packages/core/src/worktree-survey-facts.ts | 10 +- packages/core/src/worktree-survey.test.ts | 21 +- packages/core/src/worktree-survey.ts | 17 +- packages/extension/src/commands.test.ts | 27 +- packages/extension/src/commands.ts | 13 +- packages/extension/src/extension.ts | 6 +- .../src/tree/human-only-inbox-tree.test.ts | 13 +- .../src/tree/human-only-inbox-tree.ts | 4 +- .../src/webview/pipeline-panel.test.ts | 3 + .../extension/src/webview/pipeline-panel.ts | 6 +- packages/server/e2e/waiting-on-inbox.spec.ts | 4 +- packages/server/src/change-actions-rest.ts | 18 +- .../webui/src/components/RunDialog.test.tsx | 2 +- .../src/components/WorkspaceRunStatsPanel.tsx | 5 +- packages/webui/src/standalone-entry.tsx | 19 +- 107 files changed, 9178 insertions(+), 3293 deletions(-) create mode 100644 .changeset/remaining-groups-move-into-the-register.md create mode 100644 openspec/changes/remaining-groups-move-into-the-register/.openspec.yaml create mode 100644 openspec/changes/remaining-groups-move-into-the-register/design.md create mode 100644 openspec/changes/remaining-groups-move-into-the-register/harness.json create mode 100644 openspec/changes/remaining-groups-move-into-the-register/proposal.md create mode 100644 openspec/changes/remaining-groups-move-into-the-register/specs/messages/spec.md create mode 100644 openspec/changes/remaining-groups-move-into-the-register/tasks.md diff --git a/.changeset/remaining-groups-move-into-the-register.md b/.changeset/remaining-groups-move-into-the-register.md new file mode 100644 index 00000000..7f5090e4 --- /dev/null +++ b/.changeset/remaining-groups-move-into-the-register.md @@ -0,0 +1,9 @@ +--- +"@openspec-ui/core": minor +"@openspec-ui/cli": minor +"openspec-ui-vscode": minor +"@openspec-ui/server": minor +"@openspec-ui/webui": minor +--- + +Move the eight remaining message groups - HRN, AGT, TSK, VAL, ARC, WSP, KEY and NET - into the message register, widen the said-in-place ratchet from four carriers to every carrier, and regenerate docs/messages.md. diff --git a/.gitignore b/.gitignore index df66a536..ca777d10 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ test-results/ *.vsix .env .DS_Store + +# Local scratch from the agent that implements a change. +.npm-cache/ +.tmp-register/ +.tmp-*.mjs diff --git a/docs/messages.md b/docs/messages.md index 0d52c836..9e7d8583 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -281,71 +281,2648 @@ Why: The CLI's input is not a terminal, so nobody can answer, and the request is What to do: Run it in a terminal to be asked, or from the card. +## HRN: harness settings + +### OSW-HRN-001 + +> error: reviewGate.mode "agent-sufficient" is only valid in a per-change harness.json, never in the global openspec/agent-harness.json + +Why: The global file set the review gate so an agent reviews its own work, which only one change may decide. + +What to do: Move the setting into the change's own harness.json, or use "human-required" in the global file. + +### OSW-HRN-002 + +> error: autonomyLevel "autonomous" is only valid in a per-change harness.json, never in the global openspec/agent-harness.json + +Why: The global file set a level that runs without a person, which is chosen for one change at a time. + +What to do: Set the level in the change's own harness.json, or lower it in the global file. + +### OSW-HRN-003 + +> error: checkpoints.requireConfirmationBetweenSteps: false is only valid in a per-change harness.json, never in the global openspec/agent-harness.json + +Why: The global file turned off the between-stage confirmation that only a change may waive. + +What to do: Move the setting into the change's own harness.json, or remove it from the global file. + +### OSW-HRN-004 + +> error: gitStageAllowlist is only valid in a per-change harness.json, never in the global openspec/agent-harness.json + +Why: A git-stage allowlist is a change's own decision about what it may push, so a global one is refused. + +What to do: Move the allowlist into the change's own harness.json. + +### OSW-HRN-005 + +> error: taskAgents is only valid in a per-change harness.json, never in the global openspec/agent-harness.json — a task number names a task of one change + +Why: A task number belongs to one change's tasks.md, so a workspace-wide mapping cannot mean anything. + +What to do: Move the taskAgents section into the change's own harness.json. + +### OSW-HRN-006 + +> error: steps is only valid in a per-change harness.json, never in the global openspec/agent-harness.json — a declared step belongs to the chain of one change + +Why: A declared step is placed in one change's chain, so a global one would apply to every change forever. + +What to do: Move the steps list into the change's own harness.json. + +### OSW-HRN-007 + +> error: 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" + +Why: Act lets the supervisor start another agent, which a level that asks a person does not allow. + +What to do: Open the change's Harness Settings and save to turn Act off, or set autonomyLevel to "autonomous". + +### OSW-HRN-008 + +> error: supervisor.mode "act" repeats or moves a failed stage as another attempt of it, and maxStageAttempts is {attempts}: set it above 1 in this change's harness.json, or choose "advise" + +Why: Act spends another attempt when it repeats or moves a stage, and a ceiling of one leaves it nothing to spend. + +What to do: Raise maxStageAttempts above 1 in the change's harness.json, or choose "advise". + +### OSW-HRN-009 + +> warning: Migrated legacy harness dispatch settings in {file}: {reports} + +Why: An older harness file used the retired dispatch keys, and the migration rewrote them so the settings are read. + +What to do: Nothing, the migration is automatic; save the file to keep the new form. + +### OSW-HRN-010 + +> warning: stepAgents.{stage}.dispatch "vscode-chat" was migrated to stepAgents.{stage}.agent "{agent}" + +Why: A stepAgents entry named the editor's chat through the retired dispatch key, which is now written as an agent id. + +What to do: Nothing; the entry now names the VS Code chat agent directly. + +### OSW-HRN-011 + +> warning: stepAgents.{stage} was dropped — "{stage}" is a mechanical stage and never invokes an agent + +Why: The configuration set an agent for a stage that runs no agent, so the entry never did anything. + +What to do: Nothing; the setting was removed from the resolved configuration. + +### OSW-HRN-012 + +> error: budget.maxCost must be an object of unit to ceiling + +Why: The per-unit budget was written as something other than a map from unit to ceiling. + +What to do: Write it as an object, such as { "credits": 500 }. + +### OSW-HRN-013 + +> error: budget.maxCost has a ceiling under an empty unit + +Why: A ceiling was filed under a blank unit name, which no report can be compared against. + +What to do: Name the unit, or remove the entry. + +### OSW-HRN-014 + +> error: budget.maxCost.{unit} must be a positive number + +Why: A per-unit ceiling was not a positive finite number. + +What to do: Give the unit a positive number as its ceiling. + +### OSW-HRN-015 + +> error: {label} has unknown key "{key}" (accepted keys: {keys}) + +Why: An agent entry carried a key this product does not read, so it would silently do nothing. + +What to do: Remove the key or use one of the accepted keys named. + +### OSW-HRN-016 + +> error: {label} must be a non-empty string + +Why: An agent entry was written as something other than the agent id string it must be. + +What to do: Write the agent id as a non-empty string. + +### OSW-HRN-017 + +> error: {label} references unknown agent id "{agent}" + +Why: The entry names an agent id that no registered agent or the editor's chat has. + +What to do: Use a registered agent id, or install the agent that id belongs to. + +### OSW-HRN-018 + +> error: {label} selects agent "{agent}", which is only valid under autonomyLevel "assisted" — a chain cannot use it + +Why: The editor's chat can only run under assisted autonomy, where a person starts each stage. + +What to do: Use a CLI agent under this level, or set autonomyLevel to "assisted". + +### OSW-HRN-019 + +> error: {label}.model cannot reach anything when agent "{agent}" dispatches to VS Code chat + +Why: A model id has no effect when the entry dispatches to the editor's chat. + +What to do: Remove the model, or choose an agent that takes a model id. + +### OSW-HRN-020 + +> error: {label}.model "{model}" is not a valid model id + +Why: A model id reached a command line as a flag value and did not have the shape one may have. + +What to do: Give a model id of letters, digits, ".", "_", ":" and "-", not beginning with "-". + +### OSW-HRN-021 + +> error: {label} sets a model, but agent "{agent}" does not accept one + +Why: The named agent has no command-line way to take a model id. + +What to do: Remove the model, or choose an agent that accepts one. + +### OSW-HRN-022 + +> error: {label}.customAgent cannot reach anything when agent "{agent}" dispatches to VS Code chat + +Why: A custom agent flag has no effect when the entry dispatches to the editor's chat. + +What to do: Remove the customAgent, or choose an agent that accepts one. + +### OSW-HRN-023 + +> error: {label}.customAgent must be a non-empty string + +Why: The custom agent was written as something other than the flag value it must be. + +What to do: Write the custom agent as a non-empty string. + +### OSW-HRN-024 + +> error: {label}.customAgent "{customagent}" must not begin with "-" and may contain only letters, digits, ".", "_", ":" and "-" + +Why: A value that reaches argv as a flag value must not look like another flag. + +What to do: Rename the custom agent so it begins with a letter or digit and uses only the allowed characters. + +### OSW-HRN-025 + +> error: {label} sets a custom agent, but agent "{agent}" does not accept one + +Why: The named agent's command line has no flag for a custom agent. + +What to do: Remove the customAgent, or choose an agent that accepts one. + +### OSW-HRN-026 + +> error: {label}.effort cannot reach anything when agent "{agent}" dispatches to VS Code chat + +Why: A reasoning effort has no effect when the entry dispatches to the editor's chat. + +What to do: Remove the effort, or choose an agent with an effort control. + +### OSW-HRN-027 + +> error: {label}.effort must be one of: {values} + +Why: The effort was not one of the values this product recognises. + +What to do: Use one of the listed effort values. + +### OSW-HRN-028 + +> error: {label} sets effort, but agent "{agent}" has no command-line reasoning-effort control + +Why: The named agent has no way to be told how much reasoning to spend. + +What to do: Remove the effort, or choose an agent that takes one. + +### OSW-HRN-029 + +> error: {label}.effort "{effort}" is not accepted by agent "{agent}" (accepted: {values}) + +Why: The named agent takes an effort, but not this one. + +What to do: Use one of the effort values the agent accepts. + +### OSW-HRN-030 + +> error: {label}.budget cannot reach anything when agent "{agent}" dispatches to VS Code chat + +Why: A budget has no effect when the entry dispatches to the editor's chat. + +What to do: Remove the budget, or choose an agent that takes a cap. + +### OSW-HRN-031 + +> error: {label}.budget must be an object + +Why: The entry's budget was written as something other than an object of caps. + +What to do: Write the budget as an object with maxCostUsd or maxAiCredits. + +### OSW-HRN-032 + +> error: {label}.budget has unknown key "{key}" (accepted keys: {keys}) + +Why: The budget carried a cap key this product does not read. + +What to do: Remove the key or use one of the accepted keys named. + +### OSW-HRN-033 + +> error: {label}.budget must set maxCostUsd or maxAiCredits + +Why: The budget was present but named no cap at all. + +What to do: Set maxCostUsd or maxAiCredits, or remove the budget. + +### OSW-HRN-034 + +> error: {label}.budget.maxCostUsd must be a positive number + +Why: The per-stage dollar cap was not a positive finite number. + +What to do: Give it a positive number. + +### OSW-HRN-035 + +> error: {label} sets budget.maxCostUsd, but agent "{agent}" does not accept a cost cap in USD + +Why: The named agent's command line has no dollar cost cap. + +What to do: Remove maxCostUsd, or choose an agent billed in a unit it caps. + +### OSW-HRN-036 + +> error: {label}.budget.maxAiCredits must be a positive integer + +Why: The AI-credit cap was not a positive whole number. + +What to do: Give it a positive whole number. + +### OSW-HRN-037 + +> error: {label} sets budget.maxAiCredits, but agent "{agent}" does not accept a credit cap + +Why: The named agent's command line has no AI-credit cap. + +What to do: Remove maxAiCredits, or choose an agent that takes one. + +### OSW-HRN-038 + +> error: {label}.budget.maxAiCredits must be at least {minimum} (copilot-cli's own minimum) + +Why: copilot-cli refuses a credit cap below its own minimum. + +What to do: Raise the cap to at least the named minimum. + +### OSW-HRN-039 + +> error: stepAgents must be an object + +Why: The stepAgents section was written as something other than an object of stage entries. + +What to do: Write it as an object keyed by stage. + +### OSW-HRN-040 + +> error: stepAgents.{stage} is not accepted — "{stage}" is a mechanical stage and never invokes an agent + +Why: The stage runs no agent, so an entry for it can never do anything. + +What to do: Remove the entry from stepAgents. + +### OSW-HRN-041 + +> error: unknown stepAgents key "{stage}" (expected one of: {stages}) + +Why: A stepAgents key is not a stage that runs an agent. + +What to do: Use one of the listed stage names. + +### OSW-HRN-042 + +> error: taskAgents must be an object + +Why: The taskAgents section was written as something other than an object of task entries. + +What to do: Write it as an object keyed by task number. + +### OSW-HRN-043 + +> error: taskAgents key "{task}" is not a task number (expected the leading number of a tasks.md line, such as "5.4") + +Why: An entry filed under something other than a task number can never match a task line. + +What to do: Key the entry by the leading number of a tasks.md line. + +### OSW-HRN-044 + +> error: {label} selects agent "{agent}", which cannot run one task — a delegated item's run spawns a CLI agent + +Why: A numbered task is delegated to a spawned CLI agent, which the editor's chat cannot be. + +What to do: Use a CLI agent for the task. + +### OSW-HRN-045 + +> error: autonomyLevel must be one of: {levels} + +Why: The autonomy level was not one this product recognises. + +What to do: Use one of the listed autonomy levels. + +### OSW-HRN-046 + +> error: reviewGate must be an object + +Why: The review gate was written as something other than an object with a mode. + +What to do: Write it as an object with a mode of "human-required" or "agent-sufficient". + +### OSW-HRN-047 + +> error: reviewGate.mode must be one of: {modes} + +Why: The review gate mode was not one this product recognises. + +What to do: Use "human-required" or "agent-sufficient". + +### OSW-HRN-048 + +> error: hints must be an object + +Why: The hints section was written as something other than an object with an enabled flag. + +What to do: Write it as an object with an enabled boolean. + +### OSW-HRN-049 + +> error: hints.enabled must be a boolean + +Why: The hints switch was not true or false. + +What to do: Set hints.enabled to true or false. + +### OSW-HRN-050 + +> error: supervisor must be an object + +Why: The supervisor section was written as something other than an object of its keys. + +What to do: Write it as an object of the accepted supervisor keys. + +### OSW-HRN-051 + +> error: supervisor has no key "{key}" (accepted keys: {keys}) + +Why: The supervisor section carried a key this product does not read. + +What to do: Remove the key or use one of the accepted keys named. + +### OSW-HRN-052 + +> error: supervisor.mode must be one of: {modes} + +Why: The supervisor mode was not one this version knows, and an unknown mode is refused rather than read as advise. + +What to do: Use "off", "advise" or "act". + +### OSW-HRN-053 + +> error: supervisor.mode "act" is set in a change's own harness.json, never for the whole workspace: it may start another agent unattended + +Why: Acting may start another agent, which is a change's own decision, not a workspace default. + +What to do: Move supervisor.mode "act" into the change's own harness.json. + +### OSW-HRN-054 + +> error: supervisor.{key} must be a positive whole number of seconds + +Why: A supervisor threshold was not a positive whole number of seconds. + +What to do: Give it a positive whole number. + +### OSW-HRN-055 + +> error: supervisor.{key} must be a boolean + +Why: A supervisor switch was not true or false. + +What to do: Set it to true or false. + +### OSW-HRN-056 + +> error: supervisor.{key} is set in a change's own harness.json, never for the whole workspace + +Why: Allowing a cost or provider change is a decision only one change may make. + +What to do: Move the key into the change's own harness.json. + +### OSW-HRN-057 + +> error: supervisor.fallback must be an object of stages + +Why: The fallback section was written as something other than an object keyed by stage. + +What to do: Write it as an object mapping stage names to agent lists. + +### OSW-HRN-058 + +> error: supervisor.fallback.{stage} names no stage that runs an agent + +Why: A fallback was declared for a stage that invokes no agent. + +What to do: Remove the entry, or name a stage that runs an agent. + +### OSW-HRN-059 + +> error: supervisor.fallback.{stage} must be a non-empty list of agent ids + +Why: A fallback for a stage was written as something other than a non-empty list of agent ids. + +What to do: Give the stage a non-empty list of registered agent ids. + +### OSW-HRN-060 + +> error: supervisor.fallback.{stage} may not name {agent}: a chain cannot hand a stage it attempts again to the editor's chat + +Why: A chain retries a stage by spawning a CLI agent, which the editor's chat cannot be. + +What to do: Use a CLI agent in the fallback list. + +### OSW-HRN-061 + +> error: supervisor.fallback.{stage} names "{agent}", which is not a registered agent + +Why: A fallback names an agent id no registered agent has. + +What to do: Use a registered agent id, or install that agent. + +### OSW-HRN-062 + +> error: supervisor.fallback.{stage} names "{agent}" twice + +Why: A fallback list that repeats an agent would try it twice for one failure. + +What to do: Remove the duplicate entry. + +### OSW-HRN-063 + +> error: checkpoints must be an object + +Why: The checkpoints section was written as something other than an object. + +What to do: Write it as an object with requireConfirmationBetweenSteps. + +### OSW-HRN-064 + +> error: checkpoints.requireConfirmationBetweenSteps must be a boolean + +Why: The between-stage confirmation switch was not true or false. + +What to do: Set requireConfirmationBetweenSteps to true or false. + +### OSW-HRN-065 + +> error: budget must be an object + +Why: The budget section was written as something other than an object of ceilings. + +What to do: Write it as an object of the accepted budget keys. + +### OSW-HRN-066 + +> error: budget.maxCostUsd must be a positive number + +Why: The whole-chain dollar ceiling was not a positive finite number. + +What to do: Give it a positive number. + +### OSW-HRN-067 + +> error: budget.maxTokens must be a positive integer + +Why: The whole-chain token ceiling was not a positive whole number. + +What to do: Give it a positive whole number. + +### OSW-HRN-068 + +> error: budget.maxStageCostUsd must be a positive number + +Why: The per-stage dollar ceiling was not a positive finite number. + +What to do: Give it a positive number. + +### OSW-HRN-069 + +> error: budget.maxStageTokens must be a positive integer + +Why: The per-stage token ceiling was not a positive whole number. + +What to do: Give it a positive whole number. + +### OSW-HRN-070 + +> error: budget.maxContextShare must be a share above 0 and at most 1, such as 0.8 for eighty percent + +Why: The context ceiling is a share, and a value like 80 would never fire. + +What to do: Write the share as a fraction above 0 and at most 1. + +### OSW-HRN-071 + +> error: budget.maxStageCostUsd ({stage}) must not exceed budget.maxCostUsd ({chain}), because the chain ceiling would stop the run first and the stage ceiling could never fire + +Why: A per-stage ceiling above the chain ceiling can never be reached. + +What to do: Lower the stage ceiling, or raise the chain ceiling above it. + +### OSW-HRN-072 + +> error: budget.maxStageTokens ({stage}) must not exceed budget.maxTokens ({chain}), because the chain ceiling would stop the run first and the stage ceiling could never fire + +Why: A per-stage token ceiling above the chain ceiling can never be reached. + +What to do: Lower the stage ceiling, or raise the chain ceiling above it. + +### OSW-HRN-073 + +> error: timeout must be an object + +Why: The timeout section was written as something other than an object of limits. + +What to do: Write it as an object with maxRunSeconds or maxStageSeconds. + +### OSW-HRN-074 + +> error: timeout.{key} must be a positive integer number of seconds + +Why: A time ceiling was not a positive whole number of seconds. + +What to do: Give it a positive whole number of seconds. + +### OSW-HRN-075 + +> error: timeout.maxStageSeconds ({stage}) must not exceed timeout.maxRunSeconds ({run}), because the run ceiling would stop the chain first and the stage ceiling could never fire + +Why: A per-stage time limit above the run limit can never be reached. + +What to do: Lower the stage limit, or raise the run limit above it. + +### OSW-HRN-076 + +> error: skipStages must be a list of stage names + +Why: The skipStages value was written as something other than a list of stage names. + +What to do: Write it as a list, such as ["review"]. + +### OSW-HRN-077 + +> error: skipStages may leave out only "review"; "{stage}" is not a stage it can skip + +Why: Only the review stage may be left out of a chain. + +What to do: Remove the stage name, or use "review". + +### OSW-HRN-078 + +> error: skipStages names "{stage}" twice + +Why: A stage listed twice would be skipped twice for no reason. + +What to do: Remove the duplicate. + +### OSW-HRN-079 + +> error: maxStageAttempts must be a positive integer + +Why: The stage attempt ceiling was not a positive whole number. + +What to do: Give it a positive whole number. + +### OSW-HRN-080 + +> error: gitStageAllowlist must be an object + +Why: The allowlist was written as something other than an object of patterns. + +What to do: Write it as an object with remotes and branches. + +### OSW-HRN-081 + +> error: gitStageAllowlist has unknown key "{key}" (accepted keys: {keys}) + +Why: The allowlist carried a key this product does not read. + +What to do: Remove the key or use one of the accepted keys named. + +### OSW-HRN-082 + +> error: gitStageAllowlist.remotes must be a non-empty string array + +Why: The remotes pattern list was empty or held something that is not a string. + +What to do: Give it a non-empty list of remote patterns. + +### OSW-HRN-083 + +> error: gitStageAllowlist.branches must be a non-empty string array + +Why: The branches pattern list was empty or held something that is not a string. + +What to do: Give it a non-empty list of branch patterns. + +### OSW-HRN-084 + +> error: unrecognized top-level key "{key}" (accepted keys: {keys}).{hint} + +Why: A harness file carried a top-level key the schema does not define, which would otherwise be silently ignored. + +What to do: Remove the key, or use the accepted key it was meant to be. + +### OSW-HRN-085 + +> error: root value must be an object + +Why: The harness file's JSON was not an object at all. + +What to do: Write the file as a JSON object of settings. + +### OSW-HRN-086 + +> error: allowAgentMessages must be a boolean + +Why: The switch that lets a run take another run's messages was not true or false. + +What to do: Set allowAgentMessages to true or false. + +### OSW-HRN-087 + +> error: branches must be an object + +Why: The branches section was written as something other than an object. + +What to do: Write it as an object with rebaseWhenBehind or followMain. + +### OSW-HRN-088 + +> error: branches has no key "{key}"; the ones it takes are rebaseWhenBehind and followMain + +Why: The branches section carried a key this product does not read. + +What to do: Remove the key, or use rebaseWhenBehind or followMain. + +### OSW-HRN-089 + +> error: branches.{key} must be a boolean + +Why: A branch switch was not true or false. + +What to do: Set it to true or false. + +### OSW-HRN-090 + +> error: archive must be an object + +Why: The archive section was written as something other than an object. + +What to do: Write it as an object with whenLanded. + +### OSW-HRN-091 + +> error: archive has no key "{key}"; the one it takes is whenLanded + +Why: The archive section carried a key this product does not read. + +What to do: Remove the key, or use whenLanded. + +### OSW-HRN-092 + +> error: archive.whenLanded must be a boolean + +Why: The landed-archive switch was not true or false. + +What to do: Set archive.whenLanded to true or false. + +### OSW-HRN-093 + +> error: steps must be an array + +Why: The declared steps were written as something other than a list. + +What to do: Write steps as a JSON array of step declarations. + +### OSW-HRN-094 + +> error: {label} must be an object + +Why: One declared step was written as something other than an object. + +What to do: Write that step as an object with step and exactly one of before or after. + +### OSW-HRN-095 + +> error: unrecognized key "{key}" in {label} (accepted keys: {keys}) + +Why: A declared step carried a key this product does not read. + +What to do: Remove the key or use one of the accepted keys named. + +### OSW-HRN-096 + +> error: {label}.step is not a declared step (expected one of: {steps}) + +Why: The step name is not in the closed registry of steps this product runs. + +What to do: Use one of the listed step names. + +### OSW-HRN-097 + +> error: {label} must state exactly one of "before" or "after"{suffix} + +Why: A declared step must be placed against exactly one fixed stage. + +What to do: State exactly one position: before or after. + +### OSW-HRN-098 + +> error: {label}.{position} must name a stage (expected one of: {stages}) — a step is placed against the fixed sequence, never against another declared step + +Why: A declared step is placed against a fixed stage, not against another declared step. + +What to do: Use one of the listed fixed stage names. + +### OSW-HRN-099 + +> error: {label}.param must be a string + +Why: The step's argument was written as something other than text. + +What to do: Write the param as a string, or remove it. + +### OSW-HRN-100 + +> error: {label}.param is required for step "{step}" + +Why: This step cannot run without its own argument. + +What to do: Add the param the step needs. + +### OSW-HRN-101 + +> error: {label}.maxWaitSeconds must be a positive number of seconds + +Why: The waiting step's time limit was not a positive finite number of seconds. + +What to do: Give it a positive number of seconds. + +### OSW-HRN-102 + +> error: steps[{index}] ({step}) is placed {position} "{stage}", which skipStages leaves out; place it against another stage + +Why: A declared step placed against a skipped stage would never run and would say nothing. + +What to do: Place the step against a stage that is not skipped. + +### OSW-HRN-103 + +> warning: "{agent}" answers in text and edits no file, so "{stage}" on it writes nothing: the proposal, the implementation and the ticked tasks are files. It can review. + +Why: The stage's work must be left in files, and this agent only answers in text. + +What to do: Choose an agent that edits files for this stage, or accept that the stage writes nothing. + +### OSW-HRN-104 + +> warning: "{agent}" never says how full its context is, so budget.maxContextShare cannot act on "{stage}". That ceiling reads a figure only an ACP agent sends. + +Why: The agent sends no context gauge, so the context ceiling has no figure to compare. + +What to do: Use an agent that sends a context gauge, or drop budget.maxContextShare. + +### OSW-HRN-105 + +> warning: "{agent}" has never been observed reporting its usage here, so whether a spending ceiling can act on "{stage}" is not known. A time ceiling needs no report and applies regardless. + +Why: The agent's usage reporting has never been watched, so no ceiling's effect can be asserted. + +What to do: Configure a time ceiling, which needs no report, and watch a run to learn what the agent reports. + +### OSW-HRN-106 + +> warning: "{agent}" reports no usage at all, so no spending ceiling can act on "{stage}" however large the spend. + +Why: The agent reports nothing for a spending ceiling to compare against. + +What to do: Use a time or context ceiling, or choose an agent that reports usage. + +### OSW-HRN-107 + +> warning: "{stage}" can run without any bound: "{agent}" reports nothing for a spending ceiling to compare, and no timeout is configured. A time ceiling is the only one that applies here. + +Why: Nothing bounds the stage: the agent reports no usage and no time limit is set. + +What to do: Configure timeout.maxStageSeconds or maxRunSeconds for the stage. + +### OSW-HRN-108 + +> warning: "{agent}" reports tokens and no cost, so a cost ceiling cannot act on "{stage}". A token ceiling is the one that can. + +Why: The agent counts tokens but not money, so a dollar ceiling has nothing to compare. + +What to do: Use budget.maxTokens or budget.maxStageTokens instead of a cost ceiling. + +### OSW-HRN-109 + +> warning: budget.maxCost sets a ceiling in "{unit}", and "{agent}" is billed in {billing}, so that ceiling cannot act on "{stage}". The ceiling in its own unit is the one that can. + +Why: The ceiling's unit does not match the unit the agent is billed in, so nothing is ever converted. + +What to do: Set the ceiling in the unit the agent is billed in. + +### OSW-HRN-110 + +> warning: A token ceiling counts input plus output only, and "{agent}" moves most of its tokens through cache — a measured run recorded 1,693,507 cache tokens against 8,322 counted ones, so a token ceiling will rarely act on "{stage}". A cost ceiling acts on it directly. + +Why: A cache-heavy agent's counted tokens are a small fraction of what it moves, so a token ceiling rarely fires. + +What to do: Use a cost ceiling, or expect the token ceiling to act late. + +### OSW-HRN-111 + +> error: "{change}" is not a valid change name + +Why: The chain was started with a name no change may have. + +What to do: Name the change as its folder under openspec/changes is named. + +### OSW-HRN-112 + +> error: no active change named "{change}" in {directory} — an archived change is finished and is not re-run + +Why: No active change of that name exists, and an archived change is not run again. + +What to do: Check the name, or create the change first. + +### OSW-HRN-113 + +> error: this change's harness configuration could not be read: {reason} + +Why: The change's harness.json is malformed or invalid, so the chain cannot know how to run. + +What to do: Fix the configuration the reason names, then start the chain again. + +### OSW-HRN-114 + +> error: this change's autonomyLevel is "{level}", which starts one stage at a time through a picker rather than running a chain. There is nothing to open here; start the stage from the standalone shell or the VS Code command, or set a chain-running autonomy level for this change. + +Why: The change's autonomy level runs stages one at a time, so there is no chain to start. + +What to do: Start the stage individually, or set a chain-running autonomy level for the change. + +### OSW-HRN-115 + +> error: this change's configuration pauses between stages for a confirmation, and there is no terminal here to put that choice to. Run it where somebody can answer, or set checkpoints.requireConfirmationBetweenSteps to false in openspec/changes/{change}/harness.json to say that this change may run unattended. + +Why: The change asks for a confirmation between stages, and nothing here can put that question to a person. + +What to do: Run it where somebody can answer, or turn the confirmation off in the change's harness.json. + +### OSW-HRN-116 + +> error: this change declares that it is blocked by {blockers}, which {verb} still active. Land it and archive it, or remove the line from openspec/changes/{change}/.openspec.yaml — there is no option that starts it anyway, because the declaration is a sentence its author wrote in a file under version control. + +Why: The change declares a blocker that is still active, and the declaration itself refuses the run. + +What to do: Land and archive the blocker, or remove the blocking line from the change's .openspec.yaml. + +### OSW-HRN-117 + +> error: steps[{index}] waits for "{change}", which is this change itself — a change cannot wait for itself, and this reads as the name of another one + +Why: A declared await-change step names the change it belongs to, which would wait forever. + +What to do: Point the step at the change it actually waits for. + +### OSW-HRN-118 + +> error: the "{stage}" stage names agent "{agent}", which this build has no runner for + +Why: This build ships no runner for the agent the stage names. + +What to do: Install that agent or choose one this build can run. + +### OSW-HRN-119 + +> error: the "{stage}" stage has no agent configured and this build has no default runner + +Why: The stage names no agent and this build has no default runner to fall back to. + +What to do: Set an agent for the stage in the change's harness.json. + +### OSW-HRN-120 + +> warning: {agent} is not a registered agent + +Why: A fallback candidate is not one of the agents this build knows. + +What to do: Add the agent to the fallback list only if this build ships it. + +### OSW-HRN-121 + +> warning: {agent} is another provider ({provider}), and allowProviderChange is false + +Why: Moving the stage to that agent would send the change's files to another company, which this change has not allowed. + +What to do: Set supervisor.allowProviderChange to true, or choose a fallback on the same provider. + +### OSW-HRN-122 + +> warning: {agent} may cost money, and allowCostIncrease is false + +Why: Moving the stage to that agent may cost more, which this change has not allowed. + +What to do: Set supervisor.allowCostIncrease to true, or choose a free fallback. + +### OSW-HRN-123 + +> info: the cause is not known, so the stage is neither repeated nor moved + +Why: The failure's cause could not be diagnosed, so the supervisor cannot tell whether repeating or moving would help. + +What to do: Read the run's reply and diagnose the failure before acting. + +### OSW-HRN-124 + +> info: {cause}, and {stage} names no fallback agent + +Why: The stage has no fallback configured, so the supervisor has nowhere to move it. + +What to do: Add a fallback list for the stage if a move might help. + +### OSW-HRN-125 + +> info: {cause}, and the policy refuses every fallback left: {refusals} + +Why: Every fallback the stage names is refused by the change's policy, so no move is possible. + +What to do: Allow the cost or provider change, or add a fallback the policy permits. + +### OSW-HRN-126 + +> info: {cause}, and every fallback for {stage} has been tried + +Why: The stage has already run on each of its fallbacks, so none is left to try. + +What to do: Fix the underlying cause, or add a further fallback. + +### OSW-HRN-127 + +> info: {cause} (passed over: {refusals}) + +Why: The stage moved to a fallback and this names the ones skipped on the way, so a person can see why. + +What to do: Nothing, unless the passed-over agents were the ones you wanted. + +## AGT: agents and adapters: missing, signed out, failed + +### OSW-AGT-001 + +> warning: not valid JSON + +Why: A status record or roster file on disk could not be read as JSON. + +What to do: Report it; the file is corrupt and the record it was meant to carry is not trusted. + +### OSW-AGT-002 + +> warning: its signed payload is not valid JSON + +Why: A signed status record's payload did not parse after its signature verified. + +What to do: Report it; the record cannot be trusted and is not read. + +### OSW-AGT-003 + +> warning: missing or invalid required fields + +Why: A status record or roster entry lacks a field the reader needs to trust it. + +What to do: Report it; the file is written wrongly and is not trusted. + +### OSW-AGT-004 + +> warning: record identity "{instanceId}" does not match file name "{expectedId}" + +Why: A status record names an instance other than the file it was found in. + +What to do: Report it; the record is not taken as the run's own. + +### OSW-AGT-005 + +> warning: activityAt or heartbeatAt is not a valid timestamp + +Why: A status record's activity or heartbeat time could not be read as a date. + +What to do: Report it; the record's age cannot be judged. + +### OSW-AGT-006 + +> warning: not an entry + +Why: A roster file did not hold a JSON object. + +What to do: Report it; the file is not taken as an enrolment. + +### OSW-AGT-007 + +> warning: its file name does not match its key id "{keyId}" + +Why: A roster file is named for a key id other than the one it holds. + +What to do: Report it; the entry is not trusted. + +### OSW-AGT-008 + +> warning: its key does not match its key id + +Why: A roster entry's public key does not match the key id it claims. + +What to do: Report it; the entry is not trusted. + +### OSW-AGT-009 + +> error: the key offered for {keyId} does not match that key id + +Why: An enrolment was offered a public key that does not match the key id it names. + +What to do: Read the machine's key again, and enrol only a key that matches its id. + +### OSW-AGT-010 + +> error: {keyId} is already enrolled with a different key + +Why: A key id is already enrolled here under another public key. + +What to do: Remove the old enrolment, or enrol the machine under a new key. + +### OSW-AGT-101 + +> warning: not verified + +Why: A stop request or message was not signed by a key the roster knows. + +What to do: Enrol the sender's key, or ask from a machine whose key is enrolled. + +### OSW-AGT-102 + +> warning: stale + +Why: A stop request or message arrived after the window in which a run acts on it. + +What to do: Ask again from the run's card while the run is live. + +### OSW-AGT-103 + +> warning: already read + +Why: The same stop request or message was read and acted on before. + +What to do: Nothing; a request is read once and not acted on twice. + +### OSW-AGT-104 + +> warning: from a run, which this run does not take + +Why: A message was written by another run, and this run does not take instructions from a run. + +What to do: Turn on allowAgentMessages for this run, or send the message as a person. + +### OSW-AGT-201 + +> error: the agent is not installed + +Why: The agent's executable was not found, so the run could not start it. + +What to do: Install the agent, or choose another agent for the stage. + +### OSW-AGT-202 + +> error: the agent is not signed in + +Why: The agent refused the work because no signed-in account was found. + +What to do: Sign the agent in, then start the run again. + +### OSW-AGT-203 + +> error: the machine blocked the agent + +Why: Security software or file permissions stopped the agent from a file or a program. + +What to do: Allow the agent there, then start the run again. + +### OSW-AGT-204 + +> error: the network could not be reached + +Why: The agent could not reach the service it works through. + +What to do: Check the address, the connection and any system proxy, then start the run again. + +### OSW-AGT-205 + +> warning: the service rate-limited the agent + +Why: The service limited how often it may be asked. + +What to do: Wait for the limit to pass, then start the run again. + +### OSW-AGT-206 + +> error: the service failed + +Why: The service failed on its own side while the run was working. + +What to do: Start the run again; the service's own failure often passes. + +### OSW-AGT-207 + +> info: the cause is not known + +Why: The failure's reason and output matched no cause this product knows. + +What to do: Read the run's output, and report it if nothing there explains the failure. + +### OSW-AGT-208 + +> warning: repeating will not help + +Why: The cause is one a repeat of the run meets again unchanged. + +What to do: Do what the remedy says instead of starting the run again. + +### OSW-AGT-209 + +> info: repeating is likely to help + +Why: The cause is one that passes by itself. + +What to do: Start the run again after a moment. + +### OSW-AGT-210 + +> info: whether repeating helps is not known + +Why: No cause was recognised, so whether a repeat helps is unknown. + +What to do: Read the run's output before deciding to start it again. + +### OSW-AGT-211 + +> info: Install `{executable}` and make sure it is on the PATH, or choose another agent for this stage. + +Why: The agent's executable is not installed, and this names it. + +What to do: Install it and put it on the PATH, or choose another agent for the stage. + +### OSW-AGT-212 + +> info: Install the agent and make sure it is on the PATH, or choose another agent for this stage. + +Why: The agent is not installed and its executable is not known by name. + +What to do: Install the agent and put it on the PATH, or choose another agent for the stage. + +### OSW-AGT-213 + +> info: Run `{executable}` in a terminal and sign in, then start the run again. + +Why: The agent is not signed in, and this names the executable that signs it in. + +What to do: Run it in a terminal, sign in, then start the run again. + +### OSW-AGT-214 + +> info: Sign the agent in, then start the run again. + +Why: The agent is not signed in and its executable is not known by name. + +What to do: Sign the agent in, then start the run again. + +### OSW-AGT-215 + +> info: Something on this machine refused the agent a file or a program: security software, or the file's permissions. Allow it there, then start the run again. + +Why: The machine, not the service, blocked the agent from a file or a program. + +What to do: Allow the agent in the security software or the file's permissions, then start the run again. + +### OSW-AGT-216 + +> info: The service limited how often it may be asked. Wait, then start the run again. + +Why: The service rate-limited the run, and waiting is what clears it. + +What to do: Wait for the limit to pass, then start the run again. + +### OSW-AGT-217 + +> info: The service failed on its side. Starting the run again is likely to work. + +Why: The service, not the run, failed, and such a failure often passes. + +What to do: Start the run again. + +### OSW-AGT-218 + +> error: agent refused the prompt + +Why: The ACP agent ended its turn with a stop reason of refusal. + +What to do: Read what the agent said, then start the run again or change the prompt. + +### OSW-AGT-219 + +> error: cancellation could not be carried out: {killFailure}. The agent process may still be running. + +Why: The cancellation could not kill the agent's process tree, for the reason given. + +What to do: Check for the agent's process and terminate it by hand if it is still running. + +### OSW-AGT-220 + +> error: the agent process did not exit within {seconds}s of being terminated, and may still be running. + +Why: The ACP agent's process outlived the confirmation window after being terminated. + +What to do: Check for the agent's process and terminate it by hand if it is still running. + +### OSW-AGT-221 + +> error: no agent available to run the "{stage}" stage + +Why: This host resolved no runner for the stage's agent, so the stage can run no work and the chain fails. + +What to do: Configure or install an agent for that stage (or sign in the one it names), then run the change again. + +## TSK: tasks: ticks, Human-only, delegation + +### OSW-TSK-001 + +> error: change "{change}" has no tasks.md + +Why: A delegated item was asked for a change whose tasks.md does not exist. + +What to do: Check the change name, and that its tasks.md exists. + +### OSW-TSK-002 + +> error: no task at line {line} of {change}/tasks.md + +Why: The inbox asked to run the item at a line the change's task list does not have. + +What to do: Refresh the inbox and choose the item again. + +### OSW-TSK-003 + +> error: task at line {line} of {change}/tasks.md is already ticked + +Why: A delegated run was asked for an item that is already closed. + +What to do: Refresh the inbox and run an item that is still open. + +### OSW-TSK-004 + +> error: the item at line {line} of {change}/tasks.md is marked for a person, and no agent is offered for it + +Why: An item marked Human-only is for a person, and delegation is refused rather than quietly offered to an agent. + +What to do: Do the item yourself, or remove the Human-only marking if an agent may do it. + +### OSW-TSK-005 + +> error: the item at line {line} of {change}/tasks.md names no agent + +Why: The item is not delegated to anything, so there is no agent to run. + +What to do: Name an agent in the item's "Delegated to " marker or in the change's taskAgents. + +### OSW-TSK-006 + +> error: "{agent}" is not an agent this build recognises + +Why: The item names an agent id that is not in this build's registry. + +What to do: Correct the id, or use an agent this build carries. + +### OSW-TSK-007 + +> error: change "{change}" was not found in this workspace + +Why: The change is not among the workspace's active changes, so its directory cannot be used. + +What to do: Refresh the Pipeline and check that the change is active. + +### OSW-TSK-008 + +> error: no runner is configured for agent "{agent}" + +Why: The agent id is known, but the host has no runner registered for it. + +What to do: Configure the agent in the host, or choose another agent. + +### OSW-TSK-009 + +> error: the line at {line} of {change}/tasks.md is not a checklist item + +Why: The line the inbox reported no longer holds a checklist item. + +What to do: Refresh the inbox and choose the item again. + +### OSW-TSK-010 + +> error: Unknown mechanical check "{name}" (expected one of: {checks}) + +Why: A task's check(...) declaration names a mechanical check outside the registry. + +What to do: Use one of the checks listed, or correct the name in tasks.md. + +### OSW-TSK-011 + +> error: Invalid parameter for check "{name}" ("{param}"): {reason} + +Why: A task's mechanical check declaration carries a parameter the check cannot take. + +What to do: Correct the parameter in tasks.md as the reason says. + +### OSW-TSK-012 + +> error: path-unchanged requires a repository-relative path parameter + +Why: A path-unchanged check was declared with no parameter, so it could never run. + +What to do: Give the check a repository-relative path parameter. + +### OSW-TSK-013 + +> error: must be a repository-relative path, not absolute and not containing ".." + +Why: A check parameter is absolute or traverses upward, so it cannot name a file inside the repository. + +What to do: Use a path relative to the repository root and without "..". + +### OSW-TSK-014 + +> error: Task list for {change} has changed since it was last loaded — refresh and try again. + +Why: tasks.md was rewritten between reading and writing, so a stale line would have been changed. + +What to do: Refresh the task list and make the change again. + +### OSW-TSK-015 + +> info: a person + +Why: An open item waits on a person rather than an agent. + +What to do: Nothing: do the item when you reach it. + +### OSW-TSK-016 + +> warning: "{agent}", which is not a registered agent + +Why: An item names an agent id the workspace's registry does not carry, so it only looks assigned. + +What to do: Correct the id in the task text or in the change's taskAgents. + +### OSW-TSK-017 + +> warning: {waiting} (this change's harness.json names it; the task text names {textagent}) + +Why: The change's configuration and the task's own text name different agents for one item. + +What to do: Settle which agent owns the item, in harness.json or in the task text. + +### OSW-TSK-018 + +> warning: {change} names {agent} for task {task}, but {where} + +Why: A change's taskAgents key names an agent for a task that is gone or waits on a person. + +What to do: Remove or correct the taskAgents key named. + +### OSW-TSK-019 + +> info: No active change to look at.{enrolments} + +Why: The workspace holds no active change, so the inbox has nothing to read. + +What to do: Open or create a change, or check the workspace root. + +### OSW-TSK-020 + +> info: Nothing is waiting — {changes} read.{stale} + +Why: Active changes were read and none holds an item waiting on a person or an agent. + +What to do: Nothing, unless a change that should be listed is missing from those read. + +### OSW-TSK-021 + +> info: {items} items waiting, across {changes} of {total} active changes: {waiting}.{stale} + +Why: Open items wait across the workspace, some on a person and some on an agent. + +What to do: Work the items, or hand the agents' items to an agent. + +### OSW-TSK-022 + +> warning: {count} harness.json could not be read, so their items are resolved from the task text alone: {listed}. + +Why: A change's taskAgents configuration is unreadable, so its delegation is read from task text alone. + +What to do: Fix the change's harness.json so its delegation is read. + +### OSW-TSK-023 + +> warning: 1 key that signs a run waits to be enrolled. + +Why: A key that signs a live run is not enrolled, so a person must confirm it was them. + +What to do: Enrol the key from the change's card or the team surface. + +### OSW-TSK-024 + +> warning: {count} keys that sign runs wait to be enrolled. + +Why: Keys that sign live runs are not enrolled, so a person must confirm each one. + +What to do: Enrol the keys from the changes' cards or the team surface. + +### OSW-TSK-025 + +> warning: 1 question from an agent waits for your answer. + +Why: An agent's question holds a run until the operator answers it. + +What to do: Answer the question on the change's card or in decisions.md. + +### OSW-TSK-026 + +> warning: {count} questions from agents wait for your answer. + +Why: Agents' questions hold runs until the operator answers them. + +What to do: Answer the questions on the changes' cards or in decisions.md. + +### OSW-TSK-027 + +> warning: {count} taskAgents entry(ies) matched no open task: {listed}. + +Why: A change's taskAgents names agents for tasks that are not open, so the configuration has gone stale. + +What to do: Remove or correct the taskAgents keys named. + +### OSW-TSK-028 + +> error: What is waiting could not be read: {reason}. + +Why: The inbox read failed, so what is waiting is unknown rather than empty. + +What to do: Read the reason, fix what it names, and reload the inbox. + +### OSW-TSK-029 + +> error: {ending} {item} came back ticked with nothing written, so the tick was reverted and the run is refused. + +Why: A delegated run ticked its item while adding no evidence, so the rubber-stamp gate rolled the tick back. + +What to do: Read the run's reply, write the evidence, then tick the item or run it again. + +### OSW-TSK-030 + +> info: {ending} {item} is ticked and its text carries more than it did. Something was recorded; whether it is true was not checked. + +Why: A delegated run ticked its item and wrote something, which the narrow gate accepts without judging truth. + +What to do: Read what was recorded and judge whether it is the evidence the task asked for. + +### OSW-TSK-031 + +> warning: {ending} {item} could not be found again afterwards, so nothing was compared: {reason}. + +Why: The item moved or vanished after the run, so the gate reports that it could not compare rather than assuming. + +What to do: Find the item and compare its text with what the run wrote. + +### OSW-TSK-032 + +> warning: {ending} {item} is still open. + +Why: The delegated run ended without ticking the item it was asked to close. + +What to do: Read the run's reply and close the item, or run it again. + +### OSW-TSK-033 + +> warning: no open task numbered {task} is left in {change}/tasks.md + +Why: The task the gate compares by number is no longer open in tasks.md after the run. + +What to do: Check whether the task was closed, renumbered or removed during the run. + +### OSW-TSK-034 + +> warning: line {line} of {change}/tasks.md is no longer a checklist item + +Why: The item's line no longer holds a checklist item after the run. + +What to do: Check what the run changed around that line. + +### OSW-TSK-035 + +> error: the item became ticked while saying nothing it did not say before + +Why: The run ticked the item without writing any new evidence, so the gate reverts the tick. + +What to do: Read the run's reply, write the evidence, and tick the item. + +### OSW-TSK-036 + +> warning: {reason}. It last said: {said} + +Why: A failed or cancelled run ended for the given reason, and the end of its stderr is kept for the reader. + +What to do: Read the last words and put right what stopped the run. + +### OSW-TSK-101 + +> warning: a request to stop after {task} named a task this change does not have; the run goes on + +Why: The stop named a task number the change's task list does not contain, and that cannot be read as either "stop now" or "never stop". + +What to do: Check the task number against tasks.md and ask the run to stop after a task it actually has. + +## VAL: validation, the merge gate, checks + +### OSW-VAL-001 + +> error: "path-unchanged" requires a repository-relative path parameter + +Why: The path-unchanged check was named in a task line without the repository-relative path it must watch. + +What to do: Add the path after the check name, as in `path-unchanged: packages/core/src`. + +### OSW-VAL-002 + +> error: path "{path}" resolves outside the workspace "{root}" + +Why: A path-unchanged parameter resolved outside the workspace, which a check must never reach. + +What to do: Correct the path in the task line so that it stays inside the workspace. + +### OSW-VAL-003 + +> error: Unknown mechanical check "{name}" (expected one of: {names}) + +Why: A check name reached the runner that the closed registry does not contain, which is a host's fault rather than a person's. + +What to do: Report it with the task line that named the check, since only the listed names may run. + +### OSW-VAL-004 + +> error: No change named {change} + +Why: A relation edit named a change the workspace does not have. + +What to do: Use the id of a change that exists, refreshing the Pipeline if the name came from a card. + +### OSW-VAL-005 + +> error: {change} is archived, and an archived change's relations are history + +Why: A relation edit targeted an archived change, whose relations are fixed history. + +What to do: Edit an active change's relations instead, or restore the change first. + +### OSW-VAL-006 + +> error: {path} cannot be read as relations: {error} + +Why: The change's metadata holds relation lines that the reader cannot parse. + +What to do: Fix the follows, supersedes or blocked_by lines in the file by hand, then try the edit again. + +### OSW-VAL-007 + +> error: The edit names no change to relate + +Why: A relation edit arrived with neither a change to add nor one to remove. + +What to do: Give the edit exactly one change to add or remove. + +### OSW-VAL-008 + +> error: A change cannot state {key} on itself + +Why: A relation edit would make the change state a relation on itself. + +What to do: Name a different change. + +### OSW-VAL-009 + +> error: No change named {change}, active or archived + +Why: The change being added as a relation exists neither active nor archived. + +What to do: Use the id of an existing change, or create that change first. + +### OSW-VAL-010 + +> error: That would close a cycle: {cycle} + +Why: The added relation would make the change graph circular, which the relation gate refuses. + +What to do: Choose a relation that keeps the graph acyclic, following the named path. + +### OSW-VAL-101 + +> error: openspec change validate --strict {change}: {count} item(s) failed ({ids}) + +Why: Strict OpenSpec validation of the change found items that do not hold. + +What to do: Fix the items named, then run the check again. + +### OSW-VAL-102 + +> info: openspec change validate --strict {change}: {count} item(s) passed + +Why: Strict OpenSpec validation of the change passed every item. + +What to do: Nothing. + +### OSW-VAL-103 + +> error: openspec change validate --strict {change} failed to run: {error} + +Why: The strict validation command could not be started or threw. + +What to do: Fix the reported tooling problem, then run the check again. + +### OSW-VAL-104 + +> error: npm run {script} failed to run: {error} + +Why: An npm script that the check runs could not be started. + +What to do: Fix the reported npm or script problem, then run the check again. + +### OSW-VAL-105 + +> info: npm run {script} exited 0 + +Why: The npm script that the check runs finished successfully. + +What to do: Nothing. + +### OSW-VAL-106 + +> error: npm run {script} exited {code}: +{output} + +Why: The npm script that the check runs finished with a non-zero exit code. + +What to do: Read the output shown, fix what failed, then run the check again. + +### OSW-VAL-107 + +> error: git diff -- {path} failed to run: {error} + +Why: The git command behind the path-unchanged check could not be started. + +What to do: Fix the reported git or filesystem problem, then run the check again. + +### OSW-VAL-108 + +> info: git diff -- {path} reported no changes + +Why: The watched path has no uncommitted changes, so the path-unchanged check passed. + +What to do: Nothing. + +### OSW-VAL-109 + +> error: git diff -- {path} reported uncommitted changes to "{path}" + +Why: The path that the check must leave unchanged has uncommitted changes. + +What to do: Commit, revert or move the changes at the named path. + +### OSW-VAL-110 + +> error: git diff -- {path} exited {code}: {output} + +Why: The git command behind the path-unchanged check exited with an unexpected code. + +What to do: Read the output shown, fix what git reported, then run the check again. + +### OSW-VAL-111 + +> error: no ".changeset/config.json" found under "{root}" — Changesets is not adopted + +Why: The changeset-present check ran where Changesets is not adopted. + +What to do: Adopt Changesets in the workspace, or remove that check from the task line. + +### OSW-VAL-112 + +> error: no pending ".changeset/*.md" file found — run "npx changeset" for "{change}" + +Why: The change has no pending changeset file, which the merge gate requires. + +What to do: Run `npx changeset` and commit the file it writes. + +### OSW-VAL-113 + +> info: {count} pending changeset file(s) found + +Why: The changeset-present check found the pending files it requires. + +What to do: Nothing. + +### OSW-VAL-114 + +> error: requirement header "{header}" does not exist in the current specification + +Why: A modified block names a requirement that the current specification no longer carries. + +What to do: Update the change's modified block to the header the current specification carries. + +### OSW-VAL-115 + +> error: scenario "{scenario}" under requirement "{header}" exists in the current specification but is omitted from this change's modified block + +Why: The current specification has a scenario that the change's modified block omits. + +What to do: Add the scenario to the modified block, or remove it from the specification. + +### OSW-VAL-116 + +> error: {change}'s tasks.md names "Successor created: `{successor}`" but no change `{successor}` states `follows: {change}` + +Why: A change's tasks.md promised a named successor that no change of that id follows. + +What to do: Create the successor with the relation stated, or correct the claim in tasks.md. + +### OSW-VAL-117 + +> info: Nothing recorded yet — no runs have been logged in this workspace. + +Why: The audit log has no entries, so there is no verify quality to report. + +What to do: Nothing. + +### OSW-VAL-118 + +> info: Nothing to report yet — {runs} runs recorded, none of which reached a verifying stage that reported what its checks found. + +Why: Runs are recorded, but none reached a verify stage that reported what its checks found. + +What to do: Nothing. + +### OSW-VAL-119 + +> info: {stages} verifying stage(s) recorded, none of which names the agent whose work was checked. They were recorded before that was written down, so none is charged to an agent. + +Why: The verify stages were recorded before the field naming the checked agent existed, so none is charged to an agent. + +What to do: Nothing, newer verifies record the agent whose work was checked. + +### OSW-VAL-120 + +> info: {stages} verifying stage(s) across {agents} checked agent(s){thinClause}{unnamedClause} + +Why: The verifying stages and their failures are reported per checked agent, with thin groups and unattributed entries named. + +What to do: Read the rows, and gather more verifies before trusting a thin group's rate. + +### OSW-VAL-121 + +> error: mechanical checks failed, verifying agent was not invoked: {summary} + +Why: A declared mechanical check failed at verify, and the chain deliberately spends no verifying run to learn what an exit code already said. + +What to do: Put right what the named checks reported (or finish the tasks they uncheck), then run the change again. + +## ARC: archiving + +### OSW-ARC-001 + +> error: initOpenSpec requires at least one tool + +Why: `initOpenSpec` was called with a tools list that held no usable name. + +What to do: Pass at least one tool name, or skip initialization. + +### OSW-ARC-002 + +> error: OpenSpec CLI returned {kind} JSON for '{command}'. Expected {contract}. Update OpenSpec CLI or OpenSpec Workbench to compatible versions. + +Why: The `openspec` CLI answered a command with JSON this Workbench cannot read. + +What to do: Update the OpenSpec CLI or OpenSpec Workbench so their versions agree. + +### OSW-ARC-003 + +> error: {binary} {args} exited with code {code}: {reason} + +Why: The `openspec` command exited non-zero, and its own diagnosis is quoted after the code. + +What to do: Read the diagnosis and put right what it names, then retry. + +### OSW-ARC-004 + +> error: could not archive "{change}": it still owes {debts} + +Why: Archiving is refused unless every task item is closed and anything closed has something written under it (a-change-is-archived-with-nothing-open). + +What to do: Finish the named tasks, writing a note under each closed one, then archive again. + +### OSW-ARC-005 + +> error: the archive did not happen, and no reason was given + +Why: The archive result carried neither an archived change nor any status entries to explain it. + +What to do: Run `openspec archive` by hand to see what it says. + +### OSW-ARC-006 + +> error: the archive did not happen, and the report gave no reason + +Why: The archive's status entries held no error message or code that could be reported. + +What to do: Run `openspec archive` by hand to see what it says. + +### OSW-ARC-007 + +> error: could not archive "{change}": {refusal} + +Why: The archive subcommand refused, and its reasons and remedies are reported together. + +What to do: Do what the refusal's reason and fix say, then archive the change again. + +### OSW-ARC-101 + +> error: archiving changed nothing, so there was nothing to commit + +Why: After archiving, git had staged nothing, so the branch would carry no archive. + +What to do: Find why the archive changed no file, then run the sweep again. + +### OSW-ARC-102 + +> error: {forge} could not be asked about pull requests: {reason} + +Why: The forge could not be asked about pull requests, so the pass cannot know what is due or already open. + +What to do: Fix the forge access the reason names, then sweep again. + +### OSW-ARC-103 + +> warning: its checks could not be read: {reason} + +Why: An open archive pull request's checks could not be read, so it cannot be merged yet. + +What to do: Nothing; the next pass reads them again. + +### OSW-ARC-104 + +> warning: a check failed + +Why: A check on the archive pull request failed, so the sweep will not merge it. + +What to do: Read the failed check and fix or re-run it. + +### OSW-ARC-105 + +> error: no merge method was tried + +Why: No merge method was available to try, so the archive pull request was left unmerged. + +What to do: Report it; a merge method should always be tried. + +### OSW-ARC-106 + +> error: {forge} refused the merge: {reason} + +Why: The forge refused every merge method tried for the archive pull request. + +What to do: Read the forge's reason and put right what it names, then wait for the next pass. + +### OSW-ARC-107 + +> error: {reason}; making it again on {ref} failed too: {failure} + +Why: The forge refused the merge and the rebuilt archive could not be pushed to its branch either. + +What to do: Fix what the second failure names, then sweep again. + +### OSW-ARC-108 + +> error: no reason given + +Why: A failure carried no message or telling git line that could be quoted. + +What to do: Run the command by hand to see why it failed. + +### OSW-ARC-201 + +> info: opened #{number} to archive {changes}, which landed with nothing open + +Why: The sweep found changes that landed with every task closed, and opened one pull request to archive them. + +What to do: Nothing; the sweep follows it and merges it once its checks pass. + +### OSW-ARC-202 + +> info: #{number} is merged by the sweep once its checks pass + +Why: The sweep merges the archive pull request itself rather than asking the forge to do it later. + +What to do: Nothing; wait for the checks. + +### OSW-ARC-203 + +> info: merged #{number} by {method}: its checks passed, or none ran + +Why: The archive pull request's checks had decided, or none ran, so the sweep merged it. + +What to do: Nothing. + +### OSW-ARC-204 + +> info: #{number} is waiting for its checks + +Why: The archive pull request's checks are still running. + +What to do: Nothing; the next pass reads them again. + +### OSW-ARC-205 + +> warning: #{number} was refused while {behind} {commits} behind the default branch, so the archive was made again on it and pushed; its checks run again + +Why: A forge that merges only what is up to date refused the branch, so the archive was rebuilt on the default branch. + +What to do: Nothing; wait for the new checks. + +### OSW-ARC-206 + +> warning: #{number} cannot merge yet: {reason} + +Why: The archive pull request is blocked, usually by a failed check or the forge's refusal. + +What to do: Read the reason and put right what it names. + +### OSW-ARC-207 + +> warning: could not archive {change}: {reason} + +Why: One change's archive failed while the pass was making the archive. + +What to do: Read the reason and archive that change again. + +### OSW-ARC-208 + +> error: {change} landed in #{number} but still owes {owed}, so it stays live: {owes} + +Why: A change landed while its task list still owed something, which the merge gate should have prevented. + +What to do: Report it, then close the named tasks or reopen the change. + +### OSW-ARC-209 + +> error: nothing was archived: {failed} + +Why: The pass could not make an archive pull request at all. + +What to do: Read the reason and fix what it names, then sweep again. + ## GIT: git, branches, worktrees, push, pull requests -### OSW-GIT-001 +### OSW-GIT-001 + +> warning: {name} has no worktree of its own: {why}. + +Why: Commit Change commits only in a change's own worktree, on its own branch. + +What to do: Make the change's worktree, or work the change there. + +### OSW-GIT-002 + +> warning: {name} is on {branch}, the default branch; nothing of a change is committed there. + +Why: A change reaches the default branch through its pull request, never by a commit on it (ADR 0043). + +What to do: Work the change on a branch of its own, named after it. + +### OSW-GIT-101 + +> error: Nothing of {name} was committed: {why} + +Why: git refused the commit: often no author is configured, or a hook failed. + +What to do: Put right what git says, then choose Commit Change again. + +### OSW-GIT-102 + +> error: The push of {branch} was refused{after}: {why} + +Why: The server, or the credentials git uses for it, refused the branch. A commit already made stays. + +What to do: Put right what the server says - access, credentials, a branch rule - then choose Commit Change again. + +### OSW-GIT-103 + +> error: git stage failed at commit: {why} + +Why: The git stage commits what the stages left before it pushes, and git refused that commit. + +What to do: Put right what git says in the change's worktree, then run the chain again from git. + +### OSW-GIT-201 + +> info: Committed {name} as {commit} and pushed it to {pushedTo}. + +Why: Commit Change committed what the change's worktree held, on its branch, and pushed the branch. + +What to do: Nothing: the change is on the server, where every directory, host and person can see it. + +### OSW-GIT-202 + +> info: {name} had nothing to commit; its branch is pushed to {pushedTo}. + +Why: Everything in the change's worktree was committed already, and the push brought the server up to date. + +What to do: Nothing. + +### OSW-GIT-203 + +> warning: {name} is made in {directory}, but not on the server: {why} + +Why: A new change is committed on its branch and pushed at once; that failed, so no other directory, host or person sees it yet. + +What to do: Put right what was refused, then choose Commit Change on the change's card. + +## WSP: the workspace: setup, lease, locks + +### OSW-WSP-001 + +> error: Another OpenSpec Workbench host ({hostKind} on {hostname}, pid {pid}{author}, last active {seconds}s ago) is currently running a mutating operation on this workspace. Wait for it to finish, or close it, before starting one here. + +Why: A live foreign host holds this workspace's lease, and only one mutating operation may run against a workspace at a time (ADR 0010). + +What to do: Wait for the named host to finish or close it, then start the operation again. + +### OSW-WSP-002 + +> error: the holder is on {holderhostname} and this is {thishostname}, so whether its process is still running cannot be checked from here. Clear it there, or wait for its lease to go stale. + +Why: A lease held on another machine cannot be shown to be dead from this one, so clearing it here could rob a running holder. + +What to do: Clear the lease on the machine named, or wait for its heartbeat to go stale. + +### OSW-WSP-003 + +> error: process {pid} is still running. Taking its lease would let a second mutating run start against files it still has open, which is what the lease prevents — stop that process instead. + +Why: The holder's process is still alive on this machine, so releasing its lease would let two mutating runs touch the same files. -> warning: {name} has no worktree of its own: {why}. +What to do: Stop the process named, or wait for it to end, then clear the lease. -Why: Commit Change commits only in a change's own worktree, on its own branch. +### OSW-WSP-004 -What to do: Make the change's worktree, or work the change there. +> warning: "{name}" is not a valid change name -### OSW-GIT-002 +Why: A working directory was asked for a change whose name no change may have, so no branch or directory of that name can be made. -> warning: {name} is on {branch}, the default branch; nothing of a change is committed there. +What to do: Name the change as its folder under openspec/changes is named. -Why: A change reaches the default branch through its pull request, never by a commit on it (ADR 0043). +### OSW-WSP-005 -What to do: Work the change on a branch of its own, named after it. +> warning: "{name}" is not in {base} -### OSW-GIT-101 +Why: The change exists only as uncommitted files, and the working directory is cut from a base that does not contain it. -> error: Nothing of {name} was committed: {why} +What to do: Commit the change's directory to the base first, or name a base that has it. -Why: git refused the commit: often no author is configured, or a hook failed. +### OSW-WSP-006 -What to do: Put right what git says, then choose Commit Change again. +> warning: branch "{name}" is already checked out at {path} -### OSW-GIT-102 +Why: A branch can be checked out in only one working directory, and this change's branch already has one. -> error: The push of {branch} was refused{after}: {why} +What to do: Run the chain in that directory, or remove it first. -Why: The server, or the credentials git uses for it, refused the branch. A commit already made stays. +### OSW-WSP-007 -What to do: Put right what the server says - access, credentials, a branch rule - then choose Commit Change again. +> warning: {path} already exists -### OSW-GIT-103 +Why: The place a working directory would be created is already occupied, so creating it would touch something this product did not make. -> error: git stage failed at commit: {why} +What to do: Remove it, or pass another path. -Why: The git stage commits what the stages left before it pushes, and git refused that commit. +### OSW-WSP-008 -What to do: Put right what git says in the change's worktree, then run the chain again from git. +> warning: branch "{name}" already exists, with no working directory on it -### OSW-GIT-201 +Why: Removing a working directory leaves its branch behind, and git would otherwise refuse with a reason that says nothing about what to do. -> info: Committed {name} as {commit} and pushed it to {pushedTo}. +What to do: Check the branch out where it is wanted, or delete it if its work has landed. -Why: Commit Change committed what the change's worktree held, on its branch, and pushed the branch. +### OSW-WSP-009 -What to do: Nothing: the change is on the server, where every directory, host and person can see it. +> warning: the working tree is not clean -### OSW-GIT-202 +Why: A directory whose tree is dirty may still hold uncommitted work, and removing it has no undo. -> info: {name} had nothing to commit; its branch is pushed to {pushedTo}. +What to do: Commit or discard the uncommitted changes, then ask for the removal again. -Why: Everything in the change's worktree was committed already, and the push brought the server up to date. +### OSW-WSP-101 -What to do: Nothing. +> info: Reclaimed the workspace lease from {hostKind} on {hostname} (pid {pid}), which stopped renewing it {seconds}s ago. -### OSW-GIT-203 +Why: A previous holder stopped heartbeating, so this run took the workspace from it rather than waiting forever. -> warning: {name} is made in {directory}, but not on the server: {why} +What to do: Nothing, unless that holder was expected to be alive: a reap of a live holder's lease is the one case worth checking by hand. -Why: A new change is committed on its branch and pushed at once; that failed, so no other directory, host or person sees it yet. +### OSW-WSP-102 -What to do: Put right what was refused, then choose Commit Change on the change's card. +> warning: it is not a directory + +Why: A path the repository lists as a working directory is not a directory, so its changes, task lists and lease cannot be read. + +What to do: Look at the path the worktree list names, and repair or prune it. + +### OSW-WSP-103 + +> warning: it does not exist + +Why: A worktree the repository lists has no directory at its path, so nothing of it can be surveyed. + +What to do: Run `git worktree prune`, or restore the missing directory. + +### OSW-WSP-104 + +> error: {said}. No process names this directory on its command line; one that holds it without naming it is not found this way. + +Why: A removal failed and no process naming the directory was found, so the underlying reason is all that can be said. + +What to do: Look for a program holding the directory without naming it on its command line, then remove it again. + +### OSW-WSP-105 + +> error: {said}. Held by {holders}, by their command lines. + +Why: A removal failed and processes whose command lines name the directory were found, so the message says who may be holding it. + +What to do: Stop the processes named, then remove the directory again. + +## KEY: signatures, keys, the team + +### OSW-KEY-001 + +> error: Agent "{agent}" is not present in the workspace allowlist + +Why: The workspace allowlist names no rule for this agent, so nothing it asks to run is permitted. + +What to do: Add the agent to the workspace allowlist, or choose an agent that is already on it. + +### OSW-KEY-002 + +> error: Agent "{agent}" is not permitted to run in process + +Why: The agent's allowlist rules carry no in-process rule matching its own name. + +What to do: Add an in-process rule for the agent, or run it as a spawned executable. + +### OSW-KEY-003 + +> error: HTTP call is not permitted by the allowlist for agent "{agent}" + +Why: The agent's rules carry no HTTP rule, so the call it asked for cannot be made. + +What to do: Add an HTTP rule for the agent, or use an agent that reaches the model another way. + +### OSW-KEY-004 + +> error: URL/method "{method} {url}" is not permitted by the allowlist + +Why: The agent's HTTP rule allows other URLs or methods than the one this call names. + +What to do: Extend the rule to the URL and method, or change the call. + +### OSW-KEY-005 + +> error: Executable "{executable}" is not permitted by the allowlist for agent "{agent}" + +Why: The agent asked to spawn an executable that no rule of its allowlist names. + +What to do: Add the executable to the agent's rules, or have it use one already permitted. + +### OSW-KEY-006 + +> error: Arguments [{arguments}] are not permitted by the allowlist + +Why: The executable is permitted, but its rules refuse the arguments this invocation carries. + +What to do: Bring the invocation within the rule's arguments, or widen the rule. + +### OSW-KEY-007 + +> error: cwd "{cwd}" is outside the workspace "{workspace}" + +Why: The run's working directory resolved outside the workspace root, and the cwd sandbox refuses it. + +What to do: Run from a directory inside the workspace. + +### OSW-KEY-008 + +> error: it is not valid JSON + +Why: A person's file could not be parsed, so nothing in it can be trusted. + +What to do: Fix the file's JSON, then read the team again. + +### OSW-KEY-009 + +> error: it is not a JSON object + +Why: A person's file parses to something other than an object with named fields. + +What to do: Make the file a JSON object with handle, name and keys. + +### OSW-KEY-010 + +> error: its handle is missing or not lower-case letters, digits and single hyphens + +Why: A person is named by a handle a forge would accept, and this file's handle is not that shape. + +What to do: Give the file a handle of lower-case letters, digits and single hyphens. + +### OSW-KEY-011 + +> error: its file is not named after its handle ({handle}.json) + +Why: The file name must be the handle, so that two files cannot claim one handle. + +What to do: Rename the file to the handle it declares. + +### OSW-KEY-012 + +> error: it has no name + +Why: Every person is shown by a name, and this file leaves it empty. + +What to do: Add the person's name to the file. + +### OSW-KEY-013 + +> error: it lists no key + +Why: A person with no key can sign nothing, so their file is unusable. + +What to do: Add at least one public key to the file. + +### OSW-KEY-014 + +> error: {key} is not a JSON object + +Why: One entry in the keys list is not an object with a key id, public key and added date. + +What to do: Make that key entry a JSON object. + +### OSW-KEY-015 + +> error: {key} is not an Ed25519 public key with its key id + +Why: The entry's public key and key id do not match, so signatures made with it cannot be verified. + +What to do: Re-add the key from the machine that holds it. + +### OSW-KEY-016 + +> error: {key} has no date it was added + +Why: Every key records when it was added, and this one has no readable date. + +What to do: Give the key an addedAt date in ISO form. + +### OSW-KEY-017 + +> error: {key} has a retirement date that is not a date + +Why: A retirement date is only read as an ISO date, and this one is not one. + +What to do: Correct the retiredAt date, or remove it. + +### OSW-KEY-018 + +> error: {key} names its machine with something other than text + +Why: The optional machine field must be text a person can read. + +What to do: Make the machine field text, or remove it. + +### OSW-KEY-019 + +> error: its e-mail addresses are not a list of addresses + +Why: The optional emails field must be a list of strings that each look like an address. + +What to do: Correct the e-mail list, or remove the field. + +### OSW-KEY-020 + +> error: key {keyId} is also {handle}'s + +Why: One key id appears in two people's files, so either could sign as the other. + +What to do: Remove the duplicate key from one file and give that person their own. + +### OSW-KEY-021 + +> error: a person is never removed: their signatures would stop verifying + +Why: A pull request that deletes a person's file would leave everything they signed unverifiable. + +What to do: Keep the file and retire the keys that are no longer used. + +### OSW-KEY-022 + +> error: key {keyId} was removed; retire it with retiredAt instead + +Why: A key is never taken out of a person's file, because what it signed must keep verifying. + +What to do: Put the key back and set its retiredAt date. + +### OSW-KEY-023 + +> error: key {keyId} was replaced + +Why: Changing a recorded public key would make everything it signed fail to verify. + +What to do: Restore the original key and add the new one alongside it. + +### OSW-KEY-024 + +> error: key {keyId}'s retirement was changed + +Why: A retirement already recorded is history, and changing it rewrites when a key stopped being used. + +What to do: Restore the recorded retirement date. + +### OSW-KEY-025 + +> error: "{handle}" is not a handle: use lower-case letters, digits and single hyphens + +Why: Joining the team takes a handle of the shape a forge accepts, and this one is not that shape. + +What to do: Choose a handle of lower-case letters, digits and single hyphens. + +### OSW-KEY-026 + +> error: a name is needed + +Why: A person joins under a name others can read, and none was given. + +What to do: Pass the name along with the handle. + +### OSW-KEY-027 + +> error: this machine's key is already {handle}'s + +Why: This machine's key can belong to only one person, and it is already in another's file. + +What to do: Join under that handle, or make a new key for this person. + +### OSW-KEY-028 + +> error: {file} cannot be read: {problems} + +Why: The person's existing file has problems, so this machine's key cannot be added to it. + +What to do: Fix the problems listed in that file, then join again. + +### OSW-KEY-029 + +> error: no live run signed by {keyId} is waiting to be enrolled + +Why: Confirmation enrols only a key that signs a live record now, so a confirmation is always about a run somebody can see. + +What to do: Enrol a key id that a live run's record is signed with. + +### OSW-KEY-030 + +> error: the key at {file} is not an Ed25519 key + +Why: The private key file holds a key of another type, which this product does not sign with. + +What to do: Remove or move the file so that a fresh Ed25519 key is made. + +### OSW-KEY-031 + +> error: the key at {file} was removed as it was made + +Why: Two writers made a key at once and the private file vanished before it could be read. + +What to do: Try again; report it if the key file keeps disappearing. + +## NET: network, proxy, the local LLM + +### OSW-NET-001 + +> warning: {token} is not set + +Why: The forge's token was not in the environment, so the forge cannot be reached. + +What to do: Set the named token in the environment, then read the pull requests again. + +### OSW-NET-002 + +> error: the {token} was refused (401) + +Why: The forge answered the token with 401, so the credentials are wrong or expired. + +What to do: Replace the named token, then read the pull requests again. + +### OSW-NET-003 + +> error: {method} {path} answered {status}: {reason} + +Why: The forge refused the request and said why in its own words. + +What to do: Read the forge's reason and put right what it names, then try again. + +### OSW-NET-004 + +> warning: gh is not installed + +Why: `gh` is not on PATH, so GitHub cannot be read through it. + +What to do: Install `gh`, or set GITHUB_TOKEN to reach GitHub over its API. + +### OSW-NET-005 + +> warning: no remote is on a GitHub host gh knows + +Why: `gh` was asked about a repository whose remote it cannot place on GitHub. + +What to do: Point `origin` at a GitHub host `gh` knows, or set GITHUB_TOKEN. + +### OSW-NET-006 + +> warning: gh is not signed in + +Why: `gh` has no credentials for this host, so it answered with an auth error. + +What to do: Run `gh auth login`, or set GITHUB_TOKEN. + +### OSW-NET-007 + +> warning: {reason}, and GITHUB_TOKEN is not set + +Why: `gh` could not reach GitHub and no API token was set either. + +What to do: Install or sign in to `gh`, or set GITHUB_TOKEN. + +### OSW-NET-008 + +> error: gh failed: {reason} + +Why: `gh` exited non-zero and gave a reason of its own. + +What to do: Read what `gh` said and put it right, then read the pull requests again. + +### OSW-NET-009 + +> error: gh printed something that is not JSON + +Why: `gh` answered the pull-request list with output the product cannot parse. + +What to do: Check the installed `gh` version and report the output if it persists. + +### OSW-NET-010 + +> error: gh printed something that is not a list + +Why: `gh` answered the pull-request list with JSON that is not a list. + +What to do: Check the installed `gh` version and report the output if it persists. + +### OSW-NET-101 + +> info: checks are still pending + +Why: At least one check has not finished, so the answer is not final yet. + +What to do: Wait; the product asks again until the checks decide. + +### OSW-NET-102 + +> warning: no check result was available + +Why: The forge reported no checks, so nothing vouches for the change. + +What to do: Add a check, or merge by hand once you are satisfied. + +### OSW-NET-103 + +> error: check failed: {check} ({state}) + +Why: One check finished in a state that is neither a pass nor pending. + +What to do: Read that check's log and put right what it reports. + +### OSW-NET-104 + +> warning: every check was skipped — no check actually ran + +Why: Checks exist but none of them decided anything, which ADR 0014 treats as no evidence. + +What to do: Run a check that actually exercises the change, then try again. + +### OSW-NET-105 + +> warning: Gitea did not say which commit the pull request is at + +Why: Gitea's answer carried no head commit, so its checks cannot be read. + +What to do: Check the pull request in Gitea, then try again. + +### OSW-NET-106 + +> warning: GitHub did not say which commit the pull request is at + +Why: GitHub's answer carried no head commit, so its checks cannot be read. + +What to do: Check the pull request on GitHub, then try again. + +### OSW-NET-201 + +> warning: checks did not reach a terminal state before timeout + +Why: The checks were still pending when the wait ran out. + +What to do: Look at why the checks are slow, or raise maxWaitMs. + +### OSW-NET-202 + +> warning: Stopped at the {calls}-web-tool-call limit. + +Why: The local model asked for more web-tool calls than the product allows. + +What to do: Raise the web-tool-call limit, or narrow the request. + +### OSW-NET-203 + +> warning: Stopped at the {iterations}-iteration web-tool limit. + +Why: The local model used every web-tool iteration the product allows. + +What to do: Raise the web-tool-iteration limit, or narrow the request. + +### OSW-NET-204 + +> info: The local LLM's server refused the key. Set the key the server expects (the local LLM settings, or OPENSPEC_UI_LOCAL_LLM_API_KEY), then start the run again. + +Why: The local LLM's server refused the key the run offered it. + +What to do: Set the key the server expects in the local LLM settings or OPENSPEC_UI_LOCAL_LLM_API_KEY, then start the run again. + +### OSW-NET-205 + +> info: The agent could not reach its service. Check the address and the connection. Where a system proxy is in the way, turn on openspec-ui.agents.ignoreSystemProxy in the editor, or set OPENSPEC_UI_IGNORE_SYSTEM_PROXY=1 for the server and the CLI. + +Why: The agent could not reach its service, and a system proxy is a common cause. + +What to do: Check the address and the connection; where a proxy is in the way, turn on the ignore-system-proxy setting. ## CLI: the CLI's arguments and subcommands diff --git a/openspec/changes/remaining-groups-move-into-the-register/.openspec.yaml b/openspec/changes/remaining-groups-move-into-the-register/.openspec.yaml new file mode 100644 index 00000000..b9ebfc72 --- /dev/null +++ b/openspec/changes/remaining-groups-move-into-the-register/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/remaining-groups-move-into-the-register/design.md b/openspec/changes/remaining-groups-move-into-the-register/design.md new file mode 100644 index 00000000..88c449c7 --- /dev/null +++ b/openspec/changes/remaining-groups-move-into-the-register/design.md @@ -0,0 +1,102 @@ +# Design + +## Context + +ADR 0046 built the register, the `say`/`formatMessage`/`withMessageCode` +surface, the generated page and a ratchet. Six groups (`CHG`, `RUN`, `QST`, +`PRM`, `GIT`, `PRC`) already say through it. The eight left are `HRN`, +`AGT`, `TSK`, `VAL`, `ARC`, `WSP`, `KEY`, `NET`. The ratchet in +`message-register.test.ts` counts only four forms - a literal to `stderr(`, +to a VS Code notification, to `failedEvent(`, and a literal `reason:` - and +holds 290. Most of what the eight remaining groups say travels through +carriers the ratchet does not count: the harness validator throws +`InvalidHarnessConfigError`, the task runner's `refused(...)` helper and the +inbox's `describe*` formatters return plain strings, and several refusals +are error classes (`ForgeRefusal`, `EnrolmentRefusedError`). Finishing ADR +0046 means teaching those carriers to carry an identifier, and teaching the +ratchet to count them. + +## Goals / Non-Goals + +**Goals:** + +- Every one of the fourteen groups has entries in the register. +- Every sentence a person reads - however it is carried - is said by its + identifier, with its words unchanged. +- The ratchet counts every carrier and falls to the documented residual, so + "all moved in" is a number the test holds, not a claim in prose. + +**Non-Goals:** + +- No words change. This is a migration, not a wording pass. +- Fragments that are not said as a complete sentence - a JSON-schema + `description` tooltip's fragment, a single status word that is never said + alone - are not given identifiers; they stay data. +- No new message semantics, no new groups, no change to the protocol's event + shape beyond the `code` the events already carry. +- Not rewriting the carriers into one shared refusal type; each carrier + gains a `code` where it lives. + +## Decisions + +1. **All eight groups move in one change.** Rejected: one change per group. + The machinery - register, `say`, page, ratchet - already exists, and the + words do not change, so each group's diff is mechanical: a sentence gains + an identifier and its carrier gains a code. A per-group change would + spend eight pull requests and eight changesets on the same operation. + The tasks are grouped by group, so the change can be split later if + review finds it too large. +2. **The words stay, the identifier leads.** Carried over from ADR 0046: a + test, a log reader or a person who knew the words still finds them; the + message gains its identifier and nothing is reworded. Rejected: + reword-as-we-go - a wording pass mixed into a migration hides what + changed and breaks every test that asserts the words. +3. **Every carrier that says a sentence gains a `code`.** `say(...)` already + returns `{ code, level, text }`. The carriers that return a bare string + or throw a bare error gain the code beside the words: + - `refused(...)` in `delegated-item-run.ts` returns `{ status, reason, + code, message }`. + - the `describe*` formatters return a `SaidMessage` (`{ code, text }`), + and their callers render the code where they now render the string. + - the refusal error classes (`InvalidHarnessConfigError`, `ForgeRefusal`, + `EnrolmentRefusedError`, the server's `Refused`, the CLI's + `ReportRefusal`) carry a `code` field, and their message text stays the + register's words. + - `reason:` fields ride beside a `code`, as the protocol events already + do. +4. **The ratchet generalizes, re-baselines once, then only falls.** The four + patterns are joined by the carriers above: the `refused(...)` reason + literals, the `describe*` string returns, the error classes' message + literals, the `message:`/`problem:` field literals, and the word maps in + `failure-diagnosis.ts` and `agent-status.ts`. Re-measuring the widened + counter raises the held number above 290 in one commit, recorded as such + in the test's comment; after that it only falls as each group moves in. + Rejected: leave the ratchet at four forms - finishing then cannot be + measured, because `HRN` and `TSK` barely touch those four. +5. **The adapters surface the code, not their own prefix.** The CLI, the + extension and the server re-say core's refusals today as + `openspec-ui-cli: ` / `OpenSpec Workbench: `. Those + prefixes are host identification, not a message; the already-migrated + groups dropped them, and these do the same, printing the register's + ` OSW-...: `. Rejected: register the prefixes - they are + not a sentence said to a person, and giving them identifiers would leave + a person looking up `openspec-ui-cli:` with nothing to learn. + +## Risks / Trade-offs + +- **The change is large.** About 250 messages across roughly thirty files. + Mitigated by the words not changing (the diff is the identifier and the + code), by the ratchet naming the exact residual, and by the per-group task + structure that lets the change be split. +- **The re-baseline can look like a regression.** The held number rises the + one time the counter widens. The commit that widens the counter says why, + and the test comment records it, as it recorded 323 to 290. +- **A carrier missed by the widened ratchet is unprovable.** The ratchet is + a floor, not a census, as ADR 0046 already said; a sentence built + elsewhere and passed in is not counted. The register's "every group has + entries" test and the moved messages' own tests are the second line of + defence. +- **`code` on an error class changes its public shape.** Adapters that read + the class's `message` keep working (the text is unchanged); code that + switches on the class keeps working (the class is unchanged); the new + field is additive. diff --git a/openspec/changes/remaining-groups-move-into-the-register/harness.json b/openspec/changes/remaining-groups-move-into-the-register/harness.json new file mode 100644 index 00000000..b926226c --- /dev/null +++ b/openspec/changes/remaining-groups-move-into-the-register/harness.json @@ -0,0 +1,20 @@ +{ + "supervisor": { + "fallback": { + "apply": [ + "copilot-cli-acp" + ], + "verify": [ + "deepseek-cli-acp" + ] + } + }, + "maxStageAttempts": 2, + "stepAgents": { + "apply": "deepseek-cli-acp" + }, + "autonomyLevel": "semi-autonomous", + "reviewGate": { + "mode": "agent-sufficient" + } +} diff --git a/openspec/changes/remaining-groups-move-into-the-register/proposal.md b/openspec/changes/remaining-groups-move-into-the-register/proposal.md new file mode 100644 index 00000000..7595d1c2 --- /dev/null +++ b/openspec/changes/remaining-groups-move-into-the-register/proposal.md @@ -0,0 +1,67 @@ +# Proposal + +## Why + +ADR 0046, accepted by the owner on 2026-10-08, decides that every message +the product says to a person is said as ` OSW--: +`, from one register in core, and that the register comes first +with a few groups while "the other groups move in later changes, one or a +few groups at a time, under the ratchet". The first steps landed: `CLI`, +`RUN`, `QST` and `PRM`, then `CHG`, `GIT` and `PRC`. The eight remaining +groups - `HRN`, `AGT`, `TSK`, `VAL`, `ARC`, `WSP`, `KEY` and `NET` - are +still said in place, as sentences written where they are said. A person +still cannot look up most of what the product tells them, and the ratchet as +written counts only four of the forms that carry those sentences, so +finishing the job cannot even be measured. + +## What Changes + +- `message-register.ts` in core: entries for the eight remaining groups, + every message's identifier, level, words with named values, why it is said + and what to do. +- The remaining carriers are taught to say by identifier rather than as a + sentence written in place: the `reason:` fields of the checks, the + gateways and the refusals; the `refused(...)` helper and the `describe*` + sentence formatters; the refusal error classes (`InvalidHarnessConfigError`, + `ForgeRefusal`, `EnrolmentRefusedError`, and the CLI's/server's `Refused` + and `ReportRefusal`); the `failedEvent(...)` and + `showError/Warning/InformationMessage(...)` literals in the chain runner, + the CLI, the server and the extension. +- The ratchet in `message-register.test.ts` generalizes from the four forms + it counts today to every carrier above, re-baselines once (the held number + rises because it now counts more forms), and from then on only falls as + the groups move in, ending at the documented residual. +- `docs/messages.md`, regenerated; it gains the eight groups. +- Tests: the register's, the ratchet's, and the moved messages' own tests, + which keep their words and gain the code. + +## Capabilities + +### New Capabilities + +None. The register, `say`, the page and the ratchet already exist; what +changes is how much of what the product says is said through them. + +### Modified Capabilities + +- `messages`: the register holds all fourteen groups; every carrier that + says a sentence carries its identifier; the ratchet counts every carrier + and falls to the documented residual. + +## Impact + +- `packages/core`: `message-register.ts` and `message-register.test.ts`; + `harness-config.ts`, `harness-config-findings.ts`, `chain-preflight.ts`, + `supervisor.ts`, `failure-diagnosis.ts`, `agent-roster.ts`, + `agent-status.ts`, `agents/acp-session-driver.ts`, `harness-chain-runner.ts`, + `delegated-item-run.ts`, `human-only-inbox-view.ts`, `task-checklist.ts`, + `mechanical-checks.ts`, `spec-delta-check.ts`, `successor-check.ts`, + `change-relations-file.ts`, `verify-quality.ts`, `gh-pr-gateway.ts`, + `openspec.ts`, `landed-archive.ts`, `workspace-lease.ts`, + `lease-release.ts`, `workspace-leftovers.ts`, `worktree-survey.ts`, + `change-worktrees.ts`, `security.ts`, `people.ts`, `enrolment.ts`, + `machine-key.ts`, `http-forges.ts`, `forge.ts`, `agents/local-llm.ts`. +- `packages/cli`, `packages/extension`, `packages/server`: the adapters that + re-say core's refusals, dropping their `openspec-ui-cli:` / + `OpenSpec Workbench:` prefixes in favour of the identifier. +- `docs/messages.md`, `openspec/specs/messages/spec.md`. diff --git a/openspec/changes/remaining-groups-move-into-the-register/specs/messages/spec.md b/openspec/changes/remaining-groups-move-into-the-register/specs/messages/spec.md new file mode 100644 index 00000000..153bea01 --- /dev/null +++ b/openspec/changes/remaining-groups-move-into-the-register/specs/messages/spec.md @@ -0,0 +1,67 @@ +## MODIFIED Requirements + +### Requirement: Every message is said by its identifier, from one register + +A message the product says to a person SHALL have an identifier +`OSW--`, in one of the groups of ADR 0046, and an entry in the +register in core giving its level (`error`, `warning` or `info`), its words, +why it is said and what to do. Code SHALL say a message by its identifier +with its values, not as a sentence written in place. An identifier once +given SHALL stay in the register, marked retired when the message goes, and +SHALL NOT be given to another message. Every group of ADR 0046 SHALL have +its messages in the register: `CHG`, `RUN`, `QST`, `PRM`, `HRN`, `AGT`, +`TSK`, `VAL`, `ARC`, `GIT`, `WSP`, `KEY`, `NET`, `CLI` and `PRC`. + +A carrier that says a sentence SHALL carry the identifier beside the words, +whatever the carrier is: a `reason:` field, a `refused(...)` result, a +`describe*` sentence formatter, a refusal error class +(`InvalidHarnessConfigError`, `ForgeRefusal`, `EnrolmentRefusedError`, +`Refused`, `ReportRefusal`), a `failedEvent(...)` line, a VS Code +notification, or a CLI `stderr(...)` line. + +#### Scenario: A message with its values + +- **WHEN** the CLI refuses an answer to a question the change does not + have +- **THEN** it says `error OSW-QST-003: Q-7 is not a question of demo`, from + the register's entry and the values `Q-7` and `demo` + +#### Scenario: A harness setting that does not validate + +- **WHEN** a change's harness configuration names an agent id no registered + agent has +- **THEN** the validator throws an error whose text is the register's words + and whose `code` is the register's identifier, `OSW-HRN-...`, and the + host shows the identifier with the words + +#### Scenario: An identifier given twice + +- **WHEN** a change gives a new message an identifier the register has had + before +- **THEN** the register's test fails + +### Requirement: Messages said in place only become fewer + +A test SHALL count the messages still said as sentences written in place +across the packages' sources, and SHALL fail when the count is above the +number it holds, and when it is below, naming the new number to hold. The +count SHALL cover every carrier that says a sentence - a literal to +`stderr(`, to a VS Code notification, to `failedEvent(`, a literal +`reason:`, a literal `message:` or `problem:` field, a `refused(...)` +reason, a `describe*` formatter's sentence, and a refusal error class's +message - not only the four forms the first ratchet counted. Widening the +count is a one-time re-baseline, recorded in the test's comment; after it, +the number SHALL only fall as each group moves into the register. + +#### Scenario: A new message said in place + +- **WHEN** a change adds `deps.stderr("openspec-ui-cli: something")` +- **THEN** the count rises above the number held and the test fails until + the message is in the register + +#### Scenario: A new carrier the ratchet does not count + +- **WHEN** a change adds a refusal error class whose message is written in + place +- **THEN** the widened ratchet counts it, and the count rises until the + message is said by identifier diff --git a/openspec/changes/remaining-groups-move-into-the-register/tasks.md b/openspec/changes/remaining-groups-move-into-the-register/tasks.md new file mode 100644 index 00000000..b6ccb84a --- /dev/null +++ b/openspec/changes/remaining-groups-move-into-the-register/tasks.md @@ -0,0 +1,179 @@ +From ADR 0046, accepted by the owner on 2026-10-08: "the other groups move +in later changes, one or a few groups at a time, under the ratchet". The +first steps landed; this change moves the eight groups that remain, one +section each, under a widened ratchet. + +## 1. The register and the ratchet + +- [x] 1.1 `packages/core/src/message-register.ts`: add the entries of the + eight groups `HRN`, `AGT`, `TSK`, `VAL`, `ARC`, `WSP`, `KEY` and `NET`, + each with its level, words with `{name}` values, `why` and `todo`. Keep + every word a message already has; nothing is reworded. The register is a + leaf with no imports, as before. +- [x] 1.2 `packages/core/src/message-register.test.ts`: widen `SAID_IN_PLACE` + so `countUnregistered` counts the carriers the first ratchet did not - a + literal `message:`/`problem:` field, a `refused(...)` reason literal, a + `describe*` formatter's sentence, and a refusal error class's message + literal - in one commit that re-baselines `UNREGISTERED_MESSAGES` to the + new, larger count and records why in the test's comment (as it recorded + 323 to 290). +- [x] 1.3 `packages/core/src/message-register.test.ts`: add the + already-existing `GIVEN` identifiers of the eight new groups, and a test + that every group in `MESSAGE_GROUPS` has at least one entry, so a group + left empty fails the test rather than going unnoticed. +- [x] 1.4 `packages/core/src/message-register.test.ts`: `npm run messages + --workspace @openspec-ui/core` writes `docs/messages.md`, and the page + test still passes (out of date? run the generator). + +## 2. HRN - harness settings + +- [x] 2.1 `packages/core/src/harness-config.ts`: every + `InvalidHarnessConfigError` is thrown with a `code` from the register, and + its message text is the register's words. Callers that read `.message` + keep working; the new `code` field is additive. +- [x] 2.2 `packages/core/src/harness-config-findings.ts`: every + `HarnessFinding.message` is the register's text and carries its `code` + beside it. +- [x] 2.3 `packages/core/src/chain-preflight.ts`: the chain-start refusals + whose `configKey` names a harness setting say their `reason` by + identifier, the `code` riding beside the `reason`. +- [x] 2.4 `packages/core/src/supervisor.ts`: `fallbackRefusal` and + `superviseFailure` say their reasons by identifier. + +## 3. AGT - agents and adapters + +- [x] 3.1 `packages/core/src/failure-diagnosis.ts`: `CAUSE_WORDS`, + `REPEAT_WORDS` and the remedies are re-keyed by register identifier; the + words stay, and the diagnosis carries `code` beside the sentence. +- [x] 3.2 `packages/core/src/agent-status.ts`: `REFUSAL_WORDS` and + `MESSAGE_REFUSAL_WORDS` map an identifier to the words, and the status + carries `code`. +- [x] 3.3 `packages/core/src/agent-roster.ts`: the malformed-roster reasons + say by identifier. +- [x] 3.4 `packages/core/src/agents/acp-session-driver.ts`: the + `agent refused the prompt` reason says by identifier. +- [x] 3.5 `packages/core/src/harness-chain-runner.ts`: the + `no agent available to run the ... stage` `failedEvent(...)` line says by + identifier. The `chain-preflight.ts` "no runner for this agent" refusal + (2.3's file) says by identifier too. + +## 4. TSK - tasks + +- [x] 4.1 `packages/core/src/delegated-item-run.ts`: `refused(reason)` + returns `{ status, reason, code, message }`, every reason said by + identifier, and `message` stays `No run was started: .`. +- [x] 4.2 `packages/core/src/human-only-inbox-view.ts`: the `describe*` + formatters (`describeHumanOnlyInbox`, `describeWaitingOn`, + `describeUnmatchedTaskAgent`, `describeHumanOnlyInboxState`, + `describeUnreadable`, `describeEnrolments`, `describeQuestions`, + `describeUnmatched`) return a `SaidMessage` `{ code, text }`, and their + callers render the `code`. +- [x] 4.3 `packages/core/src/task-checklist.ts`: `describeTaskDebts` returns + a `SaidMessage`; `delegated-items.ts` is data plumbing and needs no + change. +- [x] 4.4 `packages/core/src/harness-chain-runner.ts`: the `a request to + stop after ... named a task this change does not have` line says by + identifier. + +## 5. VAL - validation, the merge gate, checks + +- [x] 5.1 `packages/core/src/mechanical-checks.ts`: every check result's + `reason` says by identifier, the `code` beside the words. +- [x] 5.2 `packages/core/src/spec-delta-check.ts` and + `packages/core/src/successor-check.ts`: the drift and successor reasons + say by identifier. +- [x] 5.3 `packages/core/src/change-relations-file.ts`: the relation-edit + refusals' `message` says by identifier. +- [x] 5.4 `packages/core/src/verify-quality.ts`: `describeVerifyQuality` + returns a `SaidMessage`; `harness-chain-runner.ts`'s `mechanical checks + failed, verifying agent was not invoked` `failedEvent(...)` line says by + identifier. + +## 6. ARC - archiving + +- [x] 6.1 `packages/core/src/openspec.ts`: `archiveChange`'s gate and + `describeArchiveRefusal` say by identifier; the `could not archive ...` + error carries `code`. +- [x] 6.2 `packages/core/src/landed-archive.ts`: `followArchivePullRequest` + and `failureReason` say their refusals by identifier. + +## 7. WSP - the workspace + +- [x] 7.1 `packages/core/src/workspace-lease.ts`: + `describeWorkspaceLeaseConflict` and `describeWorkspaceLeaseReclamation` + return a `SaidMessage`. +- [x] 7.2 `packages/core/src/lease-release.ts`: the two refusal reasons say + by identifier. +- [x] 7.3 `packages/core/src/workspace-leftovers.ts`: the removal reasons + and `withHolders` sentences say by identifier. +- [x] 7.4 `packages/core/src/worktree-survey.ts` and + `packages/core/src/change-worktrees.ts`: the `whyUnreadable` and worktree + refusal reasons say by identifier. + +## 8. KEY - signatures, keys, the team + +- [x] 8.1 `packages/core/src/security.ts`: the allowlist and cwd-sandbox + refusal reasons say by identifier. +- [x] 8.2 `packages/core/src/people.ts`: the `parsePersonFile` and + `crossCheckPeople` problems say by identifier. +- [x] 8.3 `packages/core/src/enrolment.ts`: `EnrolmentRefusedError` carries + `code`; `machine-key.ts`'s `the key at ... is not an Ed25519 key` says by + identifier. + +## 9. NET - network, proxy, the local LLM + +- [x] 9.1 `packages/core/src/http-forges.ts`: `ForgeRefusal` carries `code`; + the token and status reasons say by identifier. +- [x] 9.2 `packages/core/src/forge.ts`: the forge-detection reasons say by + identifier. +- [x] 9.3 `packages/core/src/gh-pr-gateway.ts`: `gh is not signed in`, the + pending and the check-state reasons say by identifier. +- [x] 9.4 `packages/core/src/agents/local-llm.ts` and the + `failure-diagnosis.ts` network/local-LLM remedies: say by identifier. + +## 10. Adapters surface the code + +- [x] 10.1 `packages/cli/src/`: every adapter that re-says a core refusal + as `openspec-ui-cli: ` prints the register's form instead - + `answer-command.ts`, `task-command.ts`, `team-command.ts`, + `lease-command.ts`, `status-command.ts`, `stop-command.ts`, + `worktree-command.ts`, `run-change.ts`, `update-plan.ts`, + `enrol-command.ts`, `history-command.ts`, `report-command.ts`. +- [x] 10.2 `packages/extension/src/`: every `showError/Warning/InformationMessage(...)` + that re-says a core refusal leads with the identifier and drops the + `OpenSpec Workbench: ` prefix - `commands.ts`, `extension.ts`, + `change-elsewhere.ts`, `pipeline-panel.ts`, `run-controller.ts`, + `webview/ai-panel.ts`. +- [x] 10.3 `packages/server/src/change-actions-rest.ts`: the `Refused` + wrapper carries the identifier beside the text it already returns. + +## 11. Documents + +- [x] 11.1 `docs/messages.md`, regenerated by the core script; it lists the + eight new groups with every identifier. +- [x] 11.2 `openspec/specs/messages/spec.md`: the stale `## Purpose` line + ("created by archiving change every-message-has-an-identifier") is + replaced with one sentence on what the capability is. +- [x] 11.3 A changeset (`npx changeset`): core, cli, extension, server, + webui - minor. + +## 12. Checks + +- [x] 12.1 `npm run typecheck && npm run lint` and the affected packages' + tests, with two workers: the register's, the ratchet's, and each moved + message's own test, whose words are unchanged and whose code is asserted. + 2026-10-12: typecheck clean; lint 0 errors (4 unused-variable warnings, + unrelated to identifiers); core 2298 passed, cli 285, extension 538, + server 135, webui 768. The register's and the ratchet's tests pass; + `UNREGISTERED_MESSAGES` holds 335. +- [x] 12.2 `openspec validate remaining-groups-move-into-the-register + --strict`, and the merge gate with the worktree's absolute path as + `--cwd`. + 2026-10-12: "Change 'remaining-groups-move-into-the-register' is valid"; + the merge gate (`validate changes --base origin/main`) reports "All + changes valid", exit 0. +- [ ] 12.3 **Human-only**: in VS Code, on the extension built from this + branch, a run that fails for one of the newly registered reasons shows + `Failed:` with the identifier as a link, and the link opens its entry in + `docs/messages.md`; the `openspec-ui-cli explain message ` + command answers for one of the new codes. diff --git a/openspec/specs/messages/spec.md b/openspec/specs/messages/spec.md index d8daf819..84f7d910 100644 --- a/openspec/specs/messages/spec.md +++ b/openspec/specs/messages/spec.md @@ -1,7 +1,7 @@ # messages Specification ## Purpose -TBD - created by archiving change every-message-has-an-identifier. Update Purpose after archive. +Every message OpenSpec Workbench says to a person is written once, under an identifier, in one register in core, and said by that identifier wherever it is said. ## Requirements diff --git a/packages/cli/src/doctor-command.test.ts b/packages/cli/src/doctor-command.test.ts index 8e8ec69a..f343a31e 100644 --- a/packages/cli/src/doctor-command.test.ts +++ b/packages/cli/src/doctor-command.test.ts @@ -90,6 +90,7 @@ describe("doctorCommand", () => { ok: false as const, refusal: { reason: "this change's configuration pauses between stages for a confirmation", + code: "OSW-HRN-115", configKey: "checkpoints.requireConfirmationBetweenSteps", }, }), diff --git a/packages/cli/src/enrol-command.ts b/packages/cli/src/enrol-command.ts index cfde5555..a10de3b8 100644 --- a/packages/cli/src/enrol-command.ts +++ b/packages/cli/src/enrol-command.ts @@ -13,6 +13,7 @@ import { readEnrolmentRequests, type AgentRosterEntry, type EnrolmentReading, + withMessageCode, } from "@openspec-ui/core"; export interface EnrolOptions { @@ -74,7 +75,7 @@ export async function enrolCommand(options: EnrolOptions, deps: EnrolDeps): Prom ); } catch (error) { if (error instanceof EnrolmentRefusedError) { - deps.stderr(`openspec-ui-cli: ${error.message}`); + deps.stderr(withMessageCode(error.message, error.code)); return 1; } deps.stderr(`openspec-ui-cli: could not enrol ${options.keyId}: ${message(error)}`); diff --git a/packages/cli/src/lease-command.test.ts b/packages/cli/src/lease-command.test.ts index 438e2305..7e7caa8d 100644 --- a/packages/cli/src/lease-command.test.ts +++ b/packages/cli/src/lease-command.test.ts @@ -78,6 +78,7 @@ describe("leaseCommand", () => { kind: "refused", holder: HOLDER, reason: "process 777 is still running. ... stop that process instead.", + code: "OSW-WSP-003", }; const code = await leaseCommand( diff --git a/packages/cli/src/lease-command.ts b/packages/cli/src/lease-command.ts index 458eb502..840687c6 100644 --- a/packages/cli/src/lease-command.ts +++ b/packages/cli/src/lease-command.ts @@ -18,6 +18,7 @@ import { type WorkspaceLeaseConflict, formatMessage, say, + withMessageCode, } from "@openspec-ui/core"; export interface LeaseOptions { @@ -102,7 +103,7 @@ async function releaseLease(options: LeaseOptions, deps: LeaseDeps): Promise { const result = await runValidateAll(root, { change: "the-change", ...seams }); expect(result.ok).toBe(false); - expect(result.peopleProblems).toEqual([{ file: "openspec/people/ada.json", problem: "it is not valid JSON" }]); + expect(result.peopleProblems).toEqual([ + { file: "openspec/people/ada.json", problem: "it is not valid JSON", code: "OSW-KEY-008" }, + ]); }); it("fails a key taken out of a person the base has", async () => { diff --git a/packages/cli/src/operator-commands.test.ts b/packages/cli/src/operator-commands.test.ts index 61209d59..0fda897a 100644 --- a/packages/cli/src/operator-commands.test.ts +++ b/packages/cli/src/operator-commands.test.ts @@ -206,7 +206,7 @@ describe("operator commands", () => { return { status: "ran" as const, runId: "r1", agent: "copilot-cli", outcome: allowed ? "completed" as const : "failed" as const, - gate: { kind: "recorded" as const }, message: "permission handled", + gate: { kind: "recorded" as const }, code: "OSW-TSK-030" as const, message: "permission handled", reply: { at: "2026-10-11T00:00:02Z", body: "permission handled", outcome: allowed ? "closed" as const : "failed" as const } }; }); @@ -247,7 +247,7 @@ describe("operator commands", () => { for await (const event of selected!.run(command)) runOptions.onEvent?.(event); return { status: "ran" as const, runId: "r2", agent: "copilot-cli", outcome: "cancelled" as const, - gate: { kind: "still-open" as const }, message: "interrupted", + gate: { kind: "still-open" as const }, code: "OSW-TSK-032" as const, message: "interrupted", reply: { at: "2026-10-11T00:00:02Z", body: "interrupted", outcome: "left-open" as const } }; }); @@ -264,7 +264,7 @@ describe("operator commands", () => { const output = io(); const run = vi.fn().mockResolvedValue({ status: "ran", runId: "failed-run", agent: "copilot-cli", - outcome: "failed", gate: { kind: "still-open" }, message: "runner failed", + outcome: "failed", gate: { kind: "still-open" }, code: "OSW-TSK-032", message: "runner failed", reply: { at: "2026-10-11T00:00:02Z", body: "runner failed", outcome: "failed" } }); expect(await runTaskCommand({ ...options, number: "1.2" }, { diff --git a/packages/cli/src/rollback-command.ts b/packages/cli/src/rollback-command.ts index 722341b2..ec63ee19 100644 --- a/packages/cli/src/rollback-command.ts +++ b/packages/cli/src/rollback-command.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { createGitWrapper, describeWorkspaceLeaseConflict, readAgentStatuses, resolveAgentStatusDirectory, - withWorkspaceLease, WorkbenchRecoveryService, WorkspaceLeaseManager } from "@openspec-ui/core"; + withMessageCode, withWorkspaceLease, WorkbenchRecoveryService, WorkspaceLeaseManager } from "@openspec-ui/core"; import { changeSource, reportFailure, ReportRefusal, type ReportIo, type ReportOptions } from "./report-command.js"; import type { CheckpointPrompt } from "./run-change.js"; @@ -35,7 +35,10 @@ export async function rollbackCommand(options: ReportOptions, deps: RollbackDeps deps.stdout(options.format === "json" ? JSON.stringify(result, null, 2) : `Restored ${result.restored.length} files.`); return 0; }); - if (!held.ok) throw new ReportRefusal(describeWorkspaceLeaseConflict(held.conflict)); + if (!held.ok) { + const said = describeWorkspaceLeaseConflict(held.conflict); + throw new ReportRefusal(withMessageCode(said.text, said.code)); + } return held.value; } catch (error) { return reportFailure("rollback change", error, deps); diff --git a/packages/cli/src/run-change.ts b/packages/cli/src/run-change.ts index e45b5451..226b6c41 100644 --- a/packages/cli/src/run-change.ts +++ b/packages/cli/src/run-change.ts @@ -35,6 +35,7 @@ import { type Event, formatMessage, say, + withMessageCode, } from "@openspec-ui/core"; import { RunTextRenderer, renderRunEventAsJsonLine } from "./render-run.js"; @@ -102,7 +103,10 @@ export async function runChange(options: RunChangeOptions, deps: RunChangeDeps): author: await readGitAuthor(workspaceRoot), }); const held = await withWorkspaceLease(lease, async (hold) => { - if (hold.reclaimedFrom) deps.stderr(`openspec-ui-cli: ${describeWorkspaceLeaseReclamation(hold.reclaimedFrom)}`); + if (hold.reclaimedFrom) { + const said = describeWorkspaceLeaseReclamation(hold.reclaimedFrom); + deps.stderr(withMessageCode(said.text, said.code)); + } return await driveChain( { workspaceRoot, @@ -134,7 +138,8 @@ export async function runChange(options: RunChangeOptions, deps: RunChangeDeps): }); if (!held.ok) { - deps.stderr(`openspec-ui-cli: ${describeWorkspaceLeaseConflict(held.conflict)}`); + const said = describeWorkspaceLeaseConflict(held.conflict); + deps.stderr(withMessageCode(said.text, said.code)); return 2; } return held.value; diff --git a/packages/cli/src/status-command.ts b/packages/cli/src/status-command.ts index 037e4757..26c03dc4 100644 --- a/packages/cli/src/status-command.ts +++ b/packages/cli/src/status-command.ts @@ -30,6 +30,7 @@ import { type AgentStatusSweepResult, type GitWrapper, type TaskChecklistItem, + withMessageCode, } from "@openspec-ui/core"; export interface StatusOptions { @@ -171,7 +172,7 @@ export async function statusCommand(options: StatusOptions, deps: StatusDeps): P reportUnopened(); for (const bad of result.malformed) { - deps.stderr(`openspec-ui-cli: ${bad.fileName}: ${bad.reason}`); + deps.stderr(withMessageCode(`${bad.fileName}: ${bad.reason}`, bad.code)); } return 0; diff --git a/packages/cli/src/team-command.test.ts b/packages/cli/src/team-command.test.ts index 5b4438ac..48ef4994 100644 --- a/packages/cli/src/team-command.test.ts +++ b/packages/cli/src/team-command.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { JoinRefusedError, type JoinResult, type PeopleReading } from "@openspec-ui/core"; +import { JoinRefusedError, say, type JoinResult, type PeopleReading } from "@openspec-ui/core"; import { joinCommand, peopleCommand } from "./team-command.js"; // a-team-works-through-git: the CLI's words for joining and for the people. @@ -33,9 +33,9 @@ describe("join", () => { const refused = capture(); expect(await joinCommand({ workspaceRoot: "/repo", handle: "ada", name: "Ada", format: "text" }, { ...refused.deps, - join: async () => { throw new JoinRefusedError("this machine's key is already bob's"); }, + join: async () => { throw new JoinRefusedError(say("OSW-KEY-027", { handle: "bob" })); }, })).toBe(1); - expect(refused.err).toEqual(["openspec-ui-cli: this machine's key is already bob's"]); + expect(refused.err).toEqual(["OSW-KEY-027: this machine's key is already bob's"]); const broken = capture(); expect(await joinCommand({ workspaceRoot: "/repo", handle: "ada", name: "Ada", format: "text" }, { diff --git a/packages/cli/src/team-command.ts b/packages/cli/src/team-command.ts index 34a6b776..62dc3e66 100644 --- a/packages/cli/src/team-command.ts +++ b/packages/cli/src/team-command.ts @@ -5,7 +5,7 @@ // what is wrong with a file are core's (`joinTheTeam`, `readPeople`), the // same calls the editor makes. -import { JoinRefusedError, joinTheTeam, readPeople, type JoinResult, type PeopleReading } from "@openspec-ui/core"; +import { JoinRefusedError, joinTheTeam, readPeople, type JoinResult, type PeopleReading, withMessageCode } from "@openspec-ui/core"; export interface JoinCommandOptions { workspaceRoot: string; @@ -39,7 +39,7 @@ export async function joinCommand(options: JoinCommandOptions, deps: TeamCommand }); } catch (error) { if (error instanceof JoinRefusedError) { - deps.stderr(`openspec-ui-cli: ${error.message}`); + deps.stderr(withMessageCode(error.message, error.code)); return 1; } deps.stderr(`openspec-ui-cli: could not join: ${message(error)}`); @@ -88,6 +88,6 @@ export async function peopleCommand(options: PeopleCommandOptions, deps: TeamCom const retired = person.keys.length - current; deps.stdout(`${person.handle} ${person.name} ${current} key${current === 1 ? "" : "s"}${retired > 0 ? `, ${retired} retired` : ""}`); } - for (const problem of reading.problems) deps.stderr(`${problem.file}: ${problem.problem}`); + for (const problem of reading.problems) deps.stderr(withMessageCode(`${problem.file}: ${problem.problem}`, problem.code)); return reading.problems.length === 0 ? 0 : 1; } diff --git a/packages/cli/src/update-plan.ts b/packages/cli/src/update-plan.ts index a5d76b2a..b98d7513 100644 --- a/packages/cli/src/update-plan.ts +++ b/packages/cli/src/update-plan.ts @@ -26,6 +26,7 @@ import { type Command, formatMessage, say, + withMessageCode, } from "@openspec-ui/core"; import { RunTextRenderer, renderRunEventAsJsonLine } from "./render-run.js"; import type { CheckpointPrompt } from "./run-change.js"; @@ -120,7 +121,8 @@ export async function updatePlan(options: UpdatePlanOptions, deps: UpdatePlanDep return outcome === "completed" ? 0 : 1; }); if (!held.ok) { - deps.stderr(`openspec-ui-cli: ${describeWorkspaceLeaseConflict(held.conflict)}`); + const said = describeWorkspaceLeaseConflict(held.conflict); + deps.stderr(withMessageCode(said.text, said.code)); return 2; } return held.value; diff --git a/packages/cli/src/worktree-command.ts b/packages/cli/src/worktree-command.ts index 72742c4a..5d4755a9 100644 --- a/packages/cli/src/worktree-command.ts +++ b/packages/cli/src/worktree-command.ts @@ -16,6 +16,7 @@ import { type WorktreeRootSources, formatMessage, say, + withMessageCode, } from "@openspec-ui/core"; import { publicNameOf } from "./subcommands.js"; @@ -81,7 +82,7 @@ async function addWorktree( }); if (!plan.ok) { - deps.stderr(`openspec-ui-cli: will not create a working directory for "${context.changeName}": ${plan.refusal.reason}`); + deps.stderr(withMessageCode(`will not create a working directory for "${context.changeName}": ${plan.refusal.reason}`, plan.refusal.code)); deps.stderr(`openspec-ui-cli: ${plan.refusal.remedy}`); return 1; } diff --git a/packages/core/src/agent-roster.test.ts b/packages/core/src/agent-roster.test.ts index 34e90188..514119d1 100644 --- a/packages/core/src/agent-roster.test.ts +++ b/packages/core/src/agent-roster.test.ts @@ -81,7 +81,7 @@ describe("readAgentRoster (a-run-is-signed-by-its-person 4.5)", () => { const roster = await readAgentRoster(rosterDirectory); expect(roster.entries).toEqual([]); - expect(roster.malformed).toEqual([{ fileName: "someone.json", reason: `its file name does not match its key id "${key.keyId}"` }]); + expect(roster.malformed).toEqual([{ fileName: "someone.json", reason: `its file name does not match its key id "${key.keyId}"`, code: "OSW-AGT-007" }]); }); it("does not trust a file whose key does not match its key id", async () => { @@ -99,6 +99,7 @@ describe("readAgentRoster (a-run-is-signed-by-its-person 4.5)", () => { expect(roster.entries).toEqual([]); expect(roster.malformed[0]?.reason).toBe("its key does not match its key id"); + expect(roster.malformed[0]?.code).toBe("OSW-AGT-008"); }); }); @@ -164,7 +165,10 @@ describe("enrolment (a-run-is-signed-by-its-person 4.5)", () => { const request = { keyId: key.keyId, publicKey: key.publicKey, label: "alpha", machine: "ada-laptop", gitAuthor: null }; await expect(confirmEnrolment({ rosterDirectory, request })).rejects.toBeInstanceOf(EnrolmentRefusedError); + await expect(confirmEnrolment({ rosterDirectory, request })).rejects.toMatchObject({ code: "OSW-AGT-010" }); await expect(confirmEnrolment({ rosterDirectory, request: { ...request, publicKey: other.publicKey } })) .rejects.toBeInstanceOf(EnrolmentRefusedError); + await expect(confirmEnrolment({ rosterDirectory, request: { ...request, publicKey: other.publicKey } })) + .rejects.toMatchObject({ code: "OSW-AGT-009" }); }); }); diff --git a/packages/core/src/agent-roster.ts b/packages/core/src/agent-roster.ts index bba01cbf..979dc041 100644 --- a/packages/core/src/agent-roster.ts +++ b/packages/core/src/agent-roster.ts @@ -9,6 +9,7 @@ import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import type { AgentStatusReport } from "./agent-status.js"; +import { say, type MessageCode } from "./message-register.js"; import type { EnrolmentRequest } from "./signature-facts.js"; import { keyMatchesKeyId, type Roster, type RosterKey } from "./signed-envelope.js"; @@ -22,6 +23,9 @@ export interface AgentRosterEntry extends RosterKey { export interface AgentRosterMalformed { fileName: string; reason: string; + /** The register's identifier for `reason`, where it is one the register + * holds. A filesystem error's own words have none. */ + code?: MessageCode; } export interface AgentRosterReading { @@ -46,15 +50,23 @@ function errorCode(error: unknown): string | undefined { return error instanceof Error && "code" in error ? (error as NodeJS.ErrnoException).code : undefined; } +/** Why an entry is not trusted: the register's words and identifier. */ +interface AgentRosterProblem { + reason: string; + code: MessageCode; +} + /** An entry as a file holds it, or why it is not trusted. */ -function readEntry(fileName: string, text: string): AgentRosterEntry | string { +function readEntry(fileName: string, text: string): AgentRosterEntry | AgentRosterProblem { let value: unknown; try { value = JSON.parse(text); } catch { - return "not valid JSON"; + return { reason: say("OSW-AGT-001").text, code: "OSW-AGT-001" }; + } + if (typeof value !== "object" || value === null) { + return { reason: say("OSW-AGT-006").text, code: "OSW-AGT-006" }; } - if (typeof value !== "object" || value === null) return "not an entry"; const entry = value as Record; if ( typeof entry.keyId !== "string" || @@ -65,11 +77,13 @@ function readEntry(fileName: string, text: string): AgentRosterEntry | string { typeof entry.confirmedAt !== "string" || (entry.gitAuthor !== undefined && typeof entry.gitAuthor !== "string") ) { - return "missing or invalid required fields"; + return { reason: say("OSW-AGT-003").text, code: "OSW-AGT-003" }; + } + if (fileName !== `${entry.keyId}.json`) { + return { reason: say("OSW-AGT-007", { keyId: entry.keyId }).text, code: "OSW-AGT-007" }; } - if (fileName !== `${entry.keyId}.json`) return `its file name does not match its key id "${entry.keyId}"`; if (!KEY_ID_PATTERN.test(entry.keyId) || !keyMatchesKeyId(entry.publicKey, entry.keyId)) { - return "its key does not match its key id"; + return { reason: say("OSW-AGT-008").text, code: "OSW-AGT-008" }; } return { keyId: entry.keyId, @@ -103,7 +117,7 @@ export async function readAgentRoster(directory: string): Promise { const { request } = options; if (!KEY_ID_PATTERN.test(request.keyId) || !keyMatchesKeyId(request.publicKey, request.keyId)) { - throw new EnrolmentRefusedError(`the key offered for ${request.keyId} does not match that key id`); + throw new EnrolmentRefusedError( + say("OSW-AGT-009", { keyId: request.keyId }).text, + "OSW-AGT-009", + ); } const directory = path.resolve(options.rosterDirectory); const fileName = `${request.keyId}.json`; @@ -178,8 +199,11 @@ export async function confirmEnrolment(options: ConfirmEnrolmentOptions): Promis }); if (existing !== undefined) { const entry = readEntry(fileName, existing); - if (typeof entry === "string" || entry.publicKey !== request.publicKey) { - throw new EnrolmentRefusedError(`${request.keyId} is already enrolled with a different key`); + if ("reason" in entry || entry.publicKey !== request.publicKey) { + throw new EnrolmentRefusedError( + say("OSW-AGT-010", { keyId: request.keyId }).text, + "OSW-AGT-010", + ); } return entry; } diff --git a/packages/core/src/agent-status.test.ts b/packages/core/src/agent-status.test.ts index 136b8416..52966892 100644 --- a/packages/core/src/agent-status.test.ts +++ b/packages/core/src/agent-status.test.ts @@ -825,6 +825,7 @@ describe("readAgentStatuses", () => { expect(malformed).toHaveLength(1); expect(malformed[0]?.fileName).toBe("this-run-id.json"); expect(malformed[0]?.reason).toContain("someone-elses-id"); + expect(malformed[0]?.code).toBe("OSW-AGT-004"); }); it("reports a malformed record and the others survive", async () => { @@ -848,7 +849,7 @@ describe("readAgentStatuses", () => { await writeFile(path.join(directory, "good-run.json"), JSON.stringify(good), "utf8"); const { reports, malformed } = await readAgentStatuses(directory); - expect(malformed).toEqual([{ fileName: "broken.json", reason: "not valid JSON" }]); + expect(malformed).toEqual([{ fileName: "broken.json", reason: "not valid JSON", code: "OSW-AGT-001" }]); expect(reports).toHaveLength(1); expect(reports[0]?.instanceId).toBe("good-run"); }); @@ -960,7 +961,7 @@ describe("a request to stop this run (a-run-elsewhere-can-be-asked-to-stop 2.6)" expect(onStopRequested).not.toHaveBeenCalled(); expect(onStopRequestRefused).toHaveBeenCalledTimes(1); - expect(onStopRequestRefused).toHaveBeenCalledWith({ messageId, why: "unverified", reason: "live check" }); + expect(onStopRequestRefused).toHaveBeenCalledWith({ messageId, why: "unverified", reason: "live check", code: "OSW-AGT-101" }); expect(refused.reports[0]?.activity).toBe("a request to stop arrived, not verified; not acted on"); expect(later.reports[0]?.activity).toBe("applying"); await writer.stop(); @@ -1183,7 +1184,7 @@ describe("a note or a question addressed to this run", () => { expect(onMessage).not.toHaveBeenCalled(); expect(onMessageRefused).toHaveBeenCalledTimes(1); - expect(onMessageRefused).toHaveBeenCalledWith(expect.objectContaining({ messageId, why: "author-not-allowed" })); + expect(onMessageRefused).toHaveBeenCalledWith(expect.objectContaining({ messageId, why: "author-not-allowed", code: "OSW-AGT-104" })); expect(refused.reports[0]?.activity).toBe("a message arrived, from a run, which this run does not take; not taken"); expect(await readdir(messages)).toEqual([`${messageId}.json`]); await writer.stop(); diff --git a/packages/core/src/agent-status.ts b/packages/core/src/agent-status.ts index 03ba7600..ff63b71e 100644 --- a/packages/core/src/agent-status.ts +++ b/packages/core/src/agent-status.ts @@ -30,6 +30,7 @@ import { createGitWrapper, type GitWrapper } from "./git.js"; import { readGlobalHarnessConfig } from "./harness-config.js"; import { TASK_NUMBER_PATTERN } from "./harness-step-agent.js"; import { loadOrCreateMachineKey, type MachineKey } from "./machine-key.js"; +import { say, type MessageCode } from "./message-register.js"; import type { Command, Event } from "./protocol.js"; import type { EnrolledPerson, RecordSignature } from "./signature-facts.js"; import { isSignedEnvelope, openEnvelope, sealEnvelope, type EnvelopeSigner, type Roster } from "./signed-envelope.js"; @@ -54,19 +55,19 @@ export const AGENT_STATUS_STREAM_WRITE_INTERVAL_MS = 1_000; const LINE_BREAK = String.fromCharCode(10); -/** How a run's activity says why a request to stop it was not acted on. */ -const REFUSAL_WORDS: Readonly> = { - unverified: "not verified", - stale: "stale", - seen: "already read", -}; +/** The register's identifier for why a request to stop it was not acted on. */ +const REFUSAL_CODES = { + unverified: "OSW-AGT-101", + stale: "OSW-AGT-102", + seen: "OSW-AGT-103", +} as const satisfies Record; -/** The same words, and the one a conversation adds: a run refusing another +/** The same, and the one a conversation adds: a run refusing another * run (the-operator-can-say-something-to-a-run). */ -const MESSAGE_REFUSAL_WORDS: Readonly> = { - ...REFUSAL_WORDS, - "author-not-allowed": "from a run, which this run does not take", -}; +const MESSAGE_REFUSAL_CODES = { + ...REFUSAL_CODES, + "author-not-allowed": "OSW-AGT-104", +} as const satisfies Record; /** A note or a question as the run hands it to its host: what was said, the * enrolled person who said it, and the message's own id. */ @@ -86,6 +87,8 @@ export interface AgentStatusMessageRefusal { messageId: string; why: ConversationRefusal; words: string; + /** The register's identifier for `why`'s words. */ + code: MessageCode; } /** A request to stop a run, as the run hands it to its host: the reason, @@ -105,6 +108,8 @@ export interface AgentStatusStopRequestRefusal { messageId: string; why: StopRequestRefusal; reason: string; + /** The register's identifier for `why`'s words. */ + code: MessageCode; } /** The most of an unfinished line a stream is allowed to hold. A stream @@ -471,8 +476,8 @@ export class AgentStatusWriter { ...(afterTask !== undefined ? { afterTask } : {}), }); } else if (firstReading) { - this.setActivity(`a request to stop arrived, ${REFUSAL_WORDS[reading.why]}; not acted on`); - this.onStopRequestRefused?.({ messageId, why: reading.why, reason }); + this.setActivity(`a request to stop arrived, ${say(REFUSAL_CODES[reading.why]).text}; not acted on`); + this.onStopRequestRefused?.({ messageId, why: reading.why, reason, code: REFUSAL_CODES[reading.why] }); said = true; } } @@ -514,8 +519,8 @@ export class AgentStatusWriter { this.onMessage({ messageId, kind, words, from: reading.person.label, fromKeyId: reading.person.keyId, author, sentAt }); await forgetMessage(this.messageDirectory, messageId).catch(() => undefined); } else if (firstReading && reading.why !== "seen") { - this.setActivity(`a message arrived, ${MESSAGE_REFUSAL_WORDS[reading.why]}; not taken`); - this.onMessageRefused?.({ messageId, why: reading.why, words }); + this.setActivity(`a message arrived, ${say(MESSAGE_REFUSAL_CODES[reading.why]).text}; not taken`); + this.onMessageRefused?.({ messageId, why: reading.why, words, code: MESSAGE_REFUSAL_CODES[reading.why] }); said = true; } } @@ -679,6 +684,9 @@ export interface AgentStatusReport { export interface AgentStatusMalformed { fileName: string; reason: string; + /** The register's identifier for `reason`, where it is one the register + * holds. A filesystem error's own words have none. */ + code?: MessageCode; } export interface AgentStatusReadResult { @@ -738,7 +746,7 @@ function readStopRequested(value: unknown): AgentStatusStopRequest | null { * being read. */ type AgentStatusRecordReading = | { kind: "report"; report: AgentStatusReport } - | { kind: "malformed"; reason: string } + | { kind: "malformed"; reason: string; code?: MessageCode } | { kind: "missing" }; /** A record whose signature does not check out: its file name, why, and @@ -792,7 +800,7 @@ async function readAgentStatusRecord( try { parsed = JSON.parse(raw); } catch { - return { kind: "malformed", reason: "not valid JSON" }; + return { kind: "malformed", reason: say("OSW-AGT-001").text, code: "OSW-AGT-001" }; } let signature: RecordSignature = "unverified"; let person: EnrolledPerson | undefined; @@ -810,11 +818,11 @@ async function readAgentStatusRecord( try { parsed = JSON.parse(Buffer.from(opened.bytes).toString("utf8")); } catch { - return { kind: "malformed", reason: "its signed payload is not valid JSON" }; + return { kind: "malformed", reason: say("OSW-AGT-002").text, code: "OSW-AGT-002" }; } } if (typeof parsed !== "object" || parsed === null) { - return { kind: "malformed", reason: "missing or invalid required fields" }; + return { kind: "malformed", reason: say("OSW-AGT-003").text, code: "OSW-AGT-003" }; } const document = parsed as Partial>; if ( @@ -824,18 +832,19 @@ async function readAgentStatusRecord( typeof document.activityAt !== "string" || typeof document.heartbeatAt !== "string" ) { - return { kind: "malformed", reason: "missing or invalid required fields" }; + return { kind: "malformed", reason: say("OSW-AGT-003").text, code: "OSW-AGT-003" }; } if (document.instanceId !== expectedId) { return { kind: "malformed", - reason: `record identity "${document.instanceId}" does not match file name "${expectedId}"`, + reason: say("OSW-AGT-004", { instanceId: document.instanceId, expectedId }).text, + code: "OSW-AGT-004", }; } const activitySinceMs = now().getTime() - Date.parse(document.activityAt); const heartbeatAgeMs = now().getTime() - Date.parse(document.heartbeatAt); if (!Number.isFinite(activitySinceMs) || !Number.isFinite(heartbeatAgeMs)) { - return { kind: "malformed", reason: "activityAt or heartbeatAt is not a valid timestamp" }; + return { kind: "malformed", reason: say("OSW-AGT-005").text, code: "OSW-AGT-005" }; } return { kind: "report", @@ -906,7 +915,9 @@ export async function readAgentStatuses( if (!fileName.endsWith(".json")) continue; const reading = await readAgentStatusRecord(directory, fileName, now, staleAfterMs, roster); if (reading.kind === "report") reports.push(reading.report); - else if (reading.kind === "malformed") malformed.push({ fileName, reason: reading.reason }); + else if (reading.kind === "malformed") { + malformed.push({ fileName, reason: reading.reason, ...(reading.code !== undefined ? { code: reading.code } : {}) }); + } } return { reports, malformed }; diff --git a/packages/core/src/agents/acp-session-driver.test.ts b/packages/core/src/agents/acp-session-driver.test.ts index f74b518f..1ca0bb87 100644 --- a/packages/core/src/agents/acp-session-driver.test.ts +++ b/packages/core/src/agents/acp-session-driver.test.ts @@ -383,3 +383,24 @@ describe("AcpSessionDriver — reporting what the agent said it spent", () => { } }); }); + +describe("AcpSessionDriver — a refused prompt", () => { + it("a refusal stop reason fails the run under the register's identifier", async () => { + const mockAgent = agent({ name: "mock-agent" }) + .onRequest(AGENT_METHODS.initialize, () => ({ protocolVersion: PROTOCOL_VERSION })) + .onRequest(AGENT_METHODS.session_new, () => ({ sessionId: "session-1" })) + .onRequest(AGENT_METHODS.session_prompt, () => ({ stopReason: "refusal" })); + + const driver = new AcpSessionDriver(); + const events = await collect( + driver.run({ target: mockAgent, cwd: "/tmp/work", runId: "run-r1", commandKind: "implement", prompt: "do it" }), + ); + + const failed = events.find((event) => event.kind === "failed"); + expect(failed).toBeDefined(); + if (failed?.kind === "failed") { + expect(failed.reason).toBe("agent refused the prompt"); + expect(failed.code).toBe("OSW-AGT-218"); + } + }); +}); diff --git a/packages/core/src/agents/acp-session-driver.ts b/packages/core/src/agents/acp-session-driver.ts index 60191350..55bb35ff 100644 --- a/packages/core/src/agents/acp-session-driver.ts +++ b/packages/core/src/agents/acp-session-driver.ts @@ -30,6 +30,7 @@ import { } from "@agentclientprotocol/sdk"; import os from "node:os"; import type { AgentUsage } from "../agent-usage.js"; +import { say } from "../message-register.js"; import { commandLineHash, defaultProcessLedger, type ProcessLedger } from "../process-ledger.js"; import { readProcessStartTime, type StartTimeReader } from "../process-start-time.js"; import type { CommandKind, Event } from "../protocol.js"; @@ -358,7 +359,7 @@ export class AcpSessionDriver { if (item.stopReason === "cancelled") { yield { kind: "cancelled", runId, timestamp: nowIso() }; } else if (item.stopReason === "refusal") { - yield { kind: "failed", runId, timestamp: nowIso(), reason: "agent refused the prompt" }; + yield { kind: "failed", runId, timestamp: nowIso(), reason: say("OSW-AGT-218").text, code: "OSW-AGT-218" }; } else { yield { kind: "completed", runId, timestamp: nowIso() }; } @@ -462,13 +463,15 @@ export class AcpSessionDriver { if (exited) { yield { kind: "cancelled", runId, timestamp: nowIso() }; } else { + const failure = killFailure + ? say("OSW-AGT-219", { killFailure }) + : say("OSW-AGT-220", { seconds: KILL_CONFIRMATION_TIMEOUT_MS / 1000 }); yield { kind: "failed", runId, timestamp: nowIso(), - reason: killFailure - ? `cancellation could not be carried out: ${killFailure}. The agent process may still be running.` - : `the agent process did not exit within ${KILL_CONFIRMATION_TIMEOUT_MS / 1000}s of being terminated, and may still be running.`, + reason: failure.text, + code: failure.code, }; } } diff --git a/packages/core/src/agents/local-llm.ts b/packages/core/src/agents/local-llm.ts index ceceef44..4b57e585 100644 --- a/packages/core/src/agents/local-llm.ts +++ b/packages/core/src/agents/local-llm.ts @@ -12,6 +12,7 @@ import type { FetchLike } from "../direct-fetch.js"; import { chatCompletionsUrl, describeLocalLlmModel, resolveLocalLlmModel } from "../local-llm-settings.js"; import { completeTurn, type ChatMessage } from "./local-agent/chat-client.js"; import { runWebResearchTool, WEB_RESEARCH_PARAMETER_TYPES, WEB_RESEARCH_TOOL_SCHEMAS } from "../web-research.js"; +import { say } from "../message-register.js"; const MAX_WEB_TOOL_ITERATIONS = 10; const MAX_WEB_TOOL_CALLS = 20; @@ -115,7 +116,7 @@ export class LocalLlmAdapter implements AgentAdapter { for (const call of turn.calls) { toolCalls += 1; if (toolCalls > MAX_WEB_TOOL_CALLS) { - const reason = `Stopped at the ${MAX_WEB_TOOL_CALLS}-web-tool-call limit.`; + const reason = say("OSW-NET-202", { calls: MAX_WEB_TOOL_CALLS }).text; yield { kind: "stdout", runId, timestamp: nowIso(), chunk: `\n${reason}` }; yield { kind: "completed", runId, timestamp: nowIso(), summary: full || reason }; return; @@ -130,7 +131,7 @@ export class LocalLlmAdapter implements AgentAdapter { } } - const reason = `Stopped at the ${MAX_WEB_TOOL_ITERATIONS}-iteration web-tool limit.`; + const reason = say("OSW-NET-203", { iterations: MAX_WEB_TOOL_ITERATIONS }).text; yield { kind: "stdout", runId, timestamp: nowIso(), chunk: `\n${reason}` }; yield { kind: "completed", runId, timestamp: nowIso(), summary: full || reason }; } diff --git a/packages/core/src/archive-guard.test.ts b/packages/core/src/archive-guard.test.ts index 5f0178b2..93bb3440 100644 --- a/packages/core/src/archive-guard.test.ts +++ b/packages/core/src/archive-guard.test.ts @@ -35,14 +35,18 @@ describe("archiving a change that still owes something", () => { it("refuses an open item, names it, and moves nothing", async () => { const root = await workspace("- [x] 1.1 Done.\n- [ ] 1.2 Not done.\n"); - await expect(archiveChange("the-change", { cwd: root })).rejects.toThrow(/still open: 1\.2 Not done\./); + const error = await archiveChange("the-change", { cwd: root }).catch((caught: unknown) => caught); + expect(String(error)).toContain("still open: 1.2 Not done."); + expect(error).toMatchObject({ code: "OSW-ARC-004" }); expect(await stillThere(root)).toBe(true); }); it("refuses a human-only item closed with nothing written under it", async () => { const root = await workspace("- [x] 1.1 **Human-only.** Whether it reads.\n"); - await expect(archiveChange("the-change", { cwd: root })).rejects.toThrow(/closed with nothing written under it/); + const error = await archiveChange("the-change", { cwd: root }).catch((caught: unknown) => caught); + expect(String(error)).toContain("closed with nothing written under it"); + expect(error).toMatchObject({ code: "OSW-ARC-004" }); expect(await stillThere(root)).toBe(true); }); diff --git a/packages/core/src/chain-preflight.test.ts b/packages/core/src/chain-preflight.test.ts index 34487617..c3532f26 100644 --- a/packages/core/src/chain-preflight.test.ts +++ b/packages/core/src/chain-preflight.test.ts @@ -122,6 +122,7 @@ describe("resolveChainStart", () => { expect(resolution).toMatchObject({ ok: false }); if (!resolution.ok) { expect(resolution.refusal.reason).toContain("\"assisted\""); + expect(resolution.refusal.code).toBe("OSW-HRN-114"); expect(resolution.refusal.configKey).toBe("autonomyLevel"); } }); @@ -182,6 +183,7 @@ describe("resolveChainStart", () => { if (!resolution.ok) { expect(resolution.refusal.reason).toContain("\"apply\""); expect(resolution.refusal.reason).toContain("codex-cli"); + expect(resolution.refusal.code).toBe("OSW-HRN-118"); expect(resolution.refusal.configKey).toBe("stepAgents.apply"); } }); diff --git a/packages/core/src/chain-preflight.ts b/packages/core/src/chain-preflight.ts index 2cb16968..c7584bd2 100644 --- a/packages/core/src/chain-preflight.ts +++ b/packages/core/src/chain-preflight.ts @@ -30,6 +30,7 @@ import { resolveRunWithHarnessTarget, } from "./harness-config.js"; import { CHAIN_STAGES } from "./harness-chain-runner.js"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; /** Why a chain will not be started, and where the decision lives. * @@ -41,9 +42,18 @@ import { CHAIN_STAGES } from "./harness-chain-runner.js"; * does not ship). */ export interface ChainStartRefusal { reason: string; + /** The register's identifier for `reason`. */ + code: MessageCode; configKey?: string; } +/** A refusal with its words from the register under their identifier. */ +function refusalOf(said: SaidMessage, configKey?: string): ChainStartRefusal { + return configKey === undefined + ? { reason: said.text, code: said.code } + : { reason: said.text, code: said.code, configKey }; +} + export type ChainStartResolution = | { ok: true; config: HarnessConfig; changeDir: string } | { ok: false; refusal: ChainStartRefusal }; @@ -96,7 +106,7 @@ export async function resolveChainStart(request: ChainStartRequest): Promise 0) { return { ok: false, - refusal: { + refusal: refusalOf(say("OSW-HRN-116", { // Every blocker, not the first: a reader who lands one and is // refused again for the next was told half the truth. - reason: `this change declares that it is blocked by ${unmet.join(", ")},` - + ` which ${unmet.length === 1 ? "is" : "are"} still active.` - + " Land it and archive it, or remove the line from" - + ` openspec/changes/${changeName}/.openspec.yaml — there is no option that starts it anyway,` - + " because the declaration is a sentence its author wrote in a file under version control.", - // Deliberately no configKey: the remedy is not a setting. - }, + blockers: unmet.join(", "), + verb: unmet.length === 1 ? "is" : "are", + change: changeName, + })), }; } @@ -205,11 +195,7 @@ export async function resolveChainStart(request: ChainStartRequest): Promise { expect(result.ok).toBe(false); expect(!result.ok && result.reason).toBe("unknown-change"); + expect(!result.ok && result.code).toBe("OSW-VAL-009"); expect(!result.ok && result.ids).toEqual(["absent"]); expect(await metadataOf(root, "first")).toBe(BASE); }); @@ -144,6 +145,7 @@ describe("editChangeRelation", () => { const result = await editChangeRelation(root, { change: "first", key: "follows", add: "first" }); expect(!result.ok && result.reason).toBe("self-relation"); + expect(!result.ok && result.code).toBe("OSW-VAL-008"); expect(await metadataOf(root, "first")).toBe(BASE); }); @@ -153,6 +155,7 @@ describe("editChangeRelation", () => { const result = await editChangeRelation(root, { change: "second", key: "blocked_by", add: "first" }); expect(!result.ok && result.reason).toBe("cycle"); + expect(!result.ok && result.code).toBe("OSW-VAL-010"); expect(!result.ok && result.ids).toContain("first"); expect(!result.ok && result.ids).toContain("second"); expect(await metadataOf(root, "second")).toBe(BASE); @@ -176,6 +179,7 @@ describe("editChangeRelation", () => { const result = await editChangeRelation(root, { change: "old", key: "follows", add: "live" }); expect(!result.ok && result.reason).toBe("archived-change"); + expect(!result.ok && result.code).toBe("OSW-VAL-005"); expect(await metadataOf(root, "archive/2026-08-08-old")).toBe(BASE); }); @@ -187,6 +191,18 @@ describe("editChangeRelation", () => { const result = await editChangeRelation(root, { change: "first", key: "follows", add: "second" }); expect(!result.ok && result.reason).toBe("unreadable-metadata"); + expect(!result.ok && result.code).toBe("OSW-VAL-006"); + }); + + it("refuses an edit that names no change to relate", async () => { + const root = await repoWith({ first: BASE }); + + const result = await editChangeRelation(root, { change: "first", key: "follows" }); + + expect(!result.ok && result.reason).toBe("unknown-change"); + expect(!result.ok && result.code).toBe("OSW-VAL-007"); + expect(!result.ok && result.ids).toEqual([]); + expect(await metadataOf(root, "first")).toBe(BASE); }); it("refuses a change the workspace does not have", async () => { @@ -195,5 +211,6 @@ describe("editChangeRelation", () => { const result = await editChangeRelation(root, { change: "absent", key: "follows", add: "first" }); expect(!result.ok && result.reason).toBe("unknown-change"); + expect(!result.ok && result.code).toBe("OSW-VAL-004"); }); }); diff --git a/packages/core/src/change-relations-file.ts b/packages/core/src/change-relations-file.ts index e59037cd..724996f0 100644 --- a/packages/core/src/change-relations-file.ts +++ b/packages/core/src/change-relations-file.ts @@ -20,6 +20,7 @@ import { type ChangeGraph, type ChangeRelationKey, } from "./change-graph.js"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; const KEY_LINE = /^(follows|supersedes|blocked_by)\s*:(.*)$/u; const COMMENT = /^\s*#/u; @@ -96,9 +97,18 @@ export interface RelationEditRefusal { /** The changes the refusal is about: the id nothing matches, or the * changes in the cycle, in the order they close it. */ ids: string[]; - /** One sentence, ready to show. A host decides where it goes; core - * decides what is wrong with the edit. */ + /** One sentence, ready to show — the register's words (ADR 0046). A + * host decides where it goes; core decides what is wrong with the + * edit. */ message: string; + /** The identifier of the message in `message`. */ + code: MessageCode; +} + +/** A refusal carrying the register's words and the identifier beside + * them. */ +function refusal(reason: RelationRefusalReason, ids: string[], said: SaidMessage): RelationEditRefusal { + return { ok: false, reason, ids, message: said.text, code: said.code }; } export interface RelationEditApplied { @@ -140,44 +150,27 @@ export async function editChangeRelation(root: string, edit: RelationEdit): Prom const graph = await readChangeGraph(root, { changes: "all" }); const node = graph.get(edit.change); if (!node) { - return { ok: false, reason: "unknown-change", ids: [edit.change], message: `No change named ${edit.change}` }; + return refusal("unknown-change", [edit.change], say("OSW-VAL-004", { change: edit.change })); } if (node.archived) { - return { - ok: false, - reason: "archived-change", - ids: [edit.change], - message: `${edit.change} is archived, and an archived change's relations are history`, - }; + return refusal("archived-change", [edit.change], say("OSW-VAL-005", { change: edit.change })); } if (node.errors.length > 0) { - return { - ok: false, - reason: "unreadable-metadata", - ids: [edit.change], - message: `${node.metadataPath} cannot be read as relations: ${node.errors[0]}`, - }; + return refusal("unreadable-metadata", [edit.change], say("OSW-VAL-006", { + path: node.metadataPath, + error: String(node.errors[0]), + })); } const named = edit.add ?? edit.remove; if (named === undefined) { - return { ok: false, reason: "unknown-change", ids: [], message: "The edit names no change to relate" }; + return refusal("unknown-change", [], say("OSW-VAL-007")); } if (named === edit.change) { - return { - ok: false, - reason: "self-relation", - ids: [edit.change], - message: `A change cannot state ${edit.key} on itself`, - }; + return refusal("self-relation", [edit.change], say("OSW-VAL-008", { key: edit.key })); } if (edit.add !== undefined && !graph.has(edit.add)) { - return { - ok: false, - reason: "unknown-change", - ids: [edit.add], - message: `No change named ${edit.add}, active or archived`, - }; + return refusal("unknown-change", [edit.add], say("OSW-VAL-009", { change: edit.add })); } const field = fieldOf(edit.key); @@ -192,12 +185,7 @@ export async function editChangeRelation(root: string, edit: RelationEdit): Prom after.set(node.id, { ...node, [field]: ids }); const closed = findChangeGraphCycles(after).find((cycle) => !before.has([...cycle].sort().join(">"))); if (closed) { - return { - ok: false, - reason: "cycle", - ids: closed, - message: `That would close a cycle: ${closed.join(" then ")}`, - }; + return refusal("cycle", closed, say("OSW-VAL-010", { cycle: closed.join(" then ") })); } } diff --git a/packages/core/src/change-worktrees.test.ts b/packages/core/src/change-worktrees.test.ts index d6a8e9b2..380b4489 100644 --- a/packages/core/src/change-worktrees.test.ts +++ b/packages/core/src/change-worktrees.test.ts @@ -158,6 +158,7 @@ describe("planChangeWorktree", () => { expect(plan).toMatchObject({ ok: false }); if (!plan.ok) { expect(plan.refusal.reason).toContain("is not in main"); + expect(plan.refusal.code).toBe("OSW-WSP-005"); expect(plan.refusal.remedy).toContain("commit openspec/changes/a-change"); } expect(git.added).toHaveLength(0); @@ -198,7 +199,10 @@ describe("planChangeWorktree", () => { const plan = await planChangeWorktree({ git, repositoryRoot: root, changeName: "a-change" }); expect(plan).toMatchObject({ ok: false }); - if (!plan.ok) expect(plan.refusal.reason).toContain("/repo.worktrees/a-change"); + if (!plan.ok) { + expect(plan.refusal.reason).toContain("/repo.worktrees/a-change"); + expect(plan.refusal.code).toBe("OSW-WSP-006"); + } expect(git.added).toHaveLength(0); }); @@ -211,7 +215,10 @@ describe("planChangeWorktree", () => { const plan = await planChangeWorktree({ git, repositoryRoot: root, changeName: "a-change", path: target }); expect(plan).toMatchObject({ ok: false }); - if (!plan.ok) expect(plan.refusal.reason).toContain("already exists"); + if (!plan.ok) { + expect(plan.refusal.reason).toContain("already exists"); + expect(plan.refusal.code).toBe("OSW-WSP-007"); + } expect(git.added).toHaveLength(0); }); @@ -222,7 +229,10 @@ describe("planChangeWorktree", () => { const plan = await planChangeWorktree({ git, repositoryRoot: root, changeName: "../../etc" }); expect(plan).toMatchObject({ ok: false }); - if (!plan.ok) expect(plan.refusal.reason).toContain("not a valid change name"); + if (!plan.ok) { + expect(plan.refusal.reason).toContain("not a valid change name"); + expect(plan.refusal.code).toBe("OSW-WSP-004"); + } }); }); @@ -287,6 +297,7 @@ describe("planChangeWorktree — a branch left behind by a removal", () => { expect(plan.ok).toBe(false); if (!plan.ok) { expect(plan.refusal.reason).toContain("no working directory on it"); + expect(plan.refusal.code).toBe("OSW-WSP-008"); // Git's own message is true and says nothing about what to do next. expect(plan.refusal.remedy).toContain("git branch -D a-change"); } diff --git a/packages/core/src/change-worktrees.ts b/packages/core/src/change-worktrees.ts index 5574c239..cc5db4a1 100644 --- a/packages/core/src/change-worktrees.ts +++ b/packages/core/src/change-worktrees.ts @@ -19,6 +19,7 @@ import { isValidChangeName } from "./change-name.js"; import type { GitWorktree, GitWrapper } from "./git.js"; import { discoverOpenSpecWorkspace } from "./workbench.js"; import { resolveWorktreeRoot, worktreePathUnder, type WorktreeRootSources } from "./worktree-root.js"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; /** One working directory, said in this product's terms. */ export interface ChangeWorktree extends GitWorktree { @@ -43,9 +44,11 @@ export interface ChangeWorktree extends GitWorktree { /** Why a working directory will not be created. `remedy` is the point of * this being a structure: a refusal a reader can act on says what to do - * next, not only what went wrong. */ + * next, not only what went wrong. `reason` is the register's sentence and + * `code` its identifier (ADR 0046). */ export interface ChangeWorktreeRefusal { reason: string; + code: MessageCode; remedy: string; } @@ -88,6 +91,12 @@ async function exists(target: string): Promise { } } +/** A refusal whose words come from the register, under their identifier + * (ADR 0046). */ +function refusalOf(said: SaidMessage, remedy: string): ChangeWorktreeRefusal { + return { reason: said.text, code: said.code, remedy }; +} + /** Resolves everything that must hold before a working directory is * created, and returns either what to create or the one reason not to. * @@ -114,10 +123,10 @@ export async function planChangeWorktree(options: { if (!isValidChangeName(changeName)) { return { ok: false, - refusal: { - reason: `"${changeName}" is not a valid change name`, - remedy: "name the change as it is named in openspec/changes", - }, + refusal: refusalOf( + say("OSW-WSP-004", { name: changeName }), + "name the change as it is named in openspec/changes", + ), }; } @@ -134,10 +143,10 @@ export async function planChangeWorktree(options: { ) { return { ok: false, - refusal: { - reason: `"${changeName}" is not in ${base}`, - remedy: `commit ${changePathInRepo(changeName)} to ${base} first, or pass a base that has it`, - }, + refusal: refusalOf( + say("OSW-WSP-005", { name: changeName, base }), + `commit ${changePathInRepo(changeName)} to ${base} first, or pass a base that has it`, + ), }; } @@ -146,20 +155,20 @@ export async function planChangeWorktree(options: { if (onBranch) { return { ok: false, - refusal: { - reason: `branch "${changeName}" is already checked out at ${onBranch.path}`, - remedy: "run the chain in that directory, or remove it first", - }, + refusal: refusalOf( + say("OSW-WSP-006", { name: changeName, path: onBranch.path }), + "run the chain in that directory, or remove it first", + ), }; } if (await exists(target)) { return { ok: false, - refusal: { - reason: `${target} already exists`, - remedy: "remove it, or pass another path", - }, + refusal: refusalOf( + say("OSW-WSP-007", { path: target }), + "remove it, or pass another path", + ), }; } @@ -170,10 +179,10 @@ export async function planChangeWorktree(options: { if (await git.branchExists(changeName)) { return { ok: false, - refusal: { - reason: `branch "${changeName}" already exists, with no working directory on it`, - remedy: `check it out where you want it, or delete it with "git branch -D ${changeName}" if its work has landed`, - }, + refusal: refusalOf( + say("OSW-WSP-008", { name: changeName }), + `check it out where you want it, or delete it with "git branch -D ${changeName}" if its work has landed`, + ), }; } diff --git a/packages/core/src/delegated-item-run.test.ts b/packages/core/src/delegated-item-run.test.ts index 8e1bee5a..f3485c1d 100644 --- a/packages/core/src/delegated-item-run.test.ts +++ b/packages/core/src/delegated-item-run.test.ts @@ -141,6 +141,7 @@ describe("runDelegatedItem — what reaches the agent and the log", () => { }); expect(result.status).toBe("refused"); + expect(result.code).toBe("OSW-TSK-006"); expect(result.message).toContain('"copilto-cli" is not an agent this build recognises'); // Not even a runner was asked for: the refusal happens first. expect(spawned).toBe(false); @@ -159,6 +160,7 @@ describe("runDelegatedItem — what reaches the agent and the log", () => { }); expect(result.status).toBe("refused"); + expect(result.code).toBe("OSW-TSK-004"); expect(result.message).toContain("marked for a person"); }); @@ -173,6 +175,7 @@ describe("runDelegatedItem — what reaches the agent and the log", () => { }); expect(result.status).toBe("refused"); + expect(result.code).toBe("OSW-TSK-005"); expect(result.message).toContain("names no agent"); }); }); @@ -225,6 +228,8 @@ describe("runDelegatedItem — what a run says about itself (a-delegated-run-say expect(result.message).toContain( "The run failed: copilot exited with code 1. It last said: API Error: 400 this version is too old.", ); + // The gate found the item still open, and that is the message's code. + expect(result.code).toBe("OSW-TSK-032"); expect(kinds).toEqual(expect.arrayContaining(["stderr", "failed"])); }); @@ -339,7 +344,7 @@ describe("runDelegatedItem — the rubber-stamp gate", () => { ), }); - expect(result).toMatchObject({ status: "ran", gate: { kind: "reverted" } }); + expect(result).toMatchObject({ status: "ran", gate: { kind: "reverted", code: "OSW-TSK-035" }, code: "OSW-TSK-029" }); expect(result.message).toContain("came back ticked with nothing written"); // Asserted over the file, not over a value in memory: the tick is // only reverted if tasks.md itself came back unticked. @@ -367,7 +372,7 @@ describe("runDelegatedItem — the rubber-stamp gate", () => { ), }); - expect(result).toMatchObject({ status: "ran", gate: { kind: "recorded" } }); + expect(result).toMatchObject({ status: "ran", gate: { kind: "recorded" }, code: "OSW-TSK-030" }); // And the surface says only that something was recorded. expect(result.message).toContain("Something was recorded; whether it is true was not checked."); const lines = (await readFile(tasksPath, "utf8")).split("\n"); @@ -396,7 +401,7 @@ describe("runDelegatedItem — the rubber-stamp gate", () => { ), }); - expect(result).toMatchObject({ status: "ran", gate: { kind: "still-open" } }); + expect(result).toMatchObject({ status: "ran", gate: { kind: "still-open" }, code: "OSW-TSK-032" }); expect((await readFile(tasksPath, "utf8")).split("\n")[3]) .toBe(" Could not run: the binary is not installed here."); }); @@ -418,7 +423,7 @@ describe("runDelegatedItem — the rubber-stamp gate", () => { }); // Located by its number, so the silent tick is still caught. - expect(result).toMatchObject({ status: "ran", gate: { kind: "reverted" } }); + expect(result).toMatchObject({ status: "ran", gate: { kind: "reverted", code: "OSW-TSK-035" }, code: "OSW-TSK-029" }); expect((await readFile(tasksPath, "utf8")).split("\n")[4]) .toBe("- [ ] 2.1 **Delegated to copilot-cli**: quote the audit line"); }); @@ -438,7 +443,7 @@ describe("runDelegatedItem — the rubber-stamp gate", () => { }), }); - expect(result).toMatchObject({ status: "ran", gate: { kind: "item-not-found" } }); + expect(result).toMatchObject({ status: "ran", gate: { kind: "item-not-found", code: "OSW-TSK-033" }, code: "OSW-TSK-031" }); expect(result.message).toContain("could not be found again"); }); }); diff --git a/packages/core/src/delegated-item-run.ts b/packages/core/src/delegated-item-run.ts index b859ec59..90fb080f 100644 --- a/packages/core/src/delegated-item-run.ts +++ b/packages/core/src/delegated-item-run.ts @@ -25,6 +25,7 @@ import type { AuditLog } from "./security.js"; import { normalizeStepAgent } from "./harness-step-agent.js"; import type { AgentRunner } from "./agent-runner.js"; import type { Command, Event } from "./protocol.js"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; import { TASK_CHECKBOX_LINE_RE, readTaskChecklist, @@ -44,14 +45,15 @@ export type DelegatedItemGate = * agent wrote it; nothing here says the evidence is true. */ | { kind: "recorded" } /** Ticked with the text otherwise unchanged. The tick was reverted. */ - | { kind: "reverted"; reason: string } + | { kind: "reverted"; reason: string; code: MessageCode } /** The item could not be found again after the run, so nothing could * be compared — reported rather than assumed either way. */ - | { kind: "item-not-found"; reason: string }; + | { kind: "item-not-found"; reason: string; code: MessageCode }; export type DelegatedItemRunResult = - /** Nothing was spawned. */ - | { status: "refused"; reason: string; message: string } + /** Nothing was spawned. `reason` is the register's words and `code` + * their identifier; `message` wraps them for a surface. */ + | { status: "refused"; reason: string; code: MessageCode; message: string } | { status: "ran"; runId: string; @@ -66,6 +68,8 @@ export type DelegatedItemRunResult = * that wrote nothing there (a-delegated-run-says-what-happened). */ lastStderr?: string; gate: DelegatedItemGate; + /** `message`, from the register, and its identifier. */ + code: MessageCode; message: string; /** The agent's reply: the end of what it last said, and how it left the * item. Kept whatever the outcome (a-change-says-where-it-stands). */ @@ -221,8 +225,13 @@ async function findBlockAfterRun( return readTaskBlockAt(lines, before.lineNumber); } -function refused(reason: string): DelegatedItemRunResult { - return { status: "refused", reason, message: `No run was started: ${reason}.` }; +function refused(said: SaidMessage): DelegatedItemRunResult { + return { + status: "refused", + reason: said.text, + code: said.code, + message: `No run was started: ${said.text}.`, + }; } /** What the surface says about a finished run. Says what the gate @@ -234,7 +243,7 @@ function describeRun( outcome: "completed" | "failed" | "cancelled", reason: string | undefined, gate: DelegatedItemGate, -): string { +): SaidMessage { const named = item.taskNumber !== undefined ? `Task ${item.taskNumber}` : "The item"; const ended = outcome === "completed" ? "The run finished." @@ -242,14 +251,13 @@ function describeRun( switch (gate.kind) { case "reverted": - return `${ended} ${named} came back ticked with nothing written, so the tick was reverted and the run is refused.`; + return say("OSW-TSK-029", { ending: ended, item: named }); case "recorded": - return `${ended} ${named} is ticked and its text carries more than it did.` - + " Something was recorded; whether it is true was not checked."; + return say("OSW-TSK-030", { ending: ended, item: named }); case "item-not-found": - return `${ended} ${named} could not be found again afterwards, so nothing was compared: ${gate.reason}.`; + return say("OSW-TSK-031", { ending: ended, item: named, reason: gate.reason }); case "still-open": - return `${ended} ${named} is still open.`; + return say("OSW-TSK-032", { ending: ended, item: named }); } } @@ -286,42 +294,40 @@ export async function runDelegatedItem(request: DelegatedItemRunRequest): Promis const tasksPath = await tasksFilePath(workspaceRoot, changeName, false); if (tasksPath === undefined) { - return refused(`change "${changeName}" has no tasks.md`); + return refused(say("OSW-TSK-001", { change: changeName })); } const tasks = await readTaskChecklist(workspaceRoot, changeName, false); const task = tasks.find((candidate) => candidate.lineNumber === lineNumber); - if (!task) return refused(`no task at line ${lineNumber + 1} of ${changeName}/tasks.md`); - if (task.done) return refused(`task at line ${lineNumber + 1} of ${changeName}/tasks.md is already ticked`); + if (!task) return refused(say("OSW-TSK-002", { line: lineNumber + 1, change: changeName })); + if (task.done) return refused(say("OSW-TSK-003", { line: lineNumber + 1, change: changeName })); if (task.humanOnly) { // Not "no agent is configured" — the marking is the whole point. An // item nobody can close is offered no run, and saying why is what // keeps the two markings distinguishable. - return refused( - `the item at line ${lineNumber + 1} of ${changeName}/tasks.md is marked for a person, and no agent is offered for it`, - ); + return refused(say("OSW-TSK-004", { line: lineNumber + 1, change: changeName })); } const resolved = await resolveDelegatedItems(workspaceRoot, changeName); const item: DelegatedItem | undefined = resolved.items.find((candidate) => candidate.lineNumber === lineNumber); if (!item) { - return refused(`the item at line ${lineNumber + 1} of ${changeName}/tasks.md names no agent`); + return refused(say("OSW-TSK-005", { line: lineNumber + 1, change: changeName })); } if (!item.known) { - return refused(`"${item.agent}" is not an agent this build recognises`); + return refused(say("OSW-TSK-006", { agent: item.agent })); } if (item.changeDir.length === 0) { - return refused(`change "${changeName}" was not found in this workspace`); + return refused(say("OSW-TSK-007", { change: changeName })); } const runner = request.resolveRunner(item.agent); if (!runner) { - return refused(`no runner is configured for agent "${item.agent}"`); + return refused(say("OSW-TSK-008", { agent: item.agent })); } const before = readTaskBlockAt(await readLines(tasksPath), lineNumber); if (!before) { - return refused(`the line at ${lineNumber + 1} of ${changeName}/tasks.md is not a checklist item`); + return refused(say("OSW-TSK-009", { line: lineNumber + 1, change: changeName })); } const beforeText = blockTextWithoutState(before); @@ -429,6 +435,8 @@ export async function runDelegatedItem(request: DelegatedItemRunRequest): Promis }, }); + const reported = describeRun(item, outcome, told, gate); + return { status: "ran", runId, @@ -438,7 +446,8 @@ export async function runDelegatedItem(request: DelegatedItemRunRequest): Promis ...(reason !== undefined ? { reason } : {}), ...(lastStderr !== undefined ? { lastStderr } : {}), gate, - message: describeRun(item, outcome, told, gate), + code: reported.code, + message: reported.text, reply, }; } @@ -482,7 +491,7 @@ function withLastWords(reason: string | undefined, lastStderr: string): string { const lines = lastStderr.split(LINE_BREAK); const last = (lines[lines.length - 1] ?? "").trim(); const said = last.endsWith(".") ? last.slice(0, -1) : last; - return `${reason ?? "no reason was given"}. It last said: ${said}`; + return say("OSW-TSK-036", { reason: reason ?? "no reason was given", said }).text; } async function checkRubberStamp( @@ -501,12 +510,10 @@ async function checkRubberStamp( lines, ); if (!after) { - return { - kind: "item-not-found", - reason: taskNumber !== undefined - ? `no open task numbered ${taskNumber} is left in ${changeName}/tasks.md` - : `line ${before.lineNumber + 1} of ${changeName}/tasks.md is no longer a checklist item`, - }; + const said = taskNumber !== undefined + ? say("OSW-TSK-033", { task: taskNumber, change: changeName }) + : say("OSW-TSK-034", { line: before.lineNumber + 1, change: changeName }); + return { kind: "item-not-found", reason: said.text, code: said.code }; } if (!after.done) return { kind: "still-open" }; @@ -517,8 +524,6 @@ async function checkRubberStamp( await writeTaskCheckStates(workspaceRoot, changeName, false, [ { lineNumber: after.lineNumber, expectedText: text, done: false }, ]); - return { - kind: "reverted", - reason: "the item became ticked while saying nothing it did not say before", - }; + const said = say("OSW-TSK-035"); + return { kind: "reverted", reason: said.text, code: said.code }; } diff --git a/packages/core/src/delegated-items.test.ts b/packages/core/src/delegated-items.test.ts index 60f2a2d4..48f01f26 100644 --- a/packages/core/src/delegated-items.test.ts +++ b/packages/core/src/delegated-items.test.ts @@ -187,10 +187,12 @@ describe("collectHumanOnlyInbox reads the same precedence", () => { expect(inbox.unmatchedTaskAgents).toEqual([ { changeName: "demo", taskNumber: "9.9", agent: "copilot-cli", reason: "no-such-open-task" }, ]); - expect(describeHumanOnlyInbox(inbox)).toBe( + const said = describeHumanOnlyInbox(inbox); + expect(said.text).toBe( "Nothing is waiting — 1 active change read." + " 1 taskAgents entry matched no open task: demo names copilot-cli for task 9.9," + " but no open task carries that number.", ); + expect(said.code).toBe("OSW-TSK-020"); }); }); diff --git a/packages/core/src/enrolment.test.ts b/packages/core/src/enrolment.test.ts index c94ca2fc..1204d2dc 100644 --- a/packages/core/src/enrolment.test.ts +++ b/packages/core/src/enrolment.test.ts @@ -65,5 +65,9 @@ describe("readEnrolmentRequests and confirmEnrolmentFor (a-run-is-signed-by-its- const { repository, options } = await workspace(); await expect(confirmEnrolmentFor(repository, "0".repeat(32), options)).rejects.toBeInstanceOf(EnrolmentRefusedError); + await expect(confirmEnrolmentFor(repository, "0".repeat(32), options)).rejects.toMatchObject({ + code: "OSW-KEY-029", + message: `no live run signed by ${"0".repeat(32)} is waiting to be enrolled`, + }); }); }); diff --git a/packages/core/src/enrolment.ts b/packages/core/src/enrolment.ts index 0b49cc4a..4575c0b6 100644 --- a/packages/core/src/enrolment.ts +++ b/packages/core/src/enrolment.ts @@ -16,6 +16,7 @@ import { } from "./agent-roster.js"; import { readAgentStatuses, resolveAgentStatusDirectory } from "./agent-status.js"; import { createGitWrapper, type GitWrapper } from "./git.js"; +import { say } from "./message-register.js"; import type { EnrolmentRequest } from "./signature-facts.js"; import type { WorktreeRootSources } from "./worktree-root.js"; @@ -59,7 +60,8 @@ export async function confirmEnrolmentFor( const { requests, rosterDirectory } = await readEnrolmentRequests(workspaceRoot, options); const request = requests.find((candidate) => candidate.keyId === keyId); if (request === undefined) { - throw new EnrolmentRefusedError(`no live run signed by ${keyId} is waiting to be enrolled`); + const said = say("OSW-KEY-029", { keyId }); + throw new EnrolmentRefusedError(said.text, said.code); } return await confirmEnrolment({ rosterDirectory, diff --git a/packages/core/src/failure-diagnosis.test.ts b/packages/core/src/failure-diagnosis.test.ts index e4127d9d..1fbe7d1a 100644 --- a/packages/core/src/failure-diagnosis.test.ts +++ b/packages/core/src/failure-diagnosis.test.ts @@ -12,6 +12,8 @@ describe("diagnoseFailure", () => { commands: ["copilot"], }); expect(diagnosis.remedy).toContain("`copilot`"); + expect(diagnosis.code).toBe("OSW-AGT-202"); + expect(diagnosis.remedyCode).toBe("OSW-AGT-213"); }); it("finds the cause in the output where the reason is only an exit code", () => { @@ -64,7 +66,7 @@ describe("diagnoseFailure", () => { it("says nothing it has not seen", () => { expect(diagnoseFailure({ agentId: "copilot-cli-acp", reason: "ACP connection closed" })) - .toEqual({ cause: "unknown", repeatHelps: "unknown" }); + .toEqual({ cause: "unknown", repeatHelps: "unknown", code: "OSW-AGT-207" }); }); it("takes the earlier cause where two are present", () => { diff --git a/packages/core/src/failure-diagnosis.ts b/packages/core/src/failure-diagnosis.ts index 315822d3..b019d788 100644 --- a/packages/core/src/failure-diagnosis.ts +++ b/packages/core/src/failure-diagnosis.ts @@ -17,6 +17,8 @@ // - Act. It is a diagnosis; what to do with it is a person's, or, where a // change allows it, the supervisor's (`the-supervisor-changes-agents`). +import { isMessageCode, say, type MessageCode } from "./message-register.js"; + export type FailureCause = | "agent-not-installed" | "not-signed-in" @@ -33,6 +35,10 @@ export type RepeatHelps = "no" | "likely" | "unknown"; export interface FailureDiagnosis { cause: FailureCause; repeatHelps: RepeatHelps; + /** The register's identifier for the cause's words. */ + code?: MessageCode; + /** The register's identifier for the remedy's words. */ + remedyCode?: MessageCode; /** The line the cause was found in, as printed, cut to * `QUOTED_LINE_LIMIT` characters. Absent for `unknown`. A diagnosis a * reader can check against its evidence is one they can disagree with. */ @@ -167,42 +173,37 @@ function quoted(line: string): string { return trimmed.length <= QUOTED_LINE_LIMIT ? trimmed : `${trimmed.slice(0, QUOTED_LINE_LIMIT - 1)}…`; } -function remedyFor(cause: Exclude, agentId: string): Pick { +function remedyFor(cause: Exclude, agentId: string): Pick { const executable = EXECUTABLE_BY_AGENT[agentId]; const local = LOCAL_LLM_AGENTS.has(agentId); switch (cause) { case "agent-not-installed": - return { - remedy: executable !== undefined - ? `Install \`${executable}\` and make sure it is on the PATH, or choose another agent for this stage.` - : "Install the agent and make sure it is on the PATH, or choose another agent for this stage.", - commands: ["openspec-ui-cli diagnose workspace"], - }; + return executable !== undefined + ? { + remedy: say("OSW-AGT-211", { executable }).text, + remedyCode: "OSW-AGT-211", + commands: ["openspec-ui-cli diagnose workspace"], + } + : { + remedy: say("OSW-AGT-212").text, + remedyCode: "OSW-AGT-212", + commands: ["openspec-ui-cli diagnose workspace"], + }; case "not-signed-in": if (local) { - return { - remedy: "The local LLM's server refused the key. Set the key the server expects" - + " (the local LLM settings, or OPENSPEC_UI_LOCAL_LLM_API_KEY), then start the run again.", - }; + return { remedy: say("OSW-NET-204").text, remedyCode: "OSW-NET-204" }; } return executable !== undefined - ? { remedy: `Run \`${executable}\` in a terminal and sign in, then start the run again.`, commands: [executable] } - : { remedy: "Sign the agent in, then start the run again." }; + ? { remedy: say("OSW-AGT-213", { executable }).text, remedyCode: "OSW-AGT-213", commands: [executable] } + : { remedy: say("OSW-AGT-214").text, remedyCode: "OSW-AGT-214" }; case "blocked-by-the-machine": - return { - remedy: "Something on this machine refused the agent a file or a program: security software, or the" - + " file's permissions. Allow it there, then start the run again.", - }; + return { remedy: say("OSW-AGT-215").text, remedyCode: "OSW-AGT-215" }; case "network-unreachable": - return { - remedy: "The agent could not reach its service. Check the address and the connection. Where a system" - + " proxy is in the way, turn on openspec-ui.agents.ignoreSystemProxy in the editor, or set" - + " OPENSPEC_UI_IGNORE_SYSTEM_PROXY=1 for the server and the CLI.", - }; + return { remedy: say("OSW-NET-205").text, remedyCode: "OSW-NET-205" }; case "rate-limited": - return { remedy: "The service limited how often it may be asked. Wait, then start the run again." }; + return { remedy: say("OSW-AGT-216").text, remedyCode: "OSW-AGT-216" }; case "server-error": - return { remedy: "The service failed on its side. Starting the run again is likely to work." }; + return { remedy: say("OSW-AGT-217").text, remedyCode: "OSW-AGT-217" }; } } @@ -219,38 +220,44 @@ export function diagnoseFailure(facts: FailureFacts): FailureDiagnosis { return { cause: rule.cause, repeatHelps: rule.repeatHelps, + code: failureCauseCode(rule.cause), evidence: quoted(line), ...remedyFor(rule.cause, facts.agentId), }; } - return { cause: "unknown", repeatHelps: "unknown" }; + return { cause: "unknown", repeatHelps: "unknown", code: "OSW-AGT-207" }; } -const CAUSE_WORDS: Readonly> = { - "agent-not-installed": "the agent is not installed", - "not-signed-in": "the agent is not signed in", - "blocked-by-the-machine": "the machine blocked the agent", - "network-unreachable": "the network could not be reached", - "rate-limited": "the service rate-limited the agent", - "server-error": "the service failed", - unknown: "the cause is not known", -}; +const CAUSE_CODES = { + "agent-not-installed": "OSW-AGT-201", + "not-signed-in": "OSW-AGT-202", + "blocked-by-the-machine": "OSW-AGT-203", + "network-unreachable": "OSW-AGT-204", + "rate-limited": "OSW-AGT-205", + "server-error": "OSW-AGT-206", + unknown: "OSW-AGT-207", +} as const satisfies Record; -const REPEAT_WORDS: Readonly> = { - no: "repeating will not help", - likely: "repeating is likely to help", - unknown: "whether repeating helps is not known", -}; +const REPEAT_CODES = { + no: "OSW-AGT-208", + likely: "OSW-AGT-209", + unknown: "OSW-AGT-210", +} as const satisfies Record; + +/** The register's identifier for a cause's words. */ +export function failureCauseCode(cause: FailureCause): MessageCode { + return CAUSE_CODES[cause]; +} /** The cause, in a reader's words. */ export function describeFailureCause(cause: FailureCause): string { - return CAUSE_WORDS[cause]; + return say(CAUSE_CODES[cause]).text; } /** One line: the cause, and whether repeating helps. The words every * surface shows, so no two describe one failure differently. */ export function describeDiagnosis(diagnosis: FailureDiagnosis): string { - return `${CAUSE_WORDS[diagnosis.cause]}: ${REPEAT_WORDS[diagnosis.repeatHelps]}`; + return `${say(CAUSE_CODES[diagnosis.cause]).text}: ${say(REPEAT_CODES[diagnosis.repeatHelps]).text}`; } /** A value read back from a log or a payload, as a diagnosis, or @@ -259,14 +266,16 @@ export function describeDiagnosis(diagnosis: FailureDiagnosis): string { export function readFailureDiagnosis(value: unknown): FailureDiagnosis | undefined { if (typeof value !== "object" || value === null) return undefined; const record = value as Record; - if (typeof record.cause !== "string" || !(record.cause in CAUSE_WORDS)) return undefined; - if (typeof record.repeatHelps !== "string" || !(record.repeatHelps in REPEAT_WORDS)) return undefined; + if (typeof record.cause !== "string" || !(record.cause in CAUSE_CODES)) return undefined; + if (typeof record.repeatHelps !== "string" || !(record.repeatHelps in REPEAT_CODES)) return undefined; const commands = Array.isArray(record.commands) && record.commands.every((item) => typeof item === "string") ? record.commands as string[] : undefined; return { cause: record.cause as FailureCause, repeatHelps: record.repeatHelps as RepeatHelps, + ...(isMessageCode(record.code) ? { code: record.code } : {}), + ...(isMessageCode(record.remedyCode) ? { remedyCode: record.remedyCode } : {}), ...(typeof record.evidence === "string" ? { evidence: record.evidence } : {}), ...(typeof record.remedy === "string" ? { remedy: record.remedy } : {}), ...(commands !== undefined ? { commands } : {}), diff --git a/packages/core/src/forge.ts b/packages/core/src/forge.ts index 325ce96d..7f5ca738 100644 --- a/packages/core/src/forge.ts +++ b/packages/core/src/forge.ts @@ -17,6 +17,7 @@ import { createGitHubForge, createPullRequestGateway, pullRequestGatewayOver, ty import { createGitWrapper } from "./git.js"; import { parseForgeRemote } from "./forge-remote.js"; import { createGitHubApiForge, createGitLabForge, createGiteaForge, type FetchLike } from "./http-forges.js"; +import { say } from "./message-register.js"; export type ForgeKind = "github" | "gitlab" | "gitea"; @@ -75,7 +76,8 @@ function withTokenHint(forge: Forge): Forge { async pullRequestsByBranch() { const read = await forge.pullRequestsByBranch(); if (!read.available && /not installed|not signed in/iu.test(read.reason)) { - return { available: false, reason: `${read.reason}, and GITHUB_TOKEN is not set` }; + const said = say("OSW-NET-007", { reason: read.reason }); + return { available: false, reason: said.text, code: said.code }; } return read; }, diff --git a/packages/core/src/gh-pr-gateway.test.ts b/packages/core/src/gh-pr-gateway.test.ts index 87d1ac78..b1b8cf70 100644 --- a/packages/core/src/gh-pr-gateway.test.ts +++ b/packages/core/src/gh-pr-gateway.test.ts @@ -147,7 +147,7 @@ describe("createPullRequestGateway", () => { const gateway = createPullRequestGateway({ cwd: "/workspace/repo", pollIntervalMs: 0, maxWaitMs: 50 }); const status = await gateway.waitForChecks(7); - expect(status).toEqual({ state: "none", reason: "no check result was available" }); + expect(status).toEqual({ state: "none", reason: "no check result was available", code: "OSW-NET-102" }); }); it("merges a pull request through gh pr merge", async () => { @@ -192,7 +192,7 @@ describe("createGitHubForge", () => { expect(passing.calls[0]).toEqual(["pr", "checks", "7", "--json", "name,state"]); const none = forgeWith([{ code: 1, stderr: "no checks reported on the 'archive-landed-x' branch" }]); - expect(await none.forge.checksOf(7)).toEqual({ state: "none", reason: "no check result was available" }); + expect(await none.forge.checksOf(7)).toEqual({ state: "none", reason: "no check result was available", code: "OSW-NET-102" }); }); it("says a refused merge in the forge's words", async () => { diff --git a/packages/core/src/gh-pr-gateway.ts b/packages/core/src/gh-pr-gateway.ts index 6f2796bf..db18cbd5 100644 --- a/packages/core/src/gh-pr-gateway.ts +++ b/packages/core/src/gh-pr-gateway.ts @@ -1,5 +1,6 @@ import { setTimeout as delay } from "node:timers/promises"; import crossSpawn from "cross-spawn"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; /** A pull request's state as `gh` reports it. */ export type BranchPullRequestState = "OPEN" | "CLOSED" | "MERGED"; @@ -23,10 +24,19 @@ export function pullRequestTimes(createdAt: unknown, mergedAt: unknown): Pick
} - | { available: false; reason: string }; + | PullRequestsUnreadable; export interface ListPullRequestsOptions { cwd: string; @@ -46,16 +56,23 @@ export function buildGhPrListInvocation(): { executable: string; args: string[] const PR_STATES: ReadonlySet = new Set(["OPEN", "CLOSED", "MERGED"]); -function whyGhFailed(error: unknown): string { - const code = error instanceof Error && "code" in error ? (error as NodeJS.ErrnoException).code : undefined; - if (code === "ENOENT") return "gh is not installed"; +/** `gh` saying it is not there says what else would do, in the register's + * words under their identifier. */ +function whyGhFailed(error: unknown): SaidMessage { + const errno = error instanceof Error && "code" in error ? (error as NodeJS.ErrnoException).code : undefined; + if (errno === "ENOENT") return say("OSW-NET-004"); const text = error instanceof Error ? error.message : String(error); // Before the signed-out wording: gh's refusal for a remote on no host it // knows also ends by suggesting `gh auth login` (a-gh-refusal-names-its-cause). - if (/known GitHub host|none of the git remotes/iu.test(text)) return "no remote is on a GitHub host gh knows"; - if (/auth login|not logged in/iu.test(text)) return "gh is not signed in"; + if (/known GitHub host|none of the git remotes/iu.test(text)) return say("OSW-NET-005"); + if (/auth login|not logged in/iu.test(text)) return say("OSW-NET-006"); const first = text.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "no reason given"; - return `gh failed: ${first.trim()}`; + return say("OSW-NET-008", { reason: first.trim() }); +} + +/** A reading that could not be made, in the register's words. */ +function unavailable(said: SaidMessage): PullRequestsByBranch { + return { available: false, reason: said.text, code: said.code }; } /** Every pull request of the repository by its head branch. @@ -69,16 +86,16 @@ export async function listPullRequestsByBranch(options: ListPullRequestsOptions) try { ({ stdout } = await (options.exec ?? execFileAsync)(options.ghBinary ?? invocation.executable, invocation.args, { cwd: options.cwd })); } catch (error) { - return { available: false, reason: whyGhFailed(error) }; + return unavailable(whyGhFailed(error)); } let items: unknown; try { items = JSON.parse(stdout); } catch { - return { available: false, reason: "gh printed something that is not JSON" }; + return unavailable(say("OSW-NET-009")); } - if (!Array.isArray(items)) return { available: false, reason: "gh printed something that is not a list" }; + if (!Array.isArray(items)) return unavailable(say("OSW-NET-010")); const byBranch = new Map(); for (const item of items as Array>) { @@ -101,6 +118,8 @@ export interface PullRequestRef { export interface PullRequestCheckStatus { state: "pass" | "fail" | "none"; reason?: string; + /** The register's identifier for `reason`, where it has one. */ + code?: MessageCode; } export interface PullRequestGateway { @@ -148,10 +167,16 @@ function normalizeCheckState(item: CheckItem): string { /** The one non-final answer: it is what `waitForChecks` polls on, so it * is a named constant rather than a string compared in two places. */ -export const PENDING_REASON = "checks are still pending"; +const PENDING = say("OSW-NET-101"); +export const PENDING_REASON = PENDING.text; + +/** A check reading that decided nothing, in the register's words. */ +function noChecks(said: SaidMessage): PullRequestCheckStatus { + return { state: "none", reason: said.text, code: said.code }; +} export function parseCheckStatus(items: CheckItem[]): PullRequestCheckStatus { - if (items.length === 0) return { state: "none", reason: "no check result was available" }; + if (items.length === 0) return noChecks(say("OSW-NET-102")); const failed = items.find((item) => { const state = normalizeCheckState(item); @@ -162,12 +187,13 @@ export function parseCheckStatus(items: CheckItem[]): PullRequestCheckStatus { }); if (failed) { const failingName = failed.name ?? "unknown check"; - return { state: "fail", reason: `check failed: ${failingName} (${normalizeCheckState(failed)})` }; + const said = say("OSW-NET-103", { check: failingName, state: normalizeCheckState(failed) }); + return { state: "fail", reason: said.text, code: said.code }; } const hasPending = items.some((item) => PENDING_STATES.has(normalizeCheckState(item))); if (hasPending) { - return { state: "none", reason: PENDING_REASON }; + return noChecks(PENDING); } // Everything that ran was skipped or neutral, so nothing actually @@ -176,7 +202,7 @@ export function parseCheckStatus(items: CheckItem[]): PullRequestCheckStatus { // shape: checks exist, none of them decided anything. const anyPassed = items.some((item) => PASS_STATES.has(normalizeCheckState(item))); if (!anyPassed) { - return { state: "none", reason: "every check was skipped — no check actually ran" }; + return noChecks(say("OSW-NET-104")); } return { state: "pass" }; @@ -282,7 +308,7 @@ export function createPullRequestGateway(options: PullRequestGatewayOptions): Pu } catch (error) { const message = error instanceof Error ? error.message : String(error); if (extractNoChecks(message)) { - return { state: "none", reason: "no check result was available" }; + return noChecks(say("OSW-NET-102")); } throw error; } @@ -290,7 +316,7 @@ export function createPullRequestGateway(options: PullRequestGatewayOptions): Pu await delay(pollIntervalMs); } - return { state: "none", reason: "checks did not reach a terminal state before timeout" }; + return noChecks(say("OSW-NET-201")); }, async mergePullRequest(prNumber: number): Promise { @@ -349,7 +375,7 @@ export function pullRequestGatewayOver(forge: Forge, options: { pollIntervalMs?: if (status.reason !== PENDING_REASON) return status; await delay(pollIntervalMs); } - return { state: "none", reason: "checks did not reach a terminal state before timeout" }; + return noChecks(say("OSW-NET-201")); }, async mergePullRequest(prNumber) { await forge.mergeNow(prNumber, "merge"); @@ -388,7 +414,7 @@ export function createGitHubForge(options: GitHubForgeOptions): Forge { return parseCheckStatus(JSON.parse(stdout) as CheckItem[]); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (extractNoChecks(message)) return { state: "none", reason: "no check result was available" }; + if (extractNoChecks(message)) return noChecks(say("OSW-NET-102")); throw error; } }, diff --git a/packages/core/src/gh-pr-list.test.ts b/packages/core/src/gh-pr-list.test.ts index b09af87f..12f82629 100644 --- a/packages/core/src/gh-pr-list.test.ts +++ b/packages/core/src/gh-pr-list.test.ts @@ -51,7 +51,7 @@ describe("listPullRequestsByBranch (a-change-says-where-it-stands 2.2)", () => { }, }); - expect(read).toEqual({ available: false, reason: "gh is not installed" }); + expect(read).toEqual({ available: false, reason: "gh is not installed", code: "OSW-NET-004" }); }); it("says gh is not signed in", async () => { @@ -62,7 +62,7 @@ describe("listPullRequestsByBranch (a-change-says-where-it-stands 2.2)", () => { }, }); - expect(read).toEqual({ available: false, reason: "gh is not signed in" }); + expect(read).toEqual({ available: false, reason: "gh is not signed in", code: "OSW-NET-006" }); }); // a-gh-refusal-names-its-cause 1.2: gh's refusal as @@ -76,12 +76,12 @@ describe("listPullRequestsByBranch (a-change-says-where-it-stands 2.2)", () => { }, }); - expect(read).toEqual({ available: false, reason: "no remote is on a GitHub host gh knows" }); + expect(read).toEqual({ available: false, reason: "no remote is on a GitHub host gh knows", code: "OSW-NET-005" }); }); it("says so when gh prints something that is not a list of pull requests", async () => { const read = await listPullRequestsByBranch({ cwd: "/repo", exec: async () => ({ stdout: "oops", stderr: "" }) }); - expect(read).toEqual({ available: false, reason: "gh printed something that is not JSON" }); + expect(read).toEqual({ available: false, reason: "gh printed something that is not JSON", code: "OSW-NET-009" }); }); }); diff --git a/packages/core/src/github-api-forge.test.ts b/packages/core/src/github-api-forge.test.ts index a8514fd4..66db89ba 100644 --- a/packages/core/src/github-api-forge.test.ts +++ b/packages/core/src/github-api-forge.test.ts @@ -75,7 +75,7 @@ describe("GitHub through its API", () => { ["GET", `${REPO}/commits/abc/status`, 200, { statuses: [{ context: "deploy", state: "failure" }] }], ]); - expect(await forge.checksOf(5)).toEqual({ state: "fail", reason: "check failed: deploy (failure)" }); + expect(await forge.checksOf(5)).toEqual({ state: "fail", reason: "check failed: deploy (failure)", code: "OSW-NET-103" }); }); it("merges now and deletes the branch, as gh pr merge --delete-branch does", async () => { @@ -145,7 +145,7 @@ describe("checks on GitLab and Gitea", () => { const made = fetchFrom([["GET", "https://gitlab.com/api/v4/projects/group%2Fapp/merge_requests/4", 200, { head_pipeline: { id: 77, status: "running" } }]]); const forge = createGitLabForge({ base: "https://gitlab.com", path: "group/app", token: "t", tokenName: "GITLAB_TOKEN", fetch: made.fetch }); - expect(await forge.checksOf(4)).toEqual({ state: "none", reason: PENDING_REASON }); + expect(await forge.checksOf(4)).toEqual({ state: "none", reason: PENDING_REASON, code: "OSW-NET-101" }); }); it("reads a Gitea commit's statuses, and none as none", async () => { diff --git a/packages/core/src/harness-chain-runner.ts b/packages/core/src/harness-chain-runner.ts index d99899c5..a774da19 100644 --- a/packages/core/src/harness-chain-runner.ts +++ b/packages/core/src/harness-chain-runner.ts @@ -1,2341 +1,2341 @@ -// Chain execution for the Agentic Harness — see -// docs/adr/0012-agentic-harness-chain-execution-protocol.md and -// openspec/changes/agentic-harness-autonomy/design.md. Sequences -// `propose -> review -> apply -> verify -> archive -> git` for a change -// under one `runId`, -// pausing at a `checkpoint` (semi-autonomous, the default) or continuing -// immediately via `stageCompleted` (autonomous, or a per-change -// `checkpoints.requireConfirmationBetweenSteps: false`). The final `git` -// stage is gated by per-change `reviewGate.mode: "agent-sufficient"`. -// -// Lives in `packages/core`, not `webui`/`extension`: which stage is next, -// whether a transition pauses, and whether `autonomous` is actually -// permitted for this change are harness domain decisions, not view logic -// (see design.md, "Chain runner lives in packages/core, not webui" — a -// client-side orchestrator would duplicate this logic across both delivery -// targets and could not outlive a closed webview). - -import { readFile } from "node:fs/promises"; -import { runStartStage } from "./run-plan.js"; -import path from "node:path"; -import { describeContextShare, formatTokenCount, readAcpContextGauge } from "./acp-context-gauge.js"; -import { readAcpStreamedText } from "./acp-streamed-text.js"; -import type { AdapterInvocation, AgentRunner } from "./agent-runner.js"; -import { captureCheckpoint, finalizeCheckpoint, type WorkbenchCheckpoint } from "./checkpoint.js"; -import { - buildGhPrCreateInvocation, - buildGhPrMergeInvocation, - buildGitPushInvocation, - createPullRequestGateway, - type PullRequestGateway, -} from "./gh-pr-gateway.js"; -import { pullRequestGatewayFor } from "./forge.js"; -import { commitWhatIsLeft } from "./change-commit.js"; -import { createGitWrapper, type GitWrapper } from "./git.js"; -import { - runDeclaredChecks, - type DeclaredCheckOutcomeEntry, - type DeclaredCheckRunOutcome, -} from "./declared-checks.js"; -import type { Command, CommandContext, CommandKind, Event, VerifiedDeltaEntry } from "./protocol.js"; -import { - type HarnessChainStep, - type HarnessConfig, - isHarnessStepAgentStage, - normalizeStepAgent, - readChangeHarnessConfig, - resolveHarnessConfig, - resolveSupervisor, -} from "./harness-config.js"; -import { archiveChange, statusChange } from "./openspec.js"; -import { - DEFAULT_CHAIN_STEP_MAX_WAIT_MS, - isWaitingStep, - runChainStep, -} from "./chain-steps.js"; -import { CHAIN_ENDING_AGENT_NAME, SUPERVISOR_AGENT_NAME, VERIFY_CHECKS_AGENT_NAME } from "./audit-runs.js"; -import { superviseFailure } from "./supervisor.js"; -import { isChainStepName, skipsStage, type ChainPart } from "./harness-stage.js"; -import { untilStopBoundary } from "./stop-boundary.js"; -import { DEFAULT_AGENT_ID } from "./agents/registry.js"; -import { checkAllowlist, type AllowlistConfig, type AuditEntry, type AuditLog } from "./security.js"; -import type { AgentUsage } from "./agent-usage.js"; -import { readTaskChecklist, TASK_CHECKBOX_LINE_RE, writeTaskCheckStates } from "./task-checklist.js"; -import { buildUsageReport, unitKey, type UsageTotal } from "./usage-report.js"; -import { say, type SaidMessage } from "./message-register.js"; - -/** The subsequence of `HarnessStage` a chain drives. Each entry's - * `AgentRunner` `CommandKind`, where one exists — `"archive"` and `"git"` - * have no direct `AgentRunner` command kind: archive is a mechanical - * `openspec archive` operation, and git is a dedicated push/PR/merge - * sequence run directly by this runner. Exported (along with - * `CHAIN_STAGES`) only so a test can assert that every stage missing an - * entry here stays excluded from `HarnessStepAgentStage` — see - * harness-git-stage-no-agent tasks.md 5.4: `git` was added here without - * a `CHAIN_STAGE_COMMAND` entry, and to `HarnessStage`, in the same pull - * request that forgot to also exclude it from `HarnessStepAgentStage`. */ -export const CHAIN_STAGE_COMMAND: Readonly> = { - propose: "plan", - review: "review", - apply: "implement", - verify: "verify", -}; -export const CHAIN_STAGES = ["propose", "review", "apply", "verify", "archive", "git"] as const; -type ChainStage = (typeof CHAIN_STAGES)[number]; -const GIT_STAGE_AGENT_NAME = "git-stage"; -const DEFAULT_GIT_REMOTE = "origin"; -const DEFAULT_PR_BASE_BRANCH = "main"; - -export interface HarnessChainDeps { - /** Resolves the `AgentRunner` for an agent id — same shape each host - * already injects into its AI panel (e.g. - * `packages/extension/src/webview/ai-panel.ts`'s `AiPanelDeps. - * resolveRunner`), typically `default-runners.ts`'s `resolveRunner` - * curried over that host's `Map`. Falling back to - * the default agent for an unset `stepAgents` entry is this function's - * responsibility, not the chain runner's. `cwd` is where the stage runs: - * a host gives a change's own worktree that worktree's agents, whose - * sandbox is that worktree (a-change-runs-in-its-own-worktree). */ - resolveRunner: (agentId: string | undefined, cwd?: string) => AgentRunner | undefined; - /** Best-effort accessor for this workspace's recorded audit entries — - * used only to sum recorded usage against `harnessConfig.budget` before - * starting each stage (see openspec/changes/agent-usage-accounting/ - * design.md). Absent, or a `harnessConfig` with no `budget` configured, - * means no budget enforcement — every stage starts exactly as it did - * before this dependency existed. Kept as its own dependency here - * rather than as a method on `AuditLog` (security.ts) — that interface - * stays a pure write sink; this is the one place in this project that - * needs to read audit history back, so the read-back capability lives - * with its one caller instead of widening a security-critical - * interface for it. */ - listAuditEntries?: () => AuditEntry[] | Promise; - /** Where git-stage actions are recorded. Optional for compatibility - * with tests that do not assert audit output. */ - auditLog?: AuditLog; - /** Writes the answer to a question the operator asked, once the stage - * that carried it has ended. The chain holds no key and signs nothing: - * the host that reads messages is the one that has the key, and it - * passes this in (the-operator-can-say-something-to-a-run). Absent, a - * question is delivered and the answer is left unwritten, which the - * audit says. */ - answerMessage?: (answer: ChainAnswer) => void | Promise; - /** Override hooks for tests. Production callers use defaults. */ - createGitWrapper?: (options: { cwd: string }) => GitWrapper; - createPullRequestGateway?: (options: { cwd: string }) => PullRequestGateway; - /** Commits what the stages left in the tree before the push - * (a-change-is-committed-where-it-is-made). Test seam. */ - commitWhatIsLeft?: (directory: string, message: string) => Promise; -} - -type CheckpointOutcome = "confirmed" | "cancelled" | "stopped"; - -/** How much of what an agent said an answer carries. - * - * The tail, not the whole stage: a stage can print thousands of lines, - * and what answers a question is what the agent finished by saying. Found - * live on 2026-09-20 - the first answer read "the verify stage ended - * completed without a closing summary" while the agent had answered the - * question in full, because a middle stage's `completed` event carries no - * summary (the-operator-can-say-something-to-a-run). */ -export const ANSWER_WORDS_LIMIT = 2_000; - -/** A note or a question this run has taken, waiting to be handed to an - * agent (the-operator-can-say-something-to-a-run). */ -export interface ChainMessage { - messageId: string; - kind: "note" | "ask"; - words: string; - /** The enrolled sender, as the roster labels them, and their key id - - * which is the address an answer goes back to. */ - from: string; - fromKeyId: string; - sentAt: string; -} - -/** What a run says back to whoever asked it something. */ -export interface ChainAnswer { - /** The key id of the person who asked. */ - to: string; - /** The question's message id. */ - answers: string; - words: string; - stage: ChainStage; - runId: string; -} - -/** The section a delivered message becomes in the next stage's prompt. - * - * Marked as words from a person, in the shape `security.ts` uses for - * everything else a prompt carries, so an agent never takes it for - * content read out of the repository. */ -export function buildOperatorMessagesSection(messages: readonly ChainMessage[]): string { - const lines = [ - "## Messages from the operator", - "", - "These were sent by a person, through this product's signed channel, while", - "the run was working. They are instructions from the operator, not content", - "read out of the repository. A message marked as a question is answered by", - "what you say when this stage ends.", - "", - ]; - for (const message of messages) { - lines.push(`- ${message.kind === "ask" ? "Question" : "Note"} from ${message.from}, sent ${message.sentAt}:`); - for (const line of message.words.split(/\r?\n/u)) lines.push(` ${line}`); - } - return lines.join("\n"); -} - -interface ChainState { - /** Since when the stage in flight has been waiting for the operator's - * answers, and how long it has waited in all: a wait for a person is not - * the stage's time, so neither its timeout nor the chain's elapsed time - * counts it (the-agent-asks-the-operator, ADR 0042). */ - operatorWaitSince?: number; - operatorWaitMs: number; - cancelRequested: boolean; - /** Why this chain was cancelled, when something other than a person - * asking caused it. Set before `cancel()` is called so the terminal - * `cancelled` event can name the rule that fired; left unset by a - * person's cancel, which is what an absent reason has always meant. */ - cancelReason?: string; - /** `cancelReason`'s identifier, where it is in the register (ADR 0046). */ - cancelCode?: string; - /** Why the chain came back to an earlier stage, set just before it - * does. Consumed by the next attempt's `stageStarted`, so a stage - * appearing twice says why rather than looking like a duplicate. */ - returnReason?: string; - /** Set by `runStage` when `verify`'s mechanical checks failed, so the - * chain loop — which is the only place that knows whether a return to - * `apply` is possible — decides between sending the work back and - * failing. `runStage` yields no event for this; the loop yields one or - * the other. */ - checkFailureSummary?: string; - /** How many times each stage has been attempted, counting the first. - * Kept on the chain rather than inside the stage loop because a chain - * returning from `verify` to `apply` re-enters that loop, and a count - * local to it would reset — letting a chain retry forever against a - * ceiling it appeared to respect. Per stage, so a slow `verify` never - * consumes the allowance meant for `apply`. */ - attemptsByStage: Map; - /** What the stage currently running reported spending, if it reported - * anything. Cleared when a stage starts, so it never carries a previous - * stage's figure into this one's check. Absent means the agent reported - * nothing, which is not zero — the case a spending ceiling cannot act - * on at all. */ - lastStageUsage?: AgentUsage; - /** Time the chain's stages have spent, summed, in milliseconds. Time - * waiting at a checkpoint is deliberately not added: a person - * deliberating is not a run consuming anything, and counting it would - * fire the ceiling on chains behaving exactly as configured. */ - elapsedMs: number; - pendingCheckpoint?: { resolve: (outcome: CheckpointOutcome) => void }; - currentRunner?: AgentRunner; - currentCommand?: Command; - /** A stop a person asked for (a-change-is-run-from-its-card): the reason, - * who asked where known, and whether `stopRequested` has been yielded. */ - stopRequest?: { reason: string; by?: string; messageId?: string; afterTask?: string; announced: boolean }; - /** A request that named a task to stop after, held until that task is - * done. The run goes on working while it is held; when the task is - * ticked, or its agent names a task after it, this becomes - * `stopRequest` and the stage ends at the next sound point - * (a-run-is-told-where-to-stop). */ - heldStop?: { reason: string; by?: string; messageId?: string; afterTask: string }; - /** Wakes the running stage's stop boundary, so a stop is acted on while - * the stage says nothing. */ - stopWake?: () => void; - /** Notes and questions taken from the signed channel and not yet handed - * to an agent. They are handed over when the next stage starts: an agent - * reads its prompt when its stage begins, and there is no second way in - * (the-operator-can-say-something-to-a-run). */ - pendingMessages: ChainMessage[]; - /** The questions handed to the stage now running, waiting for what it - * says at its end. */ - awaitingAnswer: ChainMessage[]; - /** The `failed` event a stage's agent ended with, held by `runStage` until - * the attempt loop decides whether the supervisor repeats or moves the - * stage. Yielded unchanged where it does not, so a `failed` event is - * still the last of the chain it ends (ADR 0012), and never yielded for a - * stage that goes on (the-supervisor-changes-agents). */ - heldFailure?: Extract; - /** The agent the supervisor moved a stage to, which later attempts of it - * run on. */ - agentOverride: Map; - /** Every agent each stage has run on in this chain: a fallback is never - * tried twice. */ - triedAgents: Map; - /** What the chain's review said of the plan, until the update it asks - * for has run (ADR 0041). */ - reviewVerdict?: "ready" | "changes-needed"; -} - -function nowIso(): string { - return new Date().toISOString(); -} - -/** Runs a request about a run — a cancel, a permission's answer — to its - * end without waiting on it. A runner that throws on one must not become an - * unhandled rejection. */ -function drainInBackground(events: AsyncIterable): void { - void (async () => { - try { - for await (const _event of events) { - // Draining only: the stage's own open stream is what reports. - } - } catch { - // See above. - } - })(); -} - -function formatSeconds(totalMs: number): string { - const totalSeconds = Math.max(0, Math.round(totalMs / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}m${String(seconds).padStart(2, "0")}s`; -} - -/** The tasks still unchecked, as a readable list for a terminal message. - * Named rather than counted because a reader seeing this is about to take - * the work over, and `archive`'s own refusal already gives them a count — - * which of them is the part that otherwise costs opening the file. - * Truncated, because a change with forty open tasks would otherwise - * produce a message nobody reads. */ -async function unfinishedTaskTexts(workspaceRoot: string, changeName: string): Promise { - let items; - try { - items = await readTaskChecklist(workspaceRoot, changeName, false); - } catch { - // The count that got us here was read successfully; if the list - // cannot be, the message still has to be useful. - return "the task list could not be read to name them"; - } - const open = items.filter((item) => !item.done).map((item) => item.text); - if (open.length === 0) return "none, which contradicts the count just read"; - const shown = open.slice(0, 5); - const suffix = open.length > shown.length ? `, and ${open.length - shown.length} more` : ""; - return shown.map((text) => `"${text}"`).join(", ") + suffix; -} - -/** Names the per-stage ceiling a finished stage exceeded, or `undefined` - * when none was configured, none was exceeded, or the agent reported - * nothing to compare. That last case is not a pass — it is the ceiling - * having nothing to act on, which is exactly what `timeout` exists to - * cover, and it is why this returns `undefined` rather than treating an - * absent figure as zero. */ -function describeStageOverspend( - config: HarnessConfig, - usage: AgentUsage | undefined, - stage: ChainStage, -): SaidMessage | undefined { - if (!usage) return undefined; - const maxCostUsd = config.budget?.maxStageCostUsd; - if (maxCostUsd !== undefined && usage.costUsd !== undefined && usage.costUsd > maxCostUsd) { - return say("OSW-RUN-204", { stage, spent: usage.costUsd.toFixed(2), ceiling: maxCostUsd.toFixed(2) }); - } - const maxTokens = config.budget?.maxStageTokens; - if (maxTokens !== undefined) { - const used = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); - if ((usage.inputTokens !== undefined || usage.outputTokens !== undefined) && used > maxTokens) { - // Explicitly grouped: a bare `toLocaleString` made this one line of - // text read two ways, by whose machine wrote it. - return say("OSW-RUN-205", { stage, spent: formatTokenCount(used), ceiling: formatTokenCount(maxTokens) }); - } - } - return undefined; -} - -/** Names the ceiling that fired and the value it was set to, rather than - * blaming the stage that happened to be running — the posture - * `checkBudget` already takes when it stops a chain. */ -function describeTimeout(config: HarnessConfig, state: ChainState, stage: ChainStage): SaidMessage { - const runSeconds = config.timeout?.maxRunSeconds; - const stageSeconds = config.timeout?.maxStageSeconds; - const remainingRunMs = runSeconds === undefined ? undefined : Math.max(0, runSeconds * 1000 - state.elapsedMs); - const stageMs = stageSeconds === undefined ? undefined : stageSeconds * 1000; - const runFiredFirst = remainingRunMs !== undefined && (stageMs === undefined || remainingRunMs <= stageMs); - if (runFiredFirst) { - return say("OSW-RUN-207", { seconds: String(runSeconds), spent: formatSeconds(state.elapsedMs), stage }); - } - return say("OSW-RUN-208", { stage, seconds: String(stageSeconds) }); -} - -function changeNameFromDir(changeDir: string): string { - const segments = changeDir.split(/[\\/]+/).filter((segment) => segment.length > 0); - return segments[segments.length - 1] ?? ""; -} - -/** A failure, with its identifier where the reason is in the register - * (ADR 0046). */ -function failedEvent(runId: string, reason: string | SaidMessage): Event { - return typeof reason === "string" - ? { kind: "failed", runId, timestamp: nowIso(), reason } - : { kind: "failed", runId, timestamp: nowIso(), reason: reason.text, code: reason.code }; -} - -/** A cancellation's reason and identifier, where a rule caused it rather - * than a person. */ -function cancelledBy(state: ChainState): { reason?: string; code?: string } { - if (!state.cancelReason) return {}; - return { reason: state.cancelReason, ...(state.cancelCode !== undefined ? { code: state.cancelCode } : {}) }; -} - -/** Which agent this chain's `apply` stage runs as — the agent whose work - * a later `verify` stage's checks examine. - * - * An unset `apply` entry falls back to `DEFAULT_AGENT_ID`, which is the - * same fallback `resolveRunner` applies (`default-runners.ts`), so the - * name recorded here matches the `agent` the apply stage's own audit - * entries carry and the two group together. */ -function resolvedApplyAgent(harnessConfig: HarnessConfig, state?: Pick): string { - const moved = state?.agentOverride.get("apply"); - if (moved !== undefined) return moved; - const entry = harnessConfig.stepAgents.apply; - return entry === undefined ? DEFAULT_AGENT_ID : normalizeStepAgent(entry).agent; -} - -/** The agent a stage runs on in this chain, as `stageStarted` and - * `checkpoint` name it: the one the supervisor moved it to, else its - * configured entry, else "" — for a stage with no agent, or none - * configured (the-supervisor-changes-agents). */ -function stageAgentOf(stage: ChainPart, harnessConfig: HarnessConfig, state: Pick): string { - if (isChainStepName(stage) || !isHarnessStepAgentStage(stage)) return ""; - const moved = state.agentOverride.get(stage); - if (moved !== undefined) return moved; - const entry = harnessConfig.stepAgents[stage]; - return entry === undefined ? "" : normalizeStepAgent(entry).agent; -} - -function wildcardPatternToRegExp(pattern: string): RegExp { - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); - return new RegExp(`^${escaped}$`); -} - -function matchesPattern(value: string, patterns: readonly string[]): boolean { - return patterns.some((pattern) => wildcardPatternToRegExp(pattern).test(value)); -} - -interface TaskCounts { - unchecked: number; - total: number; -} - -/** Counts the change's own `tasks.md` checkboxes, using the same line - * convention as `task-checklist.ts` rather than a second, drifting copy of - * it. Returns `undefined` when the file cannot be read at all — a signal - * the callers deliberately treat as "unknown", never as "nothing remains" - * (see design.md, "The chain counts tasks itself, and fails safe"). - * - * This exists because `openspec status`'s artifact completeness answers a - * different question: whether `tasks.md` EXISTS, not whether its tasks are - * done. Reading the former as the latter is what archived two - * unimplemented changes. */ -async function countTasks(changeDir: string): Promise { - let content: string; - try { - content = await readFile(path.join(changeDir, "tasks.md"), "utf8"); - } catch { - return undefined; - } - let unchecked = 0; - let total = 0; - for (const line of content.split(/\r?\n/)) { - const match = line.match(TASK_CHECKBOX_LINE_RE); - if (!match) continue; - total += 1; - if ((match[1] ?? "").toLowerCase() !== "x") unchecked += 1; - } - return { unchecked, total }; -} - -/** A line for an implementing run that changed files and ticked no task, - * or `undefined` when it ticked one, changed nothing, or either count - * could not be read. - * - * Reported, never refused. The work may be fine and the ticks merely - * missing — which verification may now repair by ticking what it confirms - * — and refusing would stop exactly the chain that could finish. What - * this prevents is the silence: on 2026-09-12 an implementing run did its - * work, ended with nothing ticked, and nothing on the chain's timeline - * said so. See openspec/changes/a-done-task-is-ticked/design.md. */ -async function describeApplyThatTickedNothing( - before: TaskCounts | undefined, - changeDir: string, - delta: VerifiedDeltaEntry[] | undefined, -): Promise { - if (!before || !delta || delta.length === 0) return undefined; - const after = await countTasks(changeDir); - if (!after) return undefined; - const tickedBefore = before.total - before.unchecked; - const tickedAfter = after.total - after.unchecked; - if (tickedAfter > tickedBefore) return undefined; - return say("OSW-RUN-103", { count: delta.length }); -} - -/** The reason a chain ends after an implementing run that did nothing: it - * changed no file and ticked no task, while a task it could do - not - * Human-only, not delegated - is still open. `undefined` where it ticked - * one, where nothing is left for it to do, or where the task list could not - * be read. - * - * Only ever asked of a checkpoint that was read and held no change: an - * unread one decides nothing. On 2026-10-08 two chains went on from such a - * run to `verify`, which had nothing to confirm, and to `archive`, which - * refused, and the reason they stopped was said nowhere. See - * openspec/changes/an-apply-that-ticks-nothing-ends-the-chain/design.md. */ -async function describeApplyThatDidNothing( - before: TaskCounts | undefined, - changeDir: string, - workspaceRoot: string, - changeName: string, -): Promise { - if (!before) return undefined; - const after = await countTasks(changeDir); - if (!after) return undefined; - if (after.total - after.unchecked > before.total - before.unchecked) return undefined; - let items; - try { - items = await readTaskChecklist(workspaceRoot, changeName, false); - } catch { - return undefined; - } - // A person's task, or another agent's, is not this stage's to do. - const its = items.filter((item) => !item.done && item.humanOnly !== true && item.delegatedTo === undefined); - if (its.length === 0) return undefined; - const shown = its.slice(0, 5).map((item) => `"${item.text}"`).join(", "); - const more = its.length > 5 ? `, and ${its.length - 5} more` : ""; - return say("OSW-RUN-104", { count: its.length, tasks: `${shown}${more}` }); -} - -type MechanicalCheckOutcomeEntry = DeclaredCheckOutcomeEntry; -type MechanicalCheckRunOutcome = DeclaredCheckRunOutcome; - -/** Runs every mechanical check the change's `tasks.md` declares (task - * 3.1), before the `verify` stage's agent is ever invoked, and writes - * each one's pass/fail result onto its own checkbox (task 3.2) — the - * ONLY writer of those checkboxes; an agent's own report never reaches - * them through this path. Reading and parsing `tasks.md` itself is what - * throws `UnknownMechanicalCheckError`/`InvalidMechanicalCheckParameterError` - * for a malformed declaration (task-checklist.ts) — this function does - * not catch those, so a malformed `tasks.md` fails the stage exactly like - * a failing check would (via the caller's own try/catch). */ -async function runMechanicalChecksForVerify(workspaceRoot: string, changeDir: string): Promise { - const changeName = changeNameFromDir(changeDir); - const outcome = await runDeclaredChecks(workspaceRoot, changeName, false); - if (!outcome.ranAny) return outcome; - - // The writing is what makes this the `verify` stage's version rather - // than `runDeclaredChecks` itself: a checkbox records what a check - // found during a run, and `openspec-ui-cli run checks` — which asks the same - // question outside a run — deliberately writes nothing. - await writeTaskCheckStates( - workspaceRoot, - changeName, - false, - [...outcome.passed, ...outcome.failed].map((entry) => ({ - lineNumber: entry.lineNumber, - expectedText: entry.text, - done: entry.result.pass, - })), - ); - return outcome; -} - -/** Renders passing checks' results for the verifying agent's own prompt - * (task 3.4) — so it is told what is already established and does not - * re-run `npm run typecheck`/`lint`/`test`/etc itself. Plain text, not - * Markdown the agent could mistake for an instruction — same posture - * `security.ts`'s other prompt sections take toward change-file content. */ -function buildEstablishedChecksSection(passed: readonly MechanicalCheckOutcomeEntry[]): string { - const lines = passed.map((entry) => { - const paramSuffix = entry.check.param ? ` (${entry.check.param})` : ""; - return `- ${entry.check.name}${paramSuffix}: ${entry.result.reason}`; - }); - return `The following mechanical checks already ran and passed — do not repeat them:\n${lines.join("\n")}`; -} - -/** Determines the first not-yet-complete stage for a change. Whether the - * `propose` artifacts exist still comes from the `status` command (that is - * exactly the question artifact presence answers), but the `apply` vs - * `verify` decision comes from the change's real task checkboxes — - * `status.progress` is deliberately not consulted here, because it is - * absent for the CLI shape this repository actually runs, and the value - * that used to be synthesized in its place counted artifacts, not tasks. - * `review` has no durable artifact of its own in the upstream - * `openspec status` schema, so it is only ever the start stage as a side - * effect of `propose` being incomplete — a chain resuming after `propose` - * is already done starts at `apply` directly (this session's own `review`, - * if any, already happened; a chain cannot tell whether an earlier one - * did, and re-running it unconditionally on every resume would be - * surprising and wasteful). A change whose tasks are all checked but is - * not yet archived resumes at `verify`, not `archive` directly — the same - * "an agent process exiting 0 is not evidence the work was done" reasoning - * `runStage`'s archive gate already applies, applied one stage earlier. */ -async function determineStartStage(cwd: string, changeName: string, changeDir: string): Promise { - const status = await statusChange(changeName, { cwd }); - const isDone = (artifactId: string): boolean => - status.artifacts.some((artifact) => { - if (artifact.id !== artifactId) return false; - const normalized = artifact.status.toLowerCase(); - return normalized === "done" || normalized === "complete"; - }); - // `design` is deliberately not required. A change may carry none — the - // validator accepts that, and three of this repository's own active - // changes and many of its archived ones have no `design.md`. The status - // command reports a missing artifact as `ready`, meaning "could be - // produced"; reading that as an unfinished proposal sent a finished - // change back to `propose` and re-proposed work that was already - // written. A change that is genuinely mid-proposal has no task list - // yet, which `tasks` already answers. - // See design-is-optional-for-resume. - const proposeDone = isDone("proposal") && isDone("tasks"); - if (!proposeDone) return runStartStage({ proposeDone }); - const tasks = await countTasks(changeDir); - // The same decision the run dialog states (the-run-dialog-says-where-it- - // starts): one function, so the dialog cannot name one stage and the run - // begin at another. - return runStartStage({ proposeDone, ...(tasks ? { openTasks: tasks.unchecked } : {}) }); -} - -/** One entry in the sequence a chain actually runs: a fixed stage, or a - * step this change declared. */ -type ChainEntry = - | { kind: "stage"; stage: ChainStage } - | { kind: "step"; declaration: HarnessChainStep }; - -/** Places each declared step against the fixed sequence. - * - * A step is anchored to a stage, never to another step, so two - * declarations can never depend on each other's order — and a step whose - * anchor is not in this (already resumed-sliced) sequence is anchored to - * work that already happened, so it does not run. - * - * Declaration order decides the order of two steps anchored to the same - * side of the same stage, which is the only ordering question this can - * be asked. */ -function insertDeclaredSteps( - stages: readonly ChainStage[], - declared: readonly HarnessChainStep[] | undefined, -): ChainEntry[] { - const entries: ChainEntry[] = []; - for (const stage of stages) { - for (const declaration of declared ?? []) { - if (declaration.before === stage) entries.push({ kind: "step", declaration }); - } - entries.push({ kind: "stage", stage }); - for (const declaration of declared ?? []) { - if (declaration.after === stage) entries.push({ kind: "step", declaration }); - } - } - return entries; -} - -/** The next fixed stage at or after `index`, skipping declared steps. - * - * The chain asks this only about `git`, and skipping steps is the whole - * point: `git` is gated on `reviewGate.mode`, re-derived immediately - * before `archive` moves the change's own file out of the active - * directory. A version that looked only at the very next entry would see - * a declared step there and conclude that `git` was not next — and the - * chain would then walk into the git stage with its gate never - * evaluated. Found by the test for a step declared after `archive`. */ -function nextStageAfter(sequence: readonly ChainEntry[], index: number): ChainStage | undefined { - for (let i = index; i < sequence.length; i += 1) { - const entry = sequence[i]; - if (entry !== undefined && entry.kind === "stage") return entry.stage; - } - return undefined; -} - -/** What one change has spent, gathered from every working directory that - * recorded it — ADR 0022 decision 5. - * - * `totalsByChange` is keyed by the change directory's absolute path, and - * that path is different in every git worktree of the same repository. - * Looking the change up by its own path therefore finds only what this - * directory recorded, so a ceiling would be permitted once per worktree - * — which is exactly what summing the audit logs across worktrees was - * meant to prevent, and did not, until this. - * - * Matched on the directory's last segment, which is the change's name: - * `openspec/changes/` is the only shape a change directory takes, - * and every entry being summed came from one repository's own worktrees, - * so two different changes cannot collide here. - * - * Found by running two chains in two worktrees for real. */ -/** Two per-unit totals, added unit by unit. */ -function mergeCostByUnit( - left: Record, - right: Record, -): Record { - const merged: Record = { ...left }; - for (const [unit, amount] of Object.entries(right)) { - merged[unit] = (merged[unit] ?? 0) + amount; - } - return merged; -} - -function totalForChange( - totalsByChange: Record, - changeDir: string, -): UsageTotal | undefined { - const changeName = path.basename(changeDir); - let found: UsageTotal | undefined; - - for (const [key, total] of Object.entries(totalsByChange)) { - if (path.basename(key) !== changeName) continue; - found = found - ? { - runCount: found.runCount + total.runCount, - inputTokens: found.inputTokens + total.inputTokens, - outputTokens: found.outputTokens + total.outputTokens, - costUsd: found.costUsd + total.costUsd, - // Per unit, never across: a credit and a euro are not addends - // (a-run-budget-has-a-unit). - costByUnit: mergeCostByUnit(found.costByUnit, total.costByUnit), - } - : total; - } - return found; -} - -export class HarnessChainRunner { - private readonly active = new Map(); - - constructor(private readonly deps: HarnessChainDeps) { } - - /** Starts a chain for a `"chain"` command. Returns an async generator — - * every event (including every constituent stage's own `stdout`/ - * `stderr`/`progress`/`started`) is published under `command.runId`, per - * ADR 0012. Only `"chain"` commands are accepted; anything else fails - * immediately without side effects. */ - async *run(command: Command): AsyncGenerator { - if (command.kind !== "chain") { - yield failedEvent(command.runId, say("OSW-RUN-004", { kind: command.kind })); - return; - } - - const state: ChainState = { - cancelRequested: false, - elapsedMs: 0, - operatorWaitMs: 0, - attemptsByStage: new Map(), - pendingMessages: [], - awaitingAnswer: [], - agentOverride: new Map(), - triedAgents: new Map(), - }; - this.active.set(command.runId, state); - // The stage the chain is in, and how it ended, as its own events say. - // Read here rather than at each place an ending is yielded, so no - // ending — a cancel at a checkpoint, a limit, a failure — goes - // unrecorded (a-card-says-what-its-change-is-doing). - let stage: ChainPart | undefined; - let ending: Extract | undefined; - try { - for await (const event of this.runChain(command, state)) { - if (event.kind === "stageStarted") stage = event.stage; - if (event.kind === "completed" || event.kind === "failed" || event.kind === "cancelled") ending = event; - yield event; - } - } finally { - this.active.delete(command.runId); - if (ending !== undefined) this.recordEnding(command, ending, stage, state.stopRequest); - } - } - - /** The one entry a chain writes as it ends: how, at which stage, and why. - * It carries no usage — the stages' own entries do — and `isRunEntry` - * skips it, so no counter reads it as a run. - * - * A chain a person asked to stop carries that request as `stopRequest`, - * and no `reason`: a reason on a cancellation means a rule fired - * (a-change-is-run-from-its-card). */ - private recordEnding( - command: Command, - ending: Extract, - stage: ChainPart | undefined, - stopRequest: ChainState["stopRequest"], - ): void { - const stopped = ending.kind === "cancelled" && stopRequest !== undefined - ? { - reason: stopRequest.reason, - ...(stopRequest.by !== undefined ? { by: stopRequest.by } : {}), - // The request a run elsewhere acted on (a-run-elsewhere-can-be-asked-to-stop). - ...(stopRequest.messageId !== undefined ? { messageId: stopRequest.messageId } : {}), - // The task the request named, so a line read a week later says - // where the run was told to stop rather than where it happened to - // (a-run-is-told-where-to-stop). - ...(stopRequest.afterTask !== undefined ? { afterTask: stopRequest.afterTask } : {}), - } - : undefined; - const reason = ending.kind === "completed" || stopped !== undefined ? undefined : ending.reason; - this.deps.auditLog?.record({ - runId: command.runId, - agent: CHAIN_ENDING_AGENT_NAME, - outcome: ending.kind, - cwd: command.cwd, - timestamp: Number.isFinite(Date.parse(ending.timestamp)) ? ending.timestamp : nowIso(), - changeDir: command.context.changeDir, - // A declared step is not a stage the audit log names. - ...(stage !== undefined && !isChainStepName(stage) ? { stage } : {}), - ...(reason !== undefined ? { reason } : {}), - // The failed stage's own diagnosis, which the stage's failure carried - // through to here: the last-run reading prefers this entry, so a - // diagnosis left on the stage's entry alone would not reach a card - // (the-supervisor-advises). - ...(ending.kind === "failed" && ending.diagnosis !== undefined ? { diagnosis: ending.diagnosis } : {}), - ...(stopped !== undefined ? { stopRequest: stopped } : {}), - }); - } - - /** Resumes a chain paused at a checkpoint. Returns `false` if no chain - * is currently paused for `runId` (already resumed, cancelled, or never - * existed) — the caller should treat that as a no-op, not an error. */ - confirmCheckpoint(runId: string): boolean { - const state = this.active.get(runId); - if (!state?.pendingCheckpoint) return false; - const { resolve } = state.pendingCheckpoint; - state.pendingCheckpoint = undefined; - resolve("confirmed"); - return true; - } - - /** Forwards a `"resolvePermission"` command to the runner executing the - * stage currently in flight for `runId`, unchanged — the driver's - * `runId:requestId` key (`acp-session-driver.ts:231`) must match what it - * stored, so the command is never rewritten on the way through. Returns - * `false` when `runId` names no active chain at all, so a host falls - * through to its own single-stage path. Returns `true` (a no-op) when - * the chain is active but no stage is currently running — between - * stages, or paused at a checkpoint — since an answer naming no pending - * request is not an error (task 1.5). */ - resolvePermission(command: Command): boolean { - const state = this.active.get(command.runId); - if (!state) return false; - const runner = state.currentRunner; - if (!runner) return true; - void (async () => { - try { - for await (const _event of runner.run(command)) { - // Draining only — see `cancel()`'s identical shape above. A - // `"resolvePermission"` command yields no events of its own; the - // stage's own already-open stream is what continues. - } - } catch { - // Same posture as `cancel()`: a runner throwing on an answer must - // not become an unhandled rejection here. - } - })(); - return true; - } - - /** Ends a chain — immediately if it is paused at a checkpoint (fully - * within this runner's control), or by cancelling the currently running - * stage's own run otherwise. Mid-stage cancellation reuses the existing - * single-stage convention (`RunController.cancel()`: re-send a - * `"cancel"`-kind `Command` to the same runner) — that runner (see - * `agent-runner.ts`'s `createAgentRunner`) now aborts the `AbortSignal` - * it gave that run's adapter, which every adapter forwards to - * `spawnAndStream`, which terminates the spawned process tree (not only - * the direct child — see `agents/shared.ts`). The chain stops advancing - * to a further stage AND the underlying CLI process is terminated. - * Returns `false` only if `runId` names no active chain at all. */ - cancel(runId: string): boolean { - const state = this.active.get(runId); - if (!state) return false; - - state.cancelRequested = true; - if (state.pendingCheckpoint) { - const { resolve } = state.pendingCheckpoint; - state.pendingCheckpoint = undefined; - resolve("cancelled"); - return true; - } - if (state.currentRunner && state.currentCommand) { - const runner = state.currentRunner; - // `state.cancelReason` is set before every ceiling's `cancel()` and - // left unset by a person's, so this is exactly the distinction the - // audit entry needs and cannot otherwise make. - const cancelCommand: Command = { - ...state.currentCommand, - kind: "cancel", - ...(state.cancelReason !== undefined ? { reason: state.cancelReason } : {}), - }; - void (async () => { - try { - for await (const _event of runner.run(cancelCommand)) { - // Draining only. Forwarding these events to a listener is each - // single-stage run's own concern (mirrors `RunController`); the - // chain's own event stream already reflects "ending" via the - // `cancelled` event `runChain` emits once the current stage's - // `run()` call above returns. - } - } catch { - // A runner throwing synchronously on `"cancel"` must not become - // an unhandled rejection here — see `resolvePermission()`'s - // identical guard below. - } - })(); - } - return true; - } - - /** Whether this runner holds an active chain under `runId`, so a host can - * route a request about that run here rather than to a single-stage - * runner that never heard of it. */ - holds(runId: string): boolean { - return this.active.has(runId); - } - - /** Asks a chain to stop where its work is sound, rather than killing it as - * `cancel()` does (a-change-is-run-from-its-card, ADR 0028). - * - * - Waiting at a checkpoint, it ends at once. - * - Inside a stage, the stage ends at the first of: a marker naming a task - * other than the last one named, the count of ticked tasks rising, or - * the stage's own end. A stage waiting on a permission is answered - * `deny` and ends at once. - * - Between stages, no further stage starts. - * - * Each way yields `stopRequested` with outcome `asked` on the chain's own - * stream, then ends `cancelled` with no reason. `by` is the host's - * configured git identity where the caller gives none. A second request - * while one is pending changes nothing. Returns `false` for a run this - * runner does not have. - * - * `messageId` is the signed request's, where the stop was asked through - * the channel from another worktree; the chain's ending entry carries it - * (a-run-elsewhere-can-be-asked-to-stop). */ - /** Takes a note or a question for a run this runner has, to be handed to - * the agent when the next stage starts. - * - * Held rather than delivered now: an agent reads its prompt when its - * stage begins, and this product drives no agent that can be spoken to - * in the middle of one. A person who needs the run to act now has a stop - * (the-operator-can-say-something-to-a-run). - * - * Returns `false` for a run this runner does not have. */ - deliverMessage(runId: string, message: ChainMessage): boolean { - const state = this.active.get(runId); - if (!state) return false; - if (state.pendingMessages.some((held) => held.messageId === message.messageId)) return true; - state.pendingMessages.push(message); - return true; - } - - requestStop(runId: string, reason: string, by?: string, messageId?: string, afterTask?: string): boolean { - const state = this.active.get(runId); - if (!state) return false; - if (state.stopRequest !== undefined) return true; - if (afterTask !== undefined) { - // Held, not pending: the run keeps working until the task it names - // is done. The first held request wins, as the first stop does. - if (state.heldStop === undefined) { - state.heldStop = { - reason, - ...(by !== undefined ? { by } : {}), - ...(messageId !== undefined ? { messageId } : {}), - afterTask, - }; - state.stopWake?.(); - } - return true; - } - state.stopRequest = { - reason, - ...(by !== undefined ? { by } : {}), - ...(messageId !== undefined ? { messageId } : {}), - ...(afterTask !== undefined ? { afterTask } : {}), - announced: false, - }; - if (state.pendingCheckpoint) { - const { resolve } = state.pendingCheckpoint; - state.pendingCheckpoint = undefined; - resolve("stopped"); - return true; - } - state.stopWake?.(); - return true; - } - - /** A held request comes due: its task was ticked, its agent named a task - * after it, or the change's list does not have it. - * - * The first two become the stop this runner already knows how to honour. - * The third is refused and recorded, and the run goes on: a task nobody - * can find must not silently mean "stop now", which would end a run the - * operator meant to keep, nor "never stop", which would leave a request - * that never lands (a-run-is-told-where-to-stop). */ - private heldStopIsDue( - command: Command, - state: ChainState, - due: { task: string; why: "ticked" | "passed" | "absent" }, - ): void { - const held = state.heldStop; - if (held === undefined) return; - state.heldStop = undefined; - if (due.why === "absent") { - this.deps.auditLog?.record({ - runId: command.runId, - agent: CHAIN_ENDING_AGENT_NAME, - // `message`, as the channel's other refusals are recorded: nothing - // ran and nothing ended, something was said. - outcome: "message", - cwd: command.cwd, - timestamp: nowIso(), - changeDir: command.context.changeDir, - reason: `a request to stop after ${due.task} named a task this change does not have; the run goes on`, - }); - return; - } - this.requestStop(command.runId, held.reason, held.by, held.messageId); - const request = state.stopRequest; - if (request !== undefined) request.afterTask = held.afterTask; - } - - /** Yields the one `stopRequested` a requested stop produces, the first - * time it is asked to; after that, nothing. */ - private async *announceStop(command: Command, state: ChainState): AsyncGenerator { - const stop = state.stopRequest; - if (stop === undefined || stop.announced) return; - stop.announced = true; - if (stop.by === undefined) { - try { - const identity = await (this.deps.createGitWrapper ?? createGitWrapper)({ cwd: command.cwd }).configuredIdentity(); - if (identity) stop.by = identity; - } catch { - // No identity is attribution missing, not a reason to refuse a stop. - } - } - yield { - kind: "stopRequested", - runId: command.runId, - timestamp: nowIso(), - reason: stop.reason, - ...(stop.by !== undefined ? { by: stop.by } : {}), - outcome: "asked", - }; - } - - /** Adapts this runner to the generic `AgentRunner` shape, for hosts that - * dispatch through a single-runner-at-a-time abstraction already built - * for single-stage commands (e.g. `packages/extension/src/run- - * controller.ts`'s `RunController`, which tracks one active `{runner, - * command}` pair and re-sends a `"cancel"`-kind `Command` to it on - * `cancel()`). Unlike `run()` above, the returned `AgentRunner` also - * accepts a `"cancel"` command — routed to this instance's own - * `cancel()` rather than rejected — so such a host's generic cancel path - * works unmodified against an active chain. Yields no events for - * `"cancel"` itself: the chain's own `run()` stream (already being - * consumed elsewhere, by construction, since a "cancel" can only target - * a chain already running) emits `"cancelled"` when it takes effect. */ - asAgentRunner(): AgentRunner { - return { - run: (command: Command): AsyncIterable => { - if (command.kind === "cancel") { - const known = this.cancel(command.runId); - // Reports that the request registered. The chain's own stream - // says `cancelled` later, once the stage's run has actually - // ended — which, since cancel-reports-what-happened, means once - // its process is gone. Returning nothing here left the panel - // with no sign the click had landed, on exactly the path the - // 2026-09-03 report came from. - const runId = command.runId; - return (async function* cancelling(): AsyncGenerator { - yield { - kind: "cancelling", - runId, - timestamp: nowIso(), - attempted: known ? "termination-requested" : "nothing-to-cancel", - }; - })(); - } - if (command.kind === "stop") { - // A run this runner has announces its stop on the chain's own - // stream, which the status record and the host that sent this - // already read; answering here as well would say it twice. Only a - // stop that finds no run is answered on this stream. - const reason = command.reason ?? ""; - const known = this.requestStop(command.runId, reason); - const runId = command.runId; - return (async function* stopping(): AsyncGenerator { - if (!known) yield { kind: "stopRequested", runId, timestamp: nowIso(), reason, outcome: "nothing-to-stop" }; - })(); - } - if (command.kind === "resolvePermission") { - // Routes to the stage's own runner rather than letting it reach - // `run()`, which accepts only `"chain"` — see `resolvePermission()` - // above. No event is yielded here, mirroring `agent-runner.ts`'s - // own `"resolvePermission"` branch: the stage's already-open - // stream (a separate, still-open call to `run()` for the same - // runId) is what continues. - this.resolvePermission(command); - return (async function* noEvents(): AsyncGenerator { })(); - } - return this.run(command); - }, - }; - } - - private async *runChain(command: Command, state: ChainState): AsyncGenerator { - const { cwd, context, runId } = command; - yield { kind: "started", runId, timestamp: nowIso(), command: "chain", cwd }; - - const changeName = changeNameFromDir(context.changeDir); - if (!changeName) { - yield failedEvent(runId, say("OSW-RUN-001")); - return; - } - - let harnessConfig: HarnessConfig; - try { - harnessConfig = await resolveHarnessConfig(cwd, changeName); - } catch (error) { - yield failedEvent(runId, error instanceof Error ? error.message : String(error)); - return; - } - - if (harnessConfig.autonomyLevel === "assisted") { - yield failedEvent(runId, say("OSW-RUN-002")); - return; - } - - if (harnessConfig.autonomyLevel === "autonomous") { - // Re-derive from the per-change file directly rather than trusting - // the already-merged `harnessConfig` — see design.md, "checkpoints... - // provenance is re-derived, not trusted from the merged config". - let changeOverride: Partial | undefined; - try { - changeOverride = await readChangeHarnessConfig(cwd, changeName); - } catch (error) { - yield failedEvent(runId, error instanceof Error ? error.message : String(error)); - return; - } - if (changeOverride?.autonomyLevel !== "autonomous") { - yield failedEvent(runId, say("OSW-RUN-003", { change: changeName })); - return; - } - } - - let startStage: ChainStage; - try { - startStage = await determineStartStage(cwd, changeName, context.changeDir); - } catch (error) { - yield failedEvent(runId, error instanceof Error ? error.message : String(error)); - return; - } - - // The sequence is what this change declares, not a constant — ADR - // 0021. Steps are inserted AFTER the resume slice, so a step - // anchored to a stage that already happened does not run, and one - // anchored to the stage the chain resumes at does: reaching that - // stage is exactly what it was placed before. - // A stage `skipStages` leaves out does not run, and the timeline says - // so, so a transcript still reads like every other - // (a-done-change-carries-on). - const resumed = CHAIN_STAGES.slice(CHAIN_STAGES.indexOf(startStage)); - for (const stage of resumed) { - if (skipsStage(harnessConfig, stage)) { - yield { kind: "progress", runId, timestamp: nowIso(), message: `${stage} skipped: skipStages leaves it out` }; - } - } - const sequence = insertDeclaredSteps( - resumed.filter((stage) => !skipsStage(harnessConfig, stage)), - harnessConfig.steps, - ); - - // Populated around the "apply" stage only (see `captureApplyCheckpoint`/ - // `readApplyCheckpoint`), and handed to the "verify" stage's own - // Command when that stage runs. Stays `undefined` for any chain that - // doesn't run "apply" itself (e.g. resuming directly at "verify") — a - // verify stage with no delta available runs with the prompt it would - // have produced before this capability existed, per security.ts's - // absent-field path. - let verifiedDelta: VerifiedDeltaEntry[] | undefined; - - // Set when `archive` has run and the review gate says `git` must not. - // The git stage is then skipped while the rest of the sequence — a - // step a change declared after `archive` — still runs, and the chain - // reports the archive it actually performed. - let gitStageSkipped = false; - let archivedSummary: string | undefined; - - for (let index = 0; index < sequence.length; index += 1) { - const entry = sequence[index] as ChainEntry; - if (entry.kind === "stage" && entry.stage === "git" && gitStageSkipped) continue; - if (entry.kind === "step") { - const stepOk = yield* this.runDeclaredStep(entry.declaration, command, state, changeName); - if (!stepOk) return; - continue; - } - const stage = entry.stage; - const hasNextStage = index < sequence.length - 1; - - // Checked BEFORE the stage starts, never during it — a stage - // already running is never interrupted by this check (ADR 0018 - // decision 7: a run's cost is not known until it ends). Do not - // "also check during the run" here; that is exactly the mid-run - // interruption ADR 0018 rejects. - const budgetReason = await this.checkBudget(harnessConfig, context.changeDir); - if (budgetReason) { - yield failedEvent(runId, budgetReason); - return; - } - - // The run ceiling, checked before a stage starts as well as during - // one: a chain whose time is already spent must not start further - // work, and reaching the ceiling is a rule firing rather than a - // defect — so it cancels with a reason, where the budget check - // above fails. The two differ deliberately; see design.md. - const runSeconds = harnessConfig.timeout?.maxRunSeconds; - if (runSeconds !== undefined && state.elapsedMs >= runSeconds * 1000) { - const spent = say("OSW-RUN-206", { seconds: runSeconds, spent: formatSeconds(state.elapsedMs), stage }); - yield { kind: "cancelled", runId, timestamp: nowIso(), reason: spent.text, code: spent.code }; - return; - } - - // Re-derive the high-impact gate from the change's own file - // immediately before archive moves that file out of the active - // changes directory. Reading it after a successful archive always - // resolves to "not configured" and silently skips the git stage. - let shouldRunGitAfterArchive: boolean | undefined; - if (stage === "archive" && nextStageAfter(sequence, index + 1) === "git") { - try { - shouldRunGitAfterArchive = await this.shouldRunGitStage(cwd, changeName); - } catch (error) { - yield failedEvent(runId, error instanceof Error ? error.message : String(error)); - return; - } - } - - // Announced only once every check that could refuse this stage has - // passed, so "announced" always means "actually ran". A stage - // refused at the budget ceiling above returns before reaching here - // and is never announced as started — a display that showed it - // would be naming a stage that spent nothing. - const priorAttempts = state.attemptsByStage.get(stage) ?? 0; - yield { - kind: "stageStarted", - runId, - timestamp: nowIso(), - stage, - // "" for the stages that run no agent at all ("archive", "git"), - // matching `checkpoint`'s `nextAgentId` convention rather than - // inventing a second spelling for the same idea. - agentId: stageAgentOf(stage, harnessConfig, state), - // Absent on a first attempt, which is every chain that has not - // come back here — a surface renders those exactly as it always - // did. - ...(priorAttempts > 0 ? { attempt: priorAttempts + 1 } : {}), - ...(state.returnReason !== undefined ? { previousAttemptReason: state.returnReason } : {}), - }; - state.returnReason = undefined; - - const applyCheckpoint = stage === "apply" ? await this.captureApplyCheckpoint(cwd) : undefined; - // Counted beside the checkpoint, so "did the run tick anything" is - // asked of the same moment as "did the run change anything". - const tasksBeforeApply = stage === "apply" ? await countTasks(context.changeDir) : undefined; - - // Unlike the budget check above, this one can act on a stage that - // is already running: elapsed time is known during a run where a - // run's cost is not, which is the whole reason a time ceiling - // exists (see run-has-a-time-limit's design.md). The timer calls - // `cancel()` rather than killing the child directly — `cancel()` - // terminates the process tree, resolves a pending checkpoint and - // sets the flag this loop reads, and reimplementing two of those - // three is how a chain keeps walking after its stage was killed. - // Absent means one attempt, which is what every chain did before - // this setting existed. A stage is attempted again only when a - // ceiling cut it and attempts remain — never after it failed on its - // own merits, which retrying would only repeat. - const maxAttempts = harnessConfig.maxStageAttempts ?? 1; - let outcome: "completed" | "failed" | "cancelled" | "checks-failed" = "failed"; - // Read from the chain rather than started at zero: a chain that - // returned here from `verify` has already spent attempts on this - // stage, and a counter local to this loop would forget them. - let attempt = state.attemptsByStage.get(stage) ?? 0; - while (attempt < maxAttempts) { - attempt += 1; - state.attemptsByStage.set(stage, attempt); - state.cancelReason = undefined; - state.cancelCode = undefined; - state.lastStageUsage = undefined; - const stageStartedAt = Date.now(); - state.operatorWaitMs = 0; - state.operatorWaitSince = undefined; - const stageDeadlineMs = this.stageDeadlineMs(harnessConfig, state); - /** How long the stage has waited for the operator so far. */ - const waitedMs = () => state.operatorWaitMs + (state.operatorWaitSince === undefined ? 0 : Date.now() - state.operatorWaitSince); - let stageTimer: ReturnType | undefined; - // Measured on the stage's own time: where it waited for the - // operator, the timer is set again for what is left (ADR 0042). - const armStageTimer = (deadlineMs: number, inMs: number): void => { - stageTimer = setTimeout(() => { - const worked = Date.now() - stageStartedAt - waitedMs(); - if (worked < deadlineMs) { - armStageTimer(deadlineMs, deadlineMs - worked); - return; - } - const timedOut = describeTimeout(harnessConfig, state, stage); - state.cancelReason = timedOut.text; - state.cancelCode = timedOut.code; - this.cancel(runId); - }, inMs); - }; - if (stageDeadlineMs !== undefined) armStageTimer(stageDeadlineMs, stageDeadlineMs); - - try { - outcome = yield* this.runStage(stage, hasNextStage, harnessConfig, command, state, verifiedDelta); - } finally { - if (stageTimer) clearTimeout(stageTimer); - // Added whether the stage completed, failed or was cut: all - // three spent the time, and a ceiling that forgave the attempts - // it cut would let a chain retry its way past the very ceiling - // it was given. The time it waited for the operator is not its - // own (ADR 0042). - state.elapsedMs += Date.now() - stageStartedAt - waitedMs(); - state.operatorWaitSince = undefined; - } - - // A failure from the stage's agent, which `runStage` held: under - // `act` the supervisor may try the stage again, and otherwise it is - // yielded here exactly as it came (the-supervisor-changes-agents). - const held = state.heldFailure; - state.heldFailure = undefined; - if (held !== undefined && outcome === "failed") { - if (yield* this.superviseHeldFailure(stage, held, harnessConfig, state, command, attempt, maxAttempts)) continue; - break; - } - // An agent that said `failed` and then ended some other way: said - // in the order it came, as before anything was held. - if (held !== undefined) yield held; - - const cutByCeiling = outcome === "cancelled" && state.cancelReason !== undefined; - if (!cutByCeiling) break; - // The chain's own cancel flag was set by the timer calling - // `cancel()`; clearing it is what makes a further attempt - // possible, and it is cleared only on this path — a person's - // cancel leaves it set and ends the chain. - state.cancelRequested = false; - if (attempt >= maxAttempts) { - const spentAttempts = say("OSW-RUN-209", { stage, attempts: attempt, max: maxAttempts, reason: state.cancelReason ?? "" }); - yield { kind: "cancelled", runId, timestamp: nowIso(), reason: spentAttempts.text, code: spentAttempts.code }; - return; - } - yield { - kind: "stageStarted", - runId, - timestamp: nowIso(), - stage, - agentId: stageAgentOf(stage, harnessConfig, state), - attempt: attempt + 1, - previousAttemptReason: state.cancelReason, - }; - } - - // A stop asked for while the stage ran, which then ended on its own - // before a task boundary came: no further stage starts - // (a-change-is-run-from-its-card). - if (state.stopRequest !== undefined && (outcome === "checks-failed" || (outcome === "completed" && hasNextStage))) { - yield* this.announceStop(command, state); - yield { kind: "cancelled", runId, timestamp: nowIso() }; - return; - } - - // Checked after the stage, never during it: a run's cost is not - // known until it ends, so this stops the chain rather than the - // stage. The ceiling that stops a stage mid-run is `timeout`. - const stageSpendReason = describeStageOverspend(harnessConfig, state.lastStageUsage, stage); - if (stageSpendReason) { - yield failedEvent(runId, stageSpendReason); - return; - } - - if (stage === "apply" && applyCheckpoint && outcome === "completed") { - const finalized = await this.readApplyCheckpoint(applyCheckpoint); - verifiedDelta = finalized.delta; - const tickedNothing = await describeApplyThatTickedNothing(tasksBeforeApply, context.changeDir, verifiedDelta); - if (tickedNothing !== undefined) { - yield { kind: "progress", runId, timestamp: nowIso(), message: tickedNothing.text, code: tickedNothing.code }; - } - // Nothing changed and nothing ticked, with work of its own open: the - // chain ends here, before verify and archive run for nothing. - if (finalized.read && finalized.delta === undefined) { - const didNothing = await describeApplyThatDidNothing(tasksBeforeApply, context.changeDir, cwd, changeName); - if (didNothing !== undefined) { - yield failedEvent(runId, didNothing); - return; - } - } - } - - // A failing mechanical check at `verify` is the clearest statement - // this chain can produce that earlier work is unfinished, so it - // takes the same backward edge an unchecked task does. The gate - // that produced it deliberately did not invoke the verifying agent, - // and returning to `apply` spends no verifying run either — the - // gate's reason for existing is preserved, not traded away. - if (outcome === "checks-failed") { - const summary = state.checkFailureSummary ?? ""; - state.checkFailureSummary = undefined; - const applyIndex = sequence.findIndex((item) => item.kind === "stage" && item.stage === "apply"); - const applyMaxAttempts = harnessConfig.maxStageAttempts ?? 1; - const applyAttempts = state.attemptsByStage.get("apply") ?? 0; - // The same guard, for the same reason, as the unchecked-task edge - // below: nothing configured means one attempt, which means no - // return is possible, and then this must behave exactly as it did - // before a return existed. - const canReturn = applyIndex !== -1 && applyMaxAttempts > 1 && applyAttempts < applyMaxAttempts; - if (!canReturn) { - yield failedEvent(runId, `mechanical checks failed, verifying agent was not invoked: ${summary}`); - return; - } - state.returnReason = `mechanical checks failed, verifying agent was not invoked: ${summary}`; - index = applyIndex - 1; // the loop's own increment moves it to `apply` - continue; - } - - if (outcome !== "completed") return; - - // A review that asks for changes has the plan updated before apply, - // once, on the review's own agent; the update is part of the review - // stage, so the chain keeps its six stages (ADR 0041). A review that - // says the plan is ready, or says nothing, goes on as before. - if (stage === "review" && state.reviewVerdict === "changes-needed") { - state.reviewVerdict = undefined; - yield { kind: "progress", runId, timestamp: nowIso(), message: "the review asks for changes: updating the plan before apply" }; - yield { - kind: "stageStarted", - runId, - timestamp: nowIso(), - stage, - agentId: stageAgentOf(stage, harnessConfig, state), - updating: true, - }; - const updateStartedAt = Date.now(); - const updated = yield* this.runStage(stage, hasNextStage, harnessConfig, command, state, verifiedDelta, "update"); - // The update's time and spend count toward the review stage's, as - // its audit entries do; a failure `runStage` held is said here, - // since the update is not tried again. - state.elapsedMs += Date.now() - updateStartedAt; - const heldUpdateFailure = state.heldFailure; - state.heldFailure = undefined; - if (heldUpdateFailure !== undefined) yield heldUpdateFailure; - if (updated !== "completed") return; - const updateSpendReason = describeStageOverspend(harnessConfig, state.lastStageUsage, stage); - if (updateSpendReason) { - yield failedEvent(runId, updateSpendReason); - return; - } - } - - // The one backward edge in an otherwise forward-only chain, and it - // exists because `verify` is the only stage that produces a - // machine-checked statement that earlier work is unfinished: it - // writes each declared check's result onto that task's own - // checkbox, so a failing check unchecks the task. Walking on to - // `archive` from here means being refused by it — the chain would - // detect unfinished work correctly and then stop with an error - // instead of finishing it. - if (stage === "verify") { - const applyIndex = sequence.findIndex((item) => item.kind === "stage" && item.stage === "apply"); - const tasks = await countTasks(context.changeDir); - const applyMaxAttempts = harnessConfig.maxStageAttempts ?? 1; - // Nothing configured means one attempt, which means no return is - // possible — and then this must stay out of the way entirely, so - // `archive` refuses exactly as it always has. Reporting a - // different failure here would change what every existing - // configuration does, which is the regression this guard prevents. - if (tasks && tasks.unchecked > 0 && applyMaxAttempts > 1) { - const applyAttempts = state.attemptsByStage.get("apply") ?? 0; - const unfinished = await unfinishedTaskTexts(cwd, changeName); - if (applyIndex === -1 || applyAttempts >= applyMaxAttempts) { - // Named, not counted. The reader is about to take this over, - // and `archive`'s own refusal already tells them how many — - // which of them is what costs a file to find out. - yield failedEvent( - runId, - applyIndex === -1 - ? say("OSW-RUN-101", { count: tasks.unchecked, tasks: unfinished }) - : say("OSW-RUN-102", { count: tasks.unchecked, attempts: applyMaxAttempts, tasks: unfinished }), - ); - return; - } - state.returnReason = `verification left ${tasks.unchecked} task(s) unchecked`; - index = applyIndex - 1; // the loop's own increment moves it to `apply` - continue; - } - } - if (!hasNextStage) return; - - if (stage === "archive") { - archivedSummary = `archived ${changeName}`; - if (nextStageAfter(sequence, index + 1) === "git" && !shouldRunGitAfterArchive) { - // Ending here is right when `git` is all that remains, which is - // every chain that declares no step after `archive`. Where a - // declared step does remain, only the git stage is skipped — - // returning would silently drop a step the change asked for, - // which is the "setting nothing reads" this project refuses. - if (!sequence.slice(index + 1).some((later) => later.kind === "step")) { - yield { kind: "completed", runId, timestamp: nowIso(), summary: archivedSummary }; - return; - } - gitStageSkipped = true; - } - } - - if (state.cancelRequested) { - yield { kind: "cancelled", runId, timestamp: nowIso(), ...cancelledBy(state) }; - return; - } - - // Names the next PART, step or stage alike: a chain that announced - // "next: archive" while a declared wait stood between them would be - // describing a sequence it is not about to run. - const nextEntry = sequence[index + 1]; - // Unreachable: `hasNextStage` above already returned otherwise. - if (nextEntry === undefined) return; - const nextStage = nextEntry.kind === "stage" ? nextEntry.stage : nextEntry.declaration.step; - const requireConfirmation = harnessConfig.autonomyLevel === "semi-autonomous" - && harnessConfig.checkpoints?.requireConfirmationBetweenSteps !== false; - - if (requireConfirmation) { - // `state.pendingCheckpoint` is registered BEFORE yielding the - // checkpoint event, not after: a real consumer (or this module's - // own tests) reacts to a yielded event synchronously, before this - // generator gets a chance to resume and run any code that comes - // after the `yield`. Registering the resolver first closes that - // window — `confirmCheckpoint()`/`cancel()` called the instant the - // event is observed will always find a pending resolver to settle, - // rather than racing a promise that has not been constructed yet. - const checkpointPromise = new Promise((resolve) => { - state.pendingCheckpoint = { resolve }; - }); - yield { - kind: "checkpoint", - runId, - timestamp: nowIso(), - stage, - nextStage, - // "archive"/"git" have no agent (mechanical, or a dedicated - // non-agent sequence) — "" reads as "no agent required for the - // next stage", not "unknown". Reused from harness-config.ts - // rather than re-listing the two stage names here — see - // harness-step-agent.ts's `HarnessStepAgentStage` comment for - // why they are kept on one list, together. - nextAgentId: stageAgentOf(nextStage, harnessConfig, state), - }; - const checkpointOutcome = await checkpointPromise; - if (checkpointOutcome === "cancelled") { - yield { kind: "cancelled", runId, timestamp: nowIso(), ...cancelledBy(state) }; - return; - } - if (checkpointOutcome === "stopped") { - yield* this.announceStop(command, state); - yield { kind: "cancelled", runId, timestamp: nowIso() }; - return; - } - } else { - yield { kind: "stageCompleted", runId, timestamp: nowIso(), stage, nextStage }; - } - // A stop asked for between stages — while a mechanical stage ran, or - // as a stage handed over — starts no further stage. - if (state.stopRequest !== undefined) { - yield* this.announceStop(command, state); - yield { kind: "cancelled", runId, timestamp: nowIso() }; - return; - } - } - - // Reached only when the last part of the sequence was a declared step - // (ADR 0021): a stage that is last returns out of the loop above, - // having yielded its own terminal event. A step does not, because it - // is not the thing whose completion the chain reports — so without - // this, a chain ending in a step would end with no terminal event at - // all, and every consumer would be left waiting on a run that had - // finished. - yield { - kind: "completed", - runId, - timestamp: nowIso(), - summary: archivedSummary ?? `finished ${changeName}`, - }; - } - - /** Returns a failure reason naming the budget, or `undefined` when the - * stage is free to start. `undefined` whenever there is nothing to - * check against: no `budget` configured, no `listAuditEntries` - * dependency supplied, or no recorded usage yet for this change — see - * spec.md, "A configured budget stops work at stage boundaries" and - * task 8.5 (runs with no `usage` contribute nothing to the total, so a - * change whose runs are all unmeasured never trips the ceiling). */ - /** Milliseconds until the running stage must be cut, or `undefined` - * when nothing bounds it. The smaller of the stage ceiling and what is - * left of the run ceiling — whichever would fire first is the one that - * does, so a stage never outlives the chain's own limit. */ - private stageDeadlineMs(config: HarnessConfig, state: ChainState): number | undefined { - const candidates: number[] = []; - const stageSeconds = config.timeout?.maxStageSeconds; - if (stageSeconds !== undefined) candidates.push(stageSeconds * 1000); - const runSeconds = config.timeout?.maxRunSeconds; - if (runSeconds !== undefined) candidates.push(Math.max(0, runSeconds * 1000 - state.elapsedMs)); - return candidates.length === 0 ? undefined : Math.min(...candidates); - } - - private async checkBudget(harnessConfig: HarnessConfig, changeDir: string): Promise { - const budget = harnessConfig.budget; - const perUnit = Object.entries(budget?.maxCost ?? {}); - if (!budget || (budget.maxCostUsd === undefined && budget.maxTokens === undefined && perUnit.length === 0)) { - return undefined; - } - if (!this.deps.listAuditEntries) return undefined; - - const entries = await this.deps.listAuditEntries(); - const total = totalForChange(buildUsageReport(entries).totalsByChange, changeDir); - if (!total) return undefined; - - if (budget.maxCostUsd !== undefined && total.costUsd >= budget.maxCostUsd) { - return say("OSW-RUN-201", { spent: total.costUsd.toFixed(2), ceiling: budget.maxCostUsd.toFixed(2) }); - } - if (budget.maxTokens !== undefined) { - const totalTokens = total.inputTokens + total.outputTokens; - if (totalTokens >= budget.maxTokens) { - return say("OSW-RUN-202", { spent: totalTokens, ceiling: budget.maxTokens }); - } - } - // A ceiling per unit of account, each against its own unit's total: - // nothing is converted and nothing is summed across units - // (a-run-budget-has-a-unit). - for (const [unit, ceiling] of perUnit) { - const spent = total.costByUnit[unitKey(unit)] ?? 0; - if (spent >= ceiling) { - return say("OSW-RUN-203", { spent, unit, ceiling }); - } - } - return undefined; - } - - /** Best-effort start of the "apply" stage's own checkpoint, so its delta - * can later be handed to "verify" — never lets checkpointing stop the - * chain: a size-limit error (`captureCheckpoint` throws when the - * workspace exceeds its configured limits) or any other failure here - * just means "verify" runs without a delta, exactly as if this - * capability didn't exist (see security.ts's absent-field path). */ - private async captureApplyCheckpoint(cwd: string): Promise { - try { - return await captureCheckpoint(cwd); - } catch { - return undefined; - } - } - - /** Turns a finalized "apply" checkpoint into the `VerifiedDeltaEntry[]` - * shape `security.ts` renders into the "verify" stage's prompt — content - * comes from the checkpoint's own before/after snapshots, never from - * `GitWrapper.diff()` (see design.md's rejected alternative). Best-effort - * for the same reason `captureApplyCheckpoint` is, and says whether the - * checkpoint could be read: "no change" and "could not tell" are different - * facts, and only the first may stop a chain - * (an-apply-that-ticks-nothing-ends-the-chain). */ - private async readApplyCheckpoint(checkpoint: WorkbenchCheckpoint): Promise<{ read: boolean; delta: VerifiedDeltaEntry[] | undefined }> { - try { - const delta = await finalizeCheckpoint(checkpoint); - if (delta.length === 0) return { read: true, delta: undefined }; - return { - read: true, - delta: delta.map((entry) => ({ - path: entry.path, - kind: entry.kind, - before: checkpoint.before.get(entry.path)?.content.toString("utf8"), - after: checkpoint.after?.get(entry.path)?.content.toString("utf8"), - })), - }; - } catch { - return { read: false, delta: undefined }; - } - } - - /** Runs one stage to its own terminal outcome. For an intermediate stage - * (`hasNextStage`), the stage's own raw `"completed"` event is - * deliberately swallowed rather than forwarded — per ADR 0012, - * `"completed"`/`"failed"`/`"cancelled"` are reserved, for a chain run, - * for when the WHOLE CHAIN ends, not each stage; the caller replaces a - * swallowed `"completed"` with `"checkpoint"`/`"stageCompleted"`. A - * `"failed"`/`"cancelled"` stage outcome always ends the whole chain, so - * it is forwarded as-is regardless of `hasNextStage`. */ - /** What the supervisor does about a stage whose agent failed - * (the-supervisor-changes-agents, design.md decision 5). Under `act`, with - * an attempt left and a repeat or a move its policy allows, it says so, - * records it, announces the next attempt and returns `true`; the attempt - * loop goes on. Otherwise it yields the failure, unchanged, and returns - * `false` — under `act` after saying why the stage was not tried again. */ - private *superviseHeldFailure( - stage: ChainStage, - failure: Extract, - harnessConfig: HarnessConfig, - state: ChainState, - command: Command, - attempt: number, - maxAttempts: number, - ): Generator { - const { runId, cwd, context } = command; - const supervisor = resolveSupervisor(harnessConfig.supervisor); - // A person's cancel or stop, or a stage that already spent past its - // ceiling, ends the chain as it did before `act` existed. - if ( - supervisor.mode !== "act" - || !isHarnessStepAgentStage(stage) - || state.cancelRequested - || state.stopRequest !== undefined - || describeStageOverspend(harnessConfig, state.lastStageUsage, stage) !== undefined - ) { - yield failure; - return false; - } - const current = stageAgentOf(stage, harnessConfig, state) || DEFAULT_AGENT_ID; - const decision = superviseFailure({ - stage, - current, - tried: state.triedAgents.get(stage) ?? [current], - diagnosis: failure.diagnosis, - supervisor, - }); - const goesOn = decision.action !== "none" && attempt < maxAttempts; - const because = decision.action === "none" - ? decision.why - : goesOn - ? decision.because - : `${decision.because}, but ${stage} has had ${attempt} attempt(s), the maximum configured (maxStageAttempts: ${maxAttempts})`; - const action = goesOn ? decision.action : "none"; - const to = goesOn && decision.action === "move" ? decision.agent : undefined; - const message = action === "move" - ? `the supervisor moved ${stage} from ${current} to ${to}: ${because}` - : action === "repeat" - ? `the supervisor repeats ${stage} on ${current}: ${because}` - : `the supervisor did not try ${stage} again: ${because}`; - yield { kind: "progress", runId, timestamp: nowIso(), message }; - this.deps.auditLog?.record({ - runId, - agent: SUPERVISOR_AGENT_NAME, - outcome: "message", - cwd, - timestamp: nowIso(), - changeDir: context.changeDir, - stage, - reason: message, - supervisorDecision: { action, stage, from: current, ...(to !== undefined ? { to } : {}), because }, - }); - if (!goesOn) { - yield failure; - return false; - } - if (to !== undefined) state.agentOverride.set(stage, to); - yield { - kind: "stageStarted", - runId, - timestamp: nowIso(), - stage, - agentId: stageAgentOf(stage, harnessConfig, state), - attempt: attempt + 1, - previousAttemptReason: message, - }; - return true; - } - - private async *runStage( - stage: ChainStage, - hasNextStage: boolean, - harnessConfig: HarnessConfig, - command: Command, - state: ChainState, - verifiedDelta: VerifiedDeltaEntry[] | undefined, - /** The command the stage runs instead of its own: `update`, where a - * review asked for changes (ADR 0041). */ - kindOverride?: CommandKind, - ): AsyncGenerator { - const { cwd, context, runId } = command; - - if (stage === "git") { - return yield* this.runGitStage(command, harnessConfig, hasNextStage); - } - - if (stage === "archive") { - // The archive stage is irreversible, and a stage exiting successfully - // is not evidence the work was done — an agent process can exit `0` - // having changed nothing. Refuse on anything short of "every task - // checked", including a task count that cannot be read at all. - const tasks = await countTasks(context.changeDir); - if (!tasks || tasks.unchecked > 0) { - const changeName = changeNameFromDir(context.changeDir); - // Named, not only counted — the same reasoning, and the same - // function, as the return from verification: whoever reads this is - // about to take the work over. See a-done-task-is-ticked. - const unfinished = tasks ? await unfinishedTaskTexts(cwd, changeName) : ""; - yield failedEvent( - runId, - tasks - ? `cannot archive "${changeName}": ${tasks.unchecked} task(s) still unchecked (${unfinished}); complete or verify them, then archive` - : `cannot archive "${changeName}": its tasks.md could not be read, so task completion is unknown; verify the change, then archive`, - ); - return "failed"; - } - try { - await archiveChange(changeNameFromDir(context.changeDir), { cwd }); - } catch (error) { - yield failedEvent(runId, error instanceof Error ? error.message : String(error)); - return "failed"; - } - if (!hasNextStage) { - yield { kind: "completed", runId, timestamp: nowIso(), summary: `archived ${changeNameFromDir(context.changeDir)}` }; - } - return "completed"; - } - - let verifyCheckOutcome: MechanicalCheckRunOutcome | undefined; - if (stage === "verify") { - // Mechanical checks (task-checklist.ts's `check` declarations) run - // BEFORE the verifying agent — task 3.1/3.3. A failure here skips - // the agent entirely: asking a model to review work that a - // mechanical check already found broken spends a run to learn what - // an exit code already said. A change whose tasks.md declares no - // checks (`ranAny: false`) falls straight through unchanged — task - // 3.5. - try { - verifyCheckOutcome = await runMechanicalChecksForVerify(cwd, context.changeDir); - // Recorded before the gate below, which returns without invoking - // the verifying agent — so the failing case, which records - // nothing today, is exactly the one this must not miss. - this.recordVerifyChecks(command, verifyCheckOutcome, harnessConfig, state); - } catch (error) { - yield failedEvent(runId, error instanceof Error ? error.message : String(error)); - return "failed"; - } - if (verifyCheckOutcome.failed.length > 0) { - // Recorded, not reported. A failing check is the clearest - // statement this chain can produce that earlier work is - // unfinished — it is what unchecks the task in the first place — - // so where another `apply` attempt is available the work goes - // back rather than the chain ending. Only the loop knows whether - // one is, so it yields the event: see verify-sends-work-back tasks.md section 6. - state.checkFailureSummary = verifyCheckOutcome.failed - .map((entry) => `${entry.check.name}${entry.check.param ? `(${entry.check.param})` : ""}: ${entry.result.reason}`) - .join("; "); - return "checks-failed"; - } - } - - // A stage the supervisor moved runs its new agent with that agent's own - // defaults: the old entry's model, effort, budget and custom agent were - // chosen for another agent (the-supervisor-changes-agents). - const stepAgent = harnessConfig.stepAgents[stage]; - const movedTo = state.agentOverride.get(stage); - const { agent: agentId, model, effort, budget, customAgent } = movedTo !== undefined - ? { agent: movedTo, model: undefined, effort: undefined, budget: undefined, customAgent: undefined } - : stepAgent === undefined - ? { agent: undefined, model: undefined, effort: undefined, budget: undefined, customAgent: undefined } - : normalizeStepAgent(stepAgent); - const runner = this.deps.resolveRunner(agentId, cwd); - if (!runner) { - yield failedEvent(runId, `no agent available to run the "${stage}" stage`); - return "failed"; - } - const ranOn = agentId ?? DEFAULT_AGENT_ID; - const tried = state.triedAgents.get(stage) ?? []; - if (!tried.includes(ranOn)) state.triedAgents.set(stage, [...tried, ranOn]); - - // Only the "verify" stage's context carries a delta and/or an - // "established checks" section — every other stage keeps the exact - // same `context` object the top-level command was given, so its - // prompt stays byte-identical to before this stage existed (see - // security.ts, buildVerifiedDeltaSection's absent-field path). - let stageContext: CommandContext = context; - if (stage === "verify") { - if (verifiedDelta) stageContext = { ...stageContext, verifiedDelta }; - if (verifyCheckOutcome && verifyCheckOutcome.ranAny && verifyCheckOutcome.passed.length > 0) { - const establishedSection = buildEstablishedChecksSection(verifyCheckOutcome.passed); - stageContext = { - ...stageContext, - promptContext: stageContext.promptContext - ? `${stageContext.promptContext}\n\n${establishedSection}` - : establishedSection, - }; - } - } - // What the operator said while the run was working, handed over now - // because a stage's start is the only way words reach an agent - // (the-operator-can-say-something-to-a-run). - const taken = state.pendingMessages.splice(0, state.pendingMessages.length); - if (taken.length > 0) { - const section = buildOperatorMessagesSection(taken); - stageContext = { - ...stageContext, - promptContext: stageContext.promptContext ? `${stageContext.promptContext}\n\n${section}` : section, - }; - for (const message of taken) { - this.deps.auditLog?.record({ - runId, - agent: "chain", - outcome: "message", - cwd, - timestamp: nowIso(), - changeDir: context.changeDir, - operatorMessage: { messageId: message.messageId, kind: message.kind, from: message.from, stage }, - }); - } - state.awaitingAnswer = taken.filter((message) => message.kind === "ask"); - } else { - state.awaitingAnswer = []; - } - - // `stage` travels beside `model`/`effort`/`budget`, which the chain - // already sets here — it is what lets an audit entry say which stage - // spent what, since every stage runs under the chain's own runId. - const stageCommand: Command = { kind: kindOverride ?? CHAIN_STAGE_COMMAND[stage], cwd, context: stageContext, runId, agentId, model, effort, budget, customAgent, stage }; - state.currentRunner = runner; - state.currentCommand = stageCommand; - - // Set once a permission request has been intercepted under - // `"autonomous"` (task 4.1) — from that point on, the stage's own - // `"failed"`/`"cancelled"` terminal events are suppressed, so the - // chain reports the one outcome this interception already yielded, - // not a `"failed"` followed by the `"cancelled"` produced by ending - // the process below (see design.md, "Under autonomous, the stage - // fails and the process is ended", step 4). - let autonomousPermissionFailure = false; - let outcome: "completed" | "failed" | "cancelled" = "completed"; - /** What the agent said at the end of this stage, for an answer: the - * stage's closing summary where it has one, and otherwise the tail of - * what it streamed. */ - let said: string | undefined; - let saidTail = ""; - const remember = (text: string): void => { - if (text.trim().length === 0) return; - saidTail = `${saidTail}${text}`.slice(-ANSWER_WORDS_LIMIT); - }; - // A stop asked for while this stage runs ends it at a sound point - // (a-change-is-run-from-its-card). Where is decided by - // `untilStopBoundary`, the same way for a chain's stage as for a - // single-stage run. - const events = untilStopBoundary({ - events: runner.run(stageCommand), - changeDir: context.changeDir, - stopAsked: () => state.stopRequest !== undefined, - stopAfterTask: () => state.heldStop?.afterTask, - onStopAfterDue: (due) => this.heldStopIsDue(command, state, due), - onWake: (wake) => { - state.stopWake = wake; - }, - announce: () => this.announceStop(command, state), - denyPermission: (requestId) => drainInBackground(runner.run({ - ...stageCommand, - kind: "resolvePermission", - permissionRequestId: requestId, - permissionOutcome: "deny", - })), - endRun: () => drainInBackground(runner.run({ ...stageCommand, kind: "cancel" })), - // Under `autonomous` a permission request fails the stage below, as it - // always has. - mayDenyPermissions: harnessConfig.autonomyLevel !== "autonomous", - }); - try { - for await (const event of events) { - // A wait for the operator's answers begins with `awaitingAnswers` - // and ends with whatever the stage does next (ADR 0042). - if (event.kind === "awaitingAnswers") { - state.operatorWaitSince ??= Date.now(); - } else if (state.operatorWaitSince !== undefined && event.kind !== "question" && event.kind !== "questionAnswered") { - state.operatorWaitMs += Date.now() - state.operatorWaitSince; - state.operatorWaitSince = undefined; - } - if (event.kind === "permissionRequest" && harnessConfig.autonomyLevel === "autonomous" && !autonomousPermissionFailure) { - autonomousPermissionFailure = true; - outcome = "failed"; - yield event; - yield failedEvent(runId, say("OSW-PRM-101", { request: event.description })); - // Ends the stage's process rather than abandoning the generator - // (the rejected alternative in design.md) — `break`ing here would - // leave the ACP driver's permission handler parked on a promise - // nobody can resolve, and the CLI subprocess would outlive the - // chain. - const cancelCommand: Command = { ...stageCommand, kind: "cancel" }; - void (async () => { - try { - for await (const _event of runner.run(cancelCommand)) { - // Draining only — same shape as `cancel()`/`resolvePermission()`. - } - } catch { - // Must not become an unhandled rejection. - } - })(); - continue; - } - if (autonomousPermissionFailure) continue; - if (event.kind === "completed") { - outcome = "completed"; - // What the review said of the plan, for the update that may - // follow it (ADR 0041). - if (stage === "review" && kindOverride === undefined) state.reviewVerdict = event.reviewVerdict; - // What this stage said, kept for a question that is waiting on - // it (the-operator-can-say-something-to-a-run). - if (typeof event.summary === "string" && event.summary.trim().length > 0) said = event.summary.trim(); - if (hasNextStage) continue; - yield event; - continue; - } - if (event.kind === "stdout") remember(event.chunk); - if (event.kind === "agentUpdate") { - remember(readAcpStreamedText(event.update)?.text ?? ""); - // The one ceiling besides `timeout` that can stop a stage - // already running: the gauge arrives during the run, where a - // cost only arrives at its end (a-run-can-outgrow-its-context). - const ceiling = harnessConfig.budget?.maxContextShare; - const gauge = ceiling === undefined ? undefined : readAcpContextGauge(event.update); - if (ceiling !== undefined && gauge !== undefined && gauge.share > ceiling && state.cancelReason === undefined) { - state.cancelReason = describeContextShare(gauge, ceiling); - state.cancelCode = undefined; - this.cancel(runId); - } - } - if (event.kind === "usageReported") state.lastStageUsage = event.usage; - if (event.kind === "failed") { - // Held, not yielded: only the attempt loop knows whether the - // supervisor tries the stage again, and a stage that goes on must - // not have said `failed` (the-supervisor-changes-agents). - outcome = "failed"; - state.heldFailure = event; - continue; - } - if (event.kind === "cancelled") { - outcome = "cancelled"; - // The runner reports that its process is gone; only the chain - // knows a rule caused that rather than a person, so the reason - // is attached here rather than invented downstream. - yield state.cancelReason ? { ...event, ...cancelledBy(state) } : event; - continue; - } - yield event; - } - } finally { - state.stopWake = undefined; - state.currentRunner = undefined; - state.currentCommand = undefined; - } - await this.answerWhatWasAsked(state, stage, runId, said ?? (saidTail.trim() || undefined), outcome); - return outcome; - } - - /** Answers every question this stage carried, with what the agent said. - * - * There is no separate turn and no second model call: the question was in - * the prompt, and the stage's own closing words are the reply. A stage - * that said nothing answers with how it ended, which is still an answer - - * silence would leave a person waiting for one that never comes. */ - private async answerWhatWasAsked( - state: ChainState, - stage: ChainStage, - runId: string, - said: string | undefined, - outcome: "completed" | "failed" | "cancelled", - ): Promise { - const asked = state.awaitingAnswer.splice(0, state.awaitingAnswer.length); - if (asked.length === 0 || this.deps.answerMessage === undefined) return; - const words = said ?? `the ${stage} stage ended ${outcome} without a closing summary`; - for (const question of asked) { - try { - await this.deps.answerMessage({ to: question.fromKeyId, answers: question.messageId, words, stage, runId }); - } catch { - // An answer that cannot be written must not end the run. The audit - // says the question was delivered; the person can look there. - } - } - } - - private async shouldRunGitStage(workspaceRoot: string, changeName: string): Promise { - const changeOverride = await readChangeHarnessConfig(workspaceRoot, changeName); - return changeOverride?.reviewGate?.mode === "agent-sufficient"; - } - - private buildGitStageAllowlist(harnessConfig: HarnessConfig): AllowlistConfig { - const remotes = harnessConfig.gitStageAllowlist?.remotes ?? []; - const branches = harnessConfig.gitStageAllowlist?.branches ?? []; - return { - [GIT_STAGE_AGENT_NAME]: [ - { - executable: "git", - argsAllowed: (args: string[]) => { - if (args[0] !== "push") return false; - const remote = args[1] ?? ""; - const branch = args[2] ?? ""; - return matchesPattern(remote, remotes) && matchesPattern(branch, branches); - }, - }, - { - executable: "gh", - argsAllowed: (args: string[]) => { - if (args[0] !== "pr") return false; - if (args[1] === "create") { - const headIndex = args.indexOf("--head"); - const baseIndex = args.indexOf("--base"); - const headBranch = headIndex >= 0 ? args[headIndex + 1] ?? "" : ""; - const baseBranch = baseIndex >= 0 ? args[baseIndex + 1] ?? "" : ""; - return matchesPattern(DEFAULT_GIT_REMOTE, remotes) - && matchesPattern(headBranch, branches) - && matchesPattern(baseBranch, branches); - } - if (args[1] === "merge") { - return matchesPattern(DEFAULT_GIT_REMOTE, remotes); - } - return false; - }, - }, - ], - }; - } - - /** Runs one declared step, reporting it on the chain's own timeline. - * Returns `false` when the chain must stop. - * - * The time a waiting step spends is deliberately NOT added to - * `state.elapsedMs`: a chain waiting for something outside itself is - * not consuming anything, exactly as a chain paused at a checkpoint is - * not, and counting it would fire the run-time ceiling on chains - * behaving exactly as they were configured. Each wait carries its own - * maximum duration instead, so a chain still never waits forever. */ - private async *runDeclaredStep( - declaration: HarnessChainStep, - command: Command, - state: ChainState, - changeName: string, - ): AsyncGenerator { - const { runId, cwd } = command; - yield { - kind: "stageStarted", - runId, - timestamp: nowIso(), - // A step names itself in the field a stage names itself in — one - // timeline, so a surface that renders stages renders this with no - // new event kind to learn. - stage: declaration.step, - // The convention `archive` and `git` already use for a part of the - // chain that invokes no agent, rather than a second spelling. - agentId: "", - }; - - const startedAt = Date.now(); - let result; - try { - result = await runChainStep(declaration.step, declaration.param, { - workspaceRoot: cwd, - changeName, - maxWaitMs: declaration.maxWaitSeconds !== undefined - ? declaration.maxWaitSeconds * 1000 - : DEFAULT_CHAIN_STEP_MAX_WAIT_MS, - }); - } catch (error) { - yield failedEvent(runId, `step "${declaration.step}" failed: ${error instanceof Error ? error.message : String(error)}`); - return false; - } - - if (!isWaitingStep(declaration.step)) { - state.elapsedMs += Date.now() - startedAt; - } - - if (!result.ok) { - yield failedEvent(runId, `step "${declaration.step}" did not succeed: ${result.reason}`); - return false; - } - - yield { kind: "progress", runId, timestamp: nowIso(), message: `${declaration.step}: ${result.reason}` }; - return true; - } - - /** Records what `verify`'s declared checks found. - * - * Written by this runner rather than by an agent run, for the reason - * `recordGitAction` exists: mechanical work that no agent performed - * still belongs in the record. And it matters most in the case that - * records nothing today — a `verify` whose checks failed never invokes - * the verifying agent, so the run that found the most left no trace. - * - * A change declaring no checks records nothing at all: an entry saying - * none ran is indistinguishable from one saying none failed. - * - * `agent` names the writer, which is this runner — so the entry also - * carries `checkedAgent`, the agent whose work these checks examined. - * Without it the only thing a per-agent readback could group by was - * the pseudo-agent, and it produced one row naming nobody however many - * agents had run. See - * quality-is-charged-to-the-agent-whose-work-was-checked. */ - private recordVerifyChecks( - command: Command, - outcome: MechanicalCheckRunOutcome, - harnessConfig: HarnessConfig, - state: Pick, - ): void { - if (!outcome.ranAny) return; - const failures = outcome.failed - .map((entry) => `${entry.check.name}${entry.check.param ? `(${entry.check.param})` : ""}: ${entry.result.reason}`) - .join("; "); - this.deps.auditLog?.record({ - runId: command.runId, - agent: VERIFY_CHECKS_AGENT_NAME, - outcome: outcome.failed.length === 0 ? "completed" : "failed", - cwd: command.cwd, - timestamp: nowIso(), - changeDir: command.context.changeDir, - stage: "verify", - checkedAgent: resolvedApplyAgent(harnessConfig, state), - checksRan: outcome.passed.length + outcome.failed.length, - checksFailed: outcome.failed.length, - ...(failures.length > 0 ? { reason: failures } : {}), - }); - } - - private recordGitAction( - command: Command, - invocation: AdapterInvocation, - outcome: "blocked" | "started" | "completed" | "failed", - details?: { reason?: string; summary?: string }, - ): void { - this.deps.auditLog?.record({ - runId: command.runId, - agent: GIT_STAGE_AGENT_NAME, - outcome, - cwd: command.cwd, - timestamp: nowIso(), - changeDir: command.context.changeDir, - invocation, - reason: details?.reason, - summary: details?.summary, - }); - } - - private async *runGitStage( - command: Command, - harnessConfig: HarnessConfig, - hasNextStage: boolean, - ): AsyncGenerator { - const allowlistRules = harnessConfig.gitStageAllowlist; - if (!allowlistRules) { - yield failedEvent(command.runId, "git stage requires a per-change gitStageAllowlist"); - return "failed"; - } - - const git = (this.deps.createGitWrapper ?? createGitWrapper)({ cwd: command.cwd }); - // The forge `origin` is on: GitHub, GitLab or Gitea (github-without-gh). - const prGateway = this.deps.createPullRequestGateway - ? this.deps.createPullRequestGateway({ cwd: command.cwd }) - : await pullRequestGatewayFor(command.cwd); - const allowlist = this.buildGitStageAllowlist(harnessConfig); - - const branch = await git.currentBranch(); - if (!branch) { - yield failedEvent(command.runId, "git stage failed: could not resolve current branch"); - return "failed"; - } - - // What the stages left uncommitted goes with the push: a push of a - // branch that holds none of the change's work leaves the server without - // it (a-change-is-committed-where-it-is-made). Never on the default - // branch, where nothing of a change is committed (ADR 0043). - if (branch !== DEFAULT_PR_BASE_BRANCH && branch !== "master") { - const changeName = changeNameFromDir(command.context.changeDir); - const message = `${changeName}: commit what the stages left`; - const commitInvocation: AdapterInvocation = { kind: "process", executable: "git", args: ["commit", "-m", message] }; - try { - const commit = await (this.deps.commitWhatIsLeft ?? commitWhatIsLeft)(command.cwd, message); - if (commit !== undefined) this.recordGitAction(command, commitInvocation, "completed", { summary: `committed ${commit.slice(0, 8)}` }); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - this.recordGitAction(command, commitInvocation, "failed", { reason }); - yield failedEvent(command.runId, say("OSW-GIT-103", { why: reason })); - return "failed"; - } - } - - const pushInvocation: AdapterInvocation = { - kind: "process", - ...buildGitPushInvocation(DEFAULT_GIT_REMOTE, branch), - }; - const pushDecision = checkAllowlist(GIT_STAGE_AGENT_NAME, pushInvocation, allowlist); - if (!pushDecision.allowed) { - this.recordGitAction(command, pushInvocation, "blocked", { reason: pushDecision.reason }); - yield failedEvent(command.runId, `git stage failed at push: ${pushDecision.reason ?? "blocked by allowlist"}`); - return "failed"; - } - this.recordGitAction(command, pushInvocation, "started"); - try { - await git.push(DEFAULT_GIT_REMOTE, branch); - this.recordGitAction(command, pushInvocation, "completed", { summary: `pushed ${branch}` }); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - this.recordGitAction(command, pushInvocation, "failed", { reason }); - yield failedEvent(command.runId, `git stage failed at push: ${reason}`); - return "failed"; - } - - const createInvocation: AdapterInvocation = { - kind: "process", - ...buildGhPrCreateInvocation(branch, DEFAULT_PR_BASE_BRANCH), - }; - const createDecision = checkAllowlist(GIT_STAGE_AGENT_NAME, createInvocation, allowlist); - if (!createDecision.allowed) { - this.recordGitAction(command, createInvocation, "blocked", { reason: createDecision.reason }); - yield failedEvent(command.runId, `git stage failed at pull-request creation: ${createDecision.reason ?? "blocked by allowlist"}`); - return "failed"; - } - - this.recordGitAction(command, createInvocation, "started"); - let pr: { number: number; url: string }; - try { - pr = await prGateway.createPullRequest(branch, DEFAULT_PR_BASE_BRANCH); - this.recordGitAction(command, createInvocation, "completed", { summary: `created PR ${pr.url}` }); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - this.recordGitAction(command, createInvocation, "failed", { reason }); - yield failedEvent(command.runId, `git stage failed at pull-request creation: ${reason}`); - return "failed"; - } - - let checks; - try { - checks = await prGateway.waitForChecks(pr.number); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - yield failedEvent(command.runId, `git stage failed while checking pull-request checks: ${reason}`); - return "failed"; - } - if (checks.state !== "pass") { - const suffix = checks.reason ? `: ${checks.reason}` : ""; - yield failedEvent(command.runId, `git stage failed at pull-request checks${suffix}`); - return "failed"; - } - - const mergeInvocation: AdapterInvocation = { - kind: "process", - ...buildGhPrMergeInvocation(pr.number), - }; - const mergeDecision = checkAllowlist(GIT_STAGE_AGENT_NAME, mergeInvocation, allowlist); - if (!mergeDecision.allowed) { - this.recordGitAction(command, mergeInvocation, "blocked", { reason: mergeDecision.reason }); - yield failedEvent(command.runId, `git stage failed at pull-request merge: ${mergeDecision.reason ?? "blocked by allowlist"}`); - return "failed"; - } - - this.recordGitAction(command, mergeInvocation, "started"); - try { - await prGateway.mergePullRequest(pr.number); - this.recordGitAction(command, mergeInvocation, "completed", { summary: `merged PR ${pr.url}` }); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - this.recordGitAction(command, mergeInvocation, "failed", { reason }); - yield failedEvent(command.runId, `git stage failed at pull-request merge: ${reason}`); - return "failed"; - } - - if (!hasNextStage) { - yield { kind: "completed", runId: command.runId, timestamp: nowIso(), summary: `merged ${pr.url}` }; - } - return "completed"; - } -} +// Chain execution for the Agentic Harness — see +// docs/adr/0012-agentic-harness-chain-execution-protocol.md and +// openspec/changes/agentic-harness-autonomy/design.md. Sequences +// `propose -> review -> apply -> verify -> archive -> git` for a change +// under one `runId`, +// pausing at a `checkpoint` (semi-autonomous, the default) or continuing +// immediately via `stageCompleted` (autonomous, or a per-change +// `checkpoints.requireConfirmationBetweenSteps: false`). The final `git` +// stage is gated by per-change `reviewGate.mode: "agent-sufficient"`. +// +// Lives in `packages/core`, not `webui`/`extension`: which stage is next, +// whether a transition pauses, and whether `autonomous` is actually +// permitted for this change are harness domain decisions, not view logic +// (see design.md, "Chain runner lives in packages/core, not webui" — a +// client-side orchestrator would duplicate this logic across both delivery +// targets and could not outlive a closed webview). + +import { readFile } from "node:fs/promises"; +import { runStartStage } from "./run-plan.js"; +import path from "node:path"; +import { describeContextShare, formatTokenCount, readAcpContextGauge } from "./acp-context-gauge.js"; +import { readAcpStreamedText } from "./acp-streamed-text.js"; +import type { AdapterInvocation, AgentRunner } from "./agent-runner.js"; +import { captureCheckpoint, finalizeCheckpoint, type WorkbenchCheckpoint } from "./checkpoint.js"; +import { + buildGhPrCreateInvocation, + buildGhPrMergeInvocation, + buildGitPushInvocation, + createPullRequestGateway, + type PullRequestGateway, +} from "./gh-pr-gateway.js"; +import { pullRequestGatewayFor } from "./forge.js"; +import { commitWhatIsLeft } from "./change-commit.js"; +import { createGitWrapper, type GitWrapper } from "./git.js"; +import { + runDeclaredChecks, + type DeclaredCheckOutcomeEntry, + type DeclaredCheckRunOutcome, +} from "./declared-checks.js"; +import type { Command, CommandContext, CommandKind, Event, VerifiedDeltaEntry } from "./protocol.js"; +import { + type HarnessChainStep, + type HarnessConfig, + isHarnessStepAgentStage, + normalizeStepAgent, + readChangeHarnessConfig, + resolveHarnessConfig, + resolveSupervisor, +} from "./harness-config.js"; +import { archiveChange, statusChange } from "./openspec.js"; +import { + DEFAULT_CHAIN_STEP_MAX_WAIT_MS, + isWaitingStep, + runChainStep, +} from "./chain-steps.js"; +import { CHAIN_ENDING_AGENT_NAME, SUPERVISOR_AGENT_NAME, VERIFY_CHECKS_AGENT_NAME } from "./audit-runs.js"; +import { superviseFailure } from "./supervisor.js"; +import { isChainStepName, skipsStage, type ChainPart } from "./harness-stage.js"; +import { untilStopBoundary } from "./stop-boundary.js"; +import { DEFAULT_AGENT_ID } from "./agents/registry.js"; +import { checkAllowlist, type AllowlistConfig, type AuditEntry, type AuditLog } from "./security.js"; +import type { AgentUsage } from "./agent-usage.js"; +import { readTaskChecklist, TASK_CHECKBOX_LINE_RE, writeTaskCheckStates } from "./task-checklist.js"; +import { buildUsageReport, unitKey, type UsageTotal } from "./usage-report.js"; +import { say, type SaidMessage } from "./message-register.js"; + +/** The subsequence of `HarnessStage` a chain drives. Each entry's + * `AgentRunner` `CommandKind`, where one exists — `"archive"` and `"git"` + * have no direct `AgentRunner` command kind: archive is a mechanical + * `openspec archive` operation, and git is a dedicated push/PR/merge + * sequence run directly by this runner. Exported (along with + * `CHAIN_STAGES`) only so a test can assert that every stage missing an + * entry here stays excluded from `HarnessStepAgentStage` — see + * harness-git-stage-no-agent tasks.md 5.4: `git` was added here without + * a `CHAIN_STAGE_COMMAND` entry, and to `HarnessStage`, in the same pull + * request that forgot to also exclude it from `HarnessStepAgentStage`. */ +export const CHAIN_STAGE_COMMAND: Readonly> = { + propose: "plan", + review: "review", + apply: "implement", + verify: "verify", +}; +export const CHAIN_STAGES = ["propose", "review", "apply", "verify", "archive", "git"] as const; +type ChainStage = (typeof CHAIN_STAGES)[number]; +const GIT_STAGE_AGENT_NAME = "git-stage"; +const DEFAULT_GIT_REMOTE = "origin"; +const DEFAULT_PR_BASE_BRANCH = "main"; + +export interface HarnessChainDeps { + /** Resolves the `AgentRunner` for an agent id — same shape each host + * already injects into its AI panel (e.g. + * `packages/extension/src/webview/ai-panel.ts`'s `AiPanelDeps. + * resolveRunner`), typically `default-runners.ts`'s `resolveRunner` + * curried over that host's `Map`. Falling back to + * the default agent for an unset `stepAgents` entry is this function's + * responsibility, not the chain runner's. `cwd` is where the stage runs: + * a host gives a change's own worktree that worktree's agents, whose + * sandbox is that worktree (a-change-runs-in-its-own-worktree). */ + resolveRunner: (agentId: string | undefined, cwd?: string) => AgentRunner | undefined; + /** Best-effort accessor for this workspace's recorded audit entries — + * used only to sum recorded usage against `harnessConfig.budget` before + * starting each stage (see openspec/changes/agent-usage-accounting/ + * design.md). Absent, or a `harnessConfig` with no `budget` configured, + * means no budget enforcement — every stage starts exactly as it did + * before this dependency existed. Kept as its own dependency here + * rather than as a method on `AuditLog` (security.ts) — that interface + * stays a pure write sink; this is the one place in this project that + * needs to read audit history back, so the read-back capability lives + * with its one caller instead of widening a security-critical + * interface for it. */ + listAuditEntries?: () => AuditEntry[] | Promise; + /** Where git-stage actions are recorded. Optional for compatibility + * with tests that do not assert audit output. */ + auditLog?: AuditLog; + /** Writes the answer to a question the operator asked, once the stage + * that carried it has ended. The chain holds no key and signs nothing: + * the host that reads messages is the one that has the key, and it + * passes this in (the-operator-can-say-something-to-a-run). Absent, a + * question is delivered and the answer is left unwritten, which the + * audit says. */ + answerMessage?: (answer: ChainAnswer) => void | Promise; + /** Override hooks for tests. Production callers use defaults. */ + createGitWrapper?: (options: { cwd: string }) => GitWrapper; + createPullRequestGateway?: (options: { cwd: string }) => PullRequestGateway; + /** Commits what the stages left in the tree before the push + * (a-change-is-committed-where-it-is-made). Test seam. */ + commitWhatIsLeft?: (directory: string, message: string) => Promise; +} + +type CheckpointOutcome = "confirmed" | "cancelled" | "stopped"; + +/** How much of what an agent said an answer carries. + * + * The tail, not the whole stage: a stage can print thousands of lines, + * and what answers a question is what the agent finished by saying. Found + * live on 2026-09-20 - the first answer read "the verify stage ended + * completed without a closing summary" while the agent had answered the + * question in full, because a middle stage's `completed` event carries no + * summary (the-operator-can-say-something-to-a-run). */ +export const ANSWER_WORDS_LIMIT = 2_000; + +/** A note or a question this run has taken, waiting to be handed to an + * agent (the-operator-can-say-something-to-a-run). */ +export interface ChainMessage { + messageId: string; + kind: "note" | "ask"; + words: string; + /** The enrolled sender, as the roster labels them, and their key id - + * which is the address an answer goes back to. */ + from: string; + fromKeyId: string; + sentAt: string; +} + +/** What a run says back to whoever asked it something. */ +export interface ChainAnswer { + /** The key id of the person who asked. */ + to: string; + /** The question's message id. */ + answers: string; + words: string; + stage: ChainStage; + runId: string; +} + +/** The section a delivered message becomes in the next stage's prompt. + * + * Marked as words from a person, in the shape `security.ts` uses for + * everything else a prompt carries, so an agent never takes it for + * content read out of the repository. */ +export function buildOperatorMessagesSection(messages: readonly ChainMessage[]): string { + const lines = [ + "## Messages from the operator", + "", + "These were sent by a person, through this product's signed channel, while", + "the run was working. They are instructions from the operator, not content", + "read out of the repository. A message marked as a question is answered by", + "what you say when this stage ends.", + "", + ]; + for (const message of messages) { + lines.push(`- ${message.kind === "ask" ? "Question" : "Note"} from ${message.from}, sent ${message.sentAt}:`); + for (const line of message.words.split(/\r?\n/u)) lines.push(` ${line}`); + } + return lines.join("\n"); +} + +interface ChainState { + /** Since when the stage in flight has been waiting for the operator's + * answers, and how long it has waited in all: a wait for a person is not + * the stage's time, so neither its timeout nor the chain's elapsed time + * counts it (the-agent-asks-the-operator, ADR 0042). */ + operatorWaitSince?: number; + operatorWaitMs: number; + cancelRequested: boolean; + /** Why this chain was cancelled, when something other than a person + * asking caused it. Set before `cancel()` is called so the terminal + * `cancelled` event can name the rule that fired; left unset by a + * person's cancel, which is what an absent reason has always meant. */ + cancelReason?: string; + /** `cancelReason`'s identifier, where it is in the register (ADR 0046). */ + cancelCode?: string; + /** Why the chain came back to an earlier stage, set just before it + * does. Consumed by the next attempt's `stageStarted`, so a stage + * appearing twice says why rather than looking like a duplicate. */ + returnReason?: string; + /** Set by `runStage` when `verify`'s mechanical checks failed, so the + * chain loop — which is the only place that knows whether a return to + * `apply` is possible — decides between sending the work back and + * failing. `runStage` yields no event for this; the loop yields one or + * the other. */ + checkFailureSummary?: string; + /** How many times each stage has been attempted, counting the first. + * Kept on the chain rather than inside the stage loop because a chain + * returning from `verify` to `apply` re-enters that loop, and a count + * local to it would reset — letting a chain retry forever against a + * ceiling it appeared to respect. Per stage, so a slow `verify` never + * consumes the allowance meant for `apply`. */ + attemptsByStage: Map; + /** What the stage currently running reported spending, if it reported + * anything. Cleared when a stage starts, so it never carries a previous + * stage's figure into this one's check. Absent means the agent reported + * nothing, which is not zero — the case a spending ceiling cannot act + * on at all. */ + lastStageUsage?: AgentUsage; + /** Time the chain's stages have spent, summed, in milliseconds. Time + * waiting at a checkpoint is deliberately not added: a person + * deliberating is not a run consuming anything, and counting it would + * fire the ceiling on chains behaving exactly as configured. */ + elapsedMs: number; + pendingCheckpoint?: { resolve: (outcome: CheckpointOutcome) => void }; + currentRunner?: AgentRunner; + currentCommand?: Command; + /** A stop a person asked for (a-change-is-run-from-its-card): the reason, + * who asked where known, and whether `stopRequested` has been yielded. */ + stopRequest?: { reason: string; by?: string; messageId?: string; afterTask?: string; announced: boolean }; + /** A request that named a task to stop after, held until that task is + * done. The run goes on working while it is held; when the task is + * ticked, or its agent names a task after it, this becomes + * `stopRequest` and the stage ends at the next sound point + * (a-run-is-told-where-to-stop). */ + heldStop?: { reason: string; by?: string; messageId?: string; afterTask: string }; + /** Wakes the running stage's stop boundary, so a stop is acted on while + * the stage says nothing. */ + stopWake?: () => void; + /** Notes and questions taken from the signed channel and not yet handed + * to an agent. They are handed over when the next stage starts: an agent + * reads its prompt when its stage begins, and there is no second way in + * (the-operator-can-say-something-to-a-run). */ + pendingMessages: ChainMessage[]; + /** The questions handed to the stage now running, waiting for what it + * says at its end. */ + awaitingAnswer: ChainMessage[]; + /** The `failed` event a stage's agent ended with, held by `runStage` until + * the attempt loop decides whether the supervisor repeats or moves the + * stage. Yielded unchanged where it does not, so a `failed` event is + * still the last of the chain it ends (ADR 0012), and never yielded for a + * stage that goes on (the-supervisor-changes-agents). */ + heldFailure?: Extract; + /** The agent the supervisor moved a stage to, which later attempts of it + * run on. */ + agentOverride: Map; + /** Every agent each stage has run on in this chain: a fallback is never + * tried twice. */ + triedAgents: Map; + /** What the chain's review said of the plan, until the update it asks + * for has run (ADR 0041). */ + reviewVerdict?: "ready" | "changes-needed"; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +/** Runs a request about a run — a cancel, a permission's answer — to its + * end without waiting on it. A runner that throws on one must not become an + * unhandled rejection. */ +function drainInBackground(events: AsyncIterable): void { + void (async () => { + try { + for await (const _event of events) { + // Draining only: the stage's own open stream is what reports. + } + } catch { + // See above. + } + })(); +} + +function formatSeconds(totalMs: number): string { + const totalSeconds = Math.max(0, Math.round(totalMs / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m${String(seconds).padStart(2, "0")}s`; +} + +/** The tasks still unchecked, as a readable list for a terminal message. + * Named rather than counted because a reader seeing this is about to take + * the work over, and `archive`'s own refusal already gives them a count — + * which of them is the part that otherwise costs opening the file. + * Truncated, because a change with forty open tasks would otherwise + * produce a message nobody reads. */ +async function unfinishedTaskTexts(workspaceRoot: string, changeName: string): Promise { + let items; + try { + items = await readTaskChecklist(workspaceRoot, changeName, false); + } catch { + // The count that got us here was read successfully; if the list + // cannot be, the message still has to be useful. + return "the task list could not be read to name them"; + } + const open = items.filter((item) => !item.done).map((item) => item.text); + if (open.length === 0) return "none, which contradicts the count just read"; + const shown = open.slice(0, 5); + const suffix = open.length > shown.length ? `, and ${open.length - shown.length} more` : ""; + return shown.map((text) => `"${text}"`).join(", ") + suffix; +} + +/** Names the per-stage ceiling a finished stage exceeded, or `undefined` + * when none was configured, none was exceeded, or the agent reported + * nothing to compare. That last case is not a pass — it is the ceiling + * having nothing to act on, which is exactly what `timeout` exists to + * cover, and it is why this returns `undefined` rather than treating an + * absent figure as zero. */ +function describeStageOverspend( + config: HarnessConfig, + usage: AgentUsage | undefined, + stage: ChainStage, +): SaidMessage | undefined { + if (!usage) return undefined; + const maxCostUsd = config.budget?.maxStageCostUsd; + if (maxCostUsd !== undefined && usage.costUsd !== undefined && usage.costUsd > maxCostUsd) { + return say("OSW-RUN-204", { stage, spent: usage.costUsd.toFixed(2), ceiling: maxCostUsd.toFixed(2) }); + } + const maxTokens = config.budget?.maxStageTokens; + if (maxTokens !== undefined) { + const used = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); + if ((usage.inputTokens !== undefined || usage.outputTokens !== undefined) && used > maxTokens) { + // Explicitly grouped: a bare `toLocaleString` made this one line of + // text read two ways, by whose machine wrote it. + return say("OSW-RUN-205", { stage, spent: formatTokenCount(used), ceiling: formatTokenCount(maxTokens) }); + } + } + return undefined; +} + +/** Names the ceiling that fired and the value it was set to, rather than + * blaming the stage that happened to be running — the posture + * `checkBudget` already takes when it stops a chain. */ +function describeTimeout(config: HarnessConfig, state: ChainState, stage: ChainStage): SaidMessage { + const runSeconds = config.timeout?.maxRunSeconds; + const stageSeconds = config.timeout?.maxStageSeconds; + const remainingRunMs = runSeconds === undefined ? undefined : Math.max(0, runSeconds * 1000 - state.elapsedMs); + const stageMs = stageSeconds === undefined ? undefined : stageSeconds * 1000; + const runFiredFirst = remainingRunMs !== undefined && (stageMs === undefined || remainingRunMs <= stageMs); + if (runFiredFirst) { + return say("OSW-RUN-207", { seconds: String(runSeconds), spent: formatSeconds(state.elapsedMs), stage }); + } + return say("OSW-RUN-208", { stage, seconds: String(stageSeconds) }); +} + +function changeNameFromDir(changeDir: string): string { + const segments = changeDir.split(/[\\/]+/).filter((segment) => segment.length > 0); + return segments[segments.length - 1] ?? ""; +} + +/** A failure, with its identifier where the reason is in the register + * (ADR 0046). */ +function failedEvent(runId: string, reason: string | SaidMessage): Event { + return typeof reason === "string" + ? { kind: "failed", runId, timestamp: nowIso(), reason } + : { kind: "failed", runId, timestamp: nowIso(), reason: reason.text, code: reason.code }; +} + +/** A cancellation's reason and identifier, where a rule caused it rather + * than a person. */ +function cancelledBy(state: ChainState): { reason?: string; code?: string } { + if (!state.cancelReason) return {}; + return { reason: state.cancelReason, ...(state.cancelCode !== undefined ? { code: state.cancelCode } : {}) }; +} + +/** Which agent this chain's `apply` stage runs as — the agent whose work + * a later `verify` stage's checks examine. + * + * An unset `apply` entry falls back to `DEFAULT_AGENT_ID`, which is the + * same fallback `resolveRunner` applies (`default-runners.ts`), so the + * name recorded here matches the `agent` the apply stage's own audit + * entries carry and the two group together. */ +function resolvedApplyAgent(harnessConfig: HarnessConfig, state?: Pick): string { + const moved = state?.agentOverride.get("apply"); + if (moved !== undefined) return moved; + const entry = harnessConfig.stepAgents.apply; + return entry === undefined ? DEFAULT_AGENT_ID : normalizeStepAgent(entry).agent; +} + +/** The agent a stage runs on in this chain, as `stageStarted` and + * `checkpoint` name it: the one the supervisor moved it to, else its + * configured entry, else "" — for a stage with no agent, or none + * configured (the-supervisor-changes-agents). */ +function stageAgentOf(stage: ChainPart, harnessConfig: HarnessConfig, state: Pick): string { + if (isChainStepName(stage) || !isHarnessStepAgentStage(stage)) return ""; + const moved = state.agentOverride.get(stage); + if (moved !== undefined) return moved; + const entry = harnessConfig.stepAgents[stage]; + return entry === undefined ? "" : normalizeStepAgent(entry).agent; +} + +function wildcardPatternToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`); +} + +function matchesPattern(value: string, patterns: readonly string[]): boolean { + return patterns.some((pattern) => wildcardPatternToRegExp(pattern).test(value)); +} + +interface TaskCounts { + unchecked: number; + total: number; +} + +/** Counts the change's own `tasks.md` checkboxes, using the same line + * convention as `task-checklist.ts` rather than a second, drifting copy of + * it. Returns `undefined` when the file cannot be read at all — a signal + * the callers deliberately treat as "unknown", never as "nothing remains" + * (see design.md, "The chain counts tasks itself, and fails safe"). + * + * This exists because `openspec status`'s artifact completeness answers a + * different question: whether `tasks.md` EXISTS, not whether its tasks are + * done. Reading the former as the latter is what archived two + * unimplemented changes. */ +async function countTasks(changeDir: string): Promise { + let content: string; + try { + content = await readFile(path.join(changeDir, "tasks.md"), "utf8"); + } catch { + return undefined; + } + let unchecked = 0; + let total = 0; + for (const line of content.split(/\r?\n/)) { + const match = line.match(TASK_CHECKBOX_LINE_RE); + if (!match) continue; + total += 1; + if ((match[1] ?? "").toLowerCase() !== "x") unchecked += 1; + } + return { unchecked, total }; +} + +/** A line for an implementing run that changed files and ticked no task, + * or `undefined` when it ticked one, changed nothing, or either count + * could not be read. + * + * Reported, never refused. The work may be fine and the ticks merely + * missing — which verification may now repair by ticking what it confirms + * — and refusing would stop exactly the chain that could finish. What + * this prevents is the silence: on 2026-09-12 an implementing run did its + * work, ended with nothing ticked, and nothing on the chain's timeline + * said so. See openspec/changes/a-done-task-is-ticked/design.md. */ +async function describeApplyThatTickedNothing( + before: TaskCounts | undefined, + changeDir: string, + delta: VerifiedDeltaEntry[] | undefined, +): Promise { + if (!before || !delta || delta.length === 0) return undefined; + const after = await countTasks(changeDir); + if (!after) return undefined; + const tickedBefore = before.total - before.unchecked; + const tickedAfter = after.total - after.unchecked; + if (tickedAfter > tickedBefore) return undefined; + return say("OSW-RUN-103", { count: delta.length }); +} + +/** The reason a chain ends after an implementing run that did nothing: it + * changed no file and ticked no task, while a task it could do - not + * Human-only, not delegated - is still open. `undefined` where it ticked + * one, where nothing is left for it to do, or where the task list could not + * be read. + * + * Only ever asked of a checkpoint that was read and held no change: an + * unread one decides nothing. On 2026-10-08 two chains went on from such a + * run to `verify`, which had nothing to confirm, and to `archive`, which + * refused, and the reason they stopped was said nowhere. See + * openspec/changes/an-apply-that-ticks-nothing-ends-the-chain/design.md. */ +async function describeApplyThatDidNothing( + before: TaskCounts | undefined, + changeDir: string, + workspaceRoot: string, + changeName: string, +): Promise { + if (!before) return undefined; + const after = await countTasks(changeDir); + if (!after) return undefined; + if (after.total - after.unchecked > before.total - before.unchecked) return undefined; + let items; + try { + items = await readTaskChecklist(workspaceRoot, changeName, false); + } catch { + return undefined; + } + // A person's task, or another agent's, is not this stage's to do. + const its = items.filter((item) => !item.done && item.humanOnly !== true && item.delegatedTo === undefined); + if (its.length === 0) return undefined; + const shown = its.slice(0, 5).map((item) => `"${item.text}"`).join(", "); + const more = its.length > 5 ? `, and ${its.length - 5} more` : ""; + return say("OSW-RUN-104", { count: its.length, tasks: `${shown}${more}` }); +} + +type MechanicalCheckOutcomeEntry = DeclaredCheckOutcomeEntry; +type MechanicalCheckRunOutcome = DeclaredCheckRunOutcome; + +/** Runs every mechanical check the change's `tasks.md` declares (task + * 3.1), before the `verify` stage's agent is ever invoked, and writes + * each one's pass/fail result onto its own checkbox (task 3.2) — the + * ONLY writer of those checkboxes; an agent's own report never reaches + * them through this path. Reading and parsing `tasks.md` itself is what + * throws `UnknownMechanicalCheckError`/`InvalidMechanicalCheckParameterError` + * for a malformed declaration (task-checklist.ts) — this function does + * not catch those, so a malformed `tasks.md` fails the stage exactly like + * a failing check would (via the caller's own try/catch). */ +async function runMechanicalChecksForVerify(workspaceRoot: string, changeDir: string): Promise { + const changeName = changeNameFromDir(changeDir); + const outcome = await runDeclaredChecks(workspaceRoot, changeName, false); + if (!outcome.ranAny) return outcome; + + // The writing is what makes this the `verify` stage's version rather + // than `runDeclaredChecks` itself: a checkbox records what a check + // found during a run, and `openspec-ui-cli run checks` — which asks the same + // question outside a run — deliberately writes nothing. + await writeTaskCheckStates( + workspaceRoot, + changeName, + false, + [...outcome.passed, ...outcome.failed].map((entry) => ({ + lineNumber: entry.lineNumber, + expectedText: entry.text, + done: entry.result.pass, + })), + ); + return outcome; +} + +/** Renders passing checks' results for the verifying agent's own prompt + * (task 3.4) — so it is told what is already established and does not + * re-run `npm run typecheck`/`lint`/`test`/etc itself. Plain text, not + * Markdown the agent could mistake for an instruction — same posture + * `security.ts`'s other prompt sections take toward change-file content. */ +function buildEstablishedChecksSection(passed: readonly MechanicalCheckOutcomeEntry[]): string { + const lines = passed.map((entry) => { + const paramSuffix = entry.check.param ? ` (${entry.check.param})` : ""; + return `- ${entry.check.name}${paramSuffix}: ${entry.result.reason}`; + }); + return `The following mechanical checks already ran and passed — do not repeat them:\n${lines.join("\n")}`; +} + +/** Determines the first not-yet-complete stage for a change. Whether the + * `propose` artifacts exist still comes from the `status` command (that is + * exactly the question artifact presence answers), but the `apply` vs + * `verify` decision comes from the change's real task checkboxes — + * `status.progress` is deliberately not consulted here, because it is + * absent for the CLI shape this repository actually runs, and the value + * that used to be synthesized in its place counted artifacts, not tasks. + * `review` has no durable artifact of its own in the upstream + * `openspec status` schema, so it is only ever the start stage as a side + * effect of `propose` being incomplete — a chain resuming after `propose` + * is already done starts at `apply` directly (this session's own `review`, + * if any, already happened; a chain cannot tell whether an earlier one + * did, and re-running it unconditionally on every resume would be + * surprising and wasteful). A change whose tasks are all checked but is + * not yet archived resumes at `verify`, not `archive` directly — the same + * "an agent process exiting 0 is not evidence the work was done" reasoning + * `runStage`'s archive gate already applies, applied one stage earlier. */ +async function determineStartStage(cwd: string, changeName: string, changeDir: string): Promise { + const status = await statusChange(changeName, { cwd }); + const isDone = (artifactId: string): boolean => + status.artifacts.some((artifact) => { + if (artifact.id !== artifactId) return false; + const normalized = artifact.status.toLowerCase(); + return normalized === "done" || normalized === "complete"; + }); + // `design` is deliberately not required. A change may carry none — the + // validator accepts that, and three of this repository's own active + // changes and many of its archived ones have no `design.md`. The status + // command reports a missing artifact as `ready`, meaning "could be + // produced"; reading that as an unfinished proposal sent a finished + // change back to `propose` and re-proposed work that was already + // written. A change that is genuinely mid-proposal has no task list + // yet, which `tasks` already answers. + // See design-is-optional-for-resume. + const proposeDone = isDone("proposal") && isDone("tasks"); + if (!proposeDone) return runStartStage({ proposeDone }); + const tasks = await countTasks(changeDir); + // The same decision the run dialog states (the-run-dialog-says-where-it- + // starts): one function, so the dialog cannot name one stage and the run + // begin at another. + return runStartStage({ proposeDone, ...(tasks ? { openTasks: tasks.unchecked } : {}) }); +} + +/** One entry in the sequence a chain actually runs: a fixed stage, or a + * step this change declared. */ +type ChainEntry = + | { kind: "stage"; stage: ChainStage } + | { kind: "step"; declaration: HarnessChainStep }; + +/** Places each declared step against the fixed sequence. + * + * A step is anchored to a stage, never to another step, so two + * declarations can never depend on each other's order — and a step whose + * anchor is not in this (already resumed-sliced) sequence is anchored to + * work that already happened, so it does not run. + * + * Declaration order decides the order of two steps anchored to the same + * side of the same stage, which is the only ordering question this can + * be asked. */ +function insertDeclaredSteps( + stages: readonly ChainStage[], + declared: readonly HarnessChainStep[] | undefined, +): ChainEntry[] { + const entries: ChainEntry[] = []; + for (const stage of stages) { + for (const declaration of declared ?? []) { + if (declaration.before === stage) entries.push({ kind: "step", declaration }); + } + entries.push({ kind: "stage", stage }); + for (const declaration of declared ?? []) { + if (declaration.after === stage) entries.push({ kind: "step", declaration }); + } + } + return entries; +} + +/** The next fixed stage at or after `index`, skipping declared steps. + * + * The chain asks this only about `git`, and skipping steps is the whole + * point: `git` is gated on `reviewGate.mode`, re-derived immediately + * before `archive` moves the change's own file out of the active + * directory. A version that looked only at the very next entry would see + * a declared step there and conclude that `git` was not next — and the + * chain would then walk into the git stage with its gate never + * evaluated. Found by the test for a step declared after `archive`. */ +function nextStageAfter(sequence: readonly ChainEntry[], index: number): ChainStage | undefined { + for (let i = index; i < sequence.length; i += 1) { + const entry = sequence[i]; + if (entry !== undefined && entry.kind === "stage") return entry.stage; + } + return undefined; +} + +/** What one change has spent, gathered from every working directory that + * recorded it — ADR 0022 decision 5. + * + * `totalsByChange` is keyed by the change directory's absolute path, and + * that path is different in every git worktree of the same repository. + * Looking the change up by its own path therefore finds only what this + * directory recorded, so a ceiling would be permitted once per worktree + * — which is exactly what summing the audit logs across worktrees was + * meant to prevent, and did not, until this. + * + * Matched on the directory's last segment, which is the change's name: + * `openspec/changes/` is the only shape a change directory takes, + * and every entry being summed came from one repository's own worktrees, + * so two different changes cannot collide here. + * + * Found by running two chains in two worktrees for real. */ +/** Two per-unit totals, added unit by unit. */ +function mergeCostByUnit( + left: Record, + right: Record, +): Record { + const merged: Record = { ...left }; + for (const [unit, amount] of Object.entries(right)) { + merged[unit] = (merged[unit] ?? 0) + amount; + } + return merged; +} + +function totalForChange( + totalsByChange: Record, + changeDir: string, +): UsageTotal | undefined { + const changeName = path.basename(changeDir); + let found: UsageTotal | undefined; + + for (const [key, total] of Object.entries(totalsByChange)) { + if (path.basename(key) !== changeName) continue; + found = found + ? { + runCount: found.runCount + total.runCount, + inputTokens: found.inputTokens + total.inputTokens, + outputTokens: found.outputTokens + total.outputTokens, + costUsd: found.costUsd + total.costUsd, + // Per unit, never across: a credit and a euro are not addends + // (a-run-budget-has-a-unit). + costByUnit: mergeCostByUnit(found.costByUnit, total.costByUnit), + } + : total; + } + return found; +} + +export class HarnessChainRunner { + private readonly active = new Map(); + + constructor(private readonly deps: HarnessChainDeps) { } + + /** Starts a chain for a `"chain"` command. Returns an async generator — + * every event (including every constituent stage's own `stdout`/ + * `stderr`/`progress`/`started`) is published under `command.runId`, per + * ADR 0012. Only `"chain"` commands are accepted; anything else fails + * immediately without side effects. */ + async *run(command: Command): AsyncGenerator { + if (command.kind !== "chain") { + yield failedEvent(command.runId, say("OSW-RUN-004", { kind: command.kind })); + return; + } + + const state: ChainState = { + cancelRequested: false, + elapsedMs: 0, + operatorWaitMs: 0, + attemptsByStage: new Map(), + pendingMessages: [], + awaitingAnswer: [], + agentOverride: new Map(), + triedAgents: new Map(), + }; + this.active.set(command.runId, state); + // The stage the chain is in, and how it ended, as its own events say. + // Read here rather than at each place an ending is yielded, so no + // ending — a cancel at a checkpoint, a limit, a failure — goes + // unrecorded (a-card-says-what-its-change-is-doing). + let stage: ChainPart | undefined; + let ending: Extract | undefined; + try { + for await (const event of this.runChain(command, state)) { + if (event.kind === "stageStarted") stage = event.stage; + if (event.kind === "completed" || event.kind === "failed" || event.kind === "cancelled") ending = event; + yield event; + } + } finally { + this.active.delete(command.runId); + if (ending !== undefined) this.recordEnding(command, ending, stage, state.stopRequest); + } + } + + /** The one entry a chain writes as it ends: how, at which stage, and why. + * It carries no usage — the stages' own entries do — and `isRunEntry` + * skips it, so no counter reads it as a run. + * + * A chain a person asked to stop carries that request as `stopRequest`, + * and no `reason`: a reason on a cancellation means a rule fired + * (a-change-is-run-from-its-card). */ + private recordEnding( + command: Command, + ending: Extract, + stage: ChainPart | undefined, + stopRequest: ChainState["stopRequest"], + ): void { + const stopped = ending.kind === "cancelled" && stopRequest !== undefined + ? { + reason: stopRequest.reason, + ...(stopRequest.by !== undefined ? { by: stopRequest.by } : {}), + // The request a run elsewhere acted on (a-run-elsewhere-can-be-asked-to-stop). + ...(stopRequest.messageId !== undefined ? { messageId: stopRequest.messageId } : {}), + // The task the request named, so a line read a week later says + // where the run was told to stop rather than where it happened to + // (a-run-is-told-where-to-stop). + ...(stopRequest.afterTask !== undefined ? { afterTask: stopRequest.afterTask } : {}), + } + : undefined; + const reason = ending.kind === "completed" || stopped !== undefined ? undefined : ending.reason; + this.deps.auditLog?.record({ + runId: command.runId, + agent: CHAIN_ENDING_AGENT_NAME, + outcome: ending.kind, + cwd: command.cwd, + timestamp: Number.isFinite(Date.parse(ending.timestamp)) ? ending.timestamp : nowIso(), + changeDir: command.context.changeDir, + // A declared step is not a stage the audit log names. + ...(stage !== undefined && !isChainStepName(stage) ? { stage } : {}), + ...(reason !== undefined ? { reason } : {}), + // The failed stage's own diagnosis, which the stage's failure carried + // through to here: the last-run reading prefers this entry, so a + // diagnosis left on the stage's entry alone would not reach a card + // (the-supervisor-advises). + ...(ending.kind === "failed" && ending.diagnosis !== undefined ? { diagnosis: ending.diagnosis } : {}), + ...(stopped !== undefined ? { stopRequest: stopped } : {}), + }); + } + + /** Resumes a chain paused at a checkpoint. Returns `false` if no chain + * is currently paused for `runId` (already resumed, cancelled, or never + * existed) — the caller should treat that as a no-op, not an error. */ + confirmCheckpoint(runId: string): boolean { + const state = this.active.get(runId); + if (!state?.pendingCheckpoint) return false; + const { resolve } = state.pendingCheckpoint; + state.pendingCheckpoint = undefined; + resolve("confirmed"); + return true; + } + + /** Forwards a `"resolvePermission"` command to the runner executing the + * stage currently in flight for `runId`, unchanged — the driver's + * `runId:requestId` key (`acp-session-driver.ts:231`) must match what it + * stored, so the command is never rewritten on the way through. Returns + * `false` when `runId` names no active chain at all, so a host falls + * through to its own single-stage path. Returns `true` (a no-op) when + * the chain is active but no stage is currently running — between + * stages, or paused at a checkpoint — since an answer naming no pending + * request is not an error (task 1.5). */ + resolvePermission(command: Command): boolean { + const state = this.active.get(command.runId); + if (!state) return false; + const runner = state.currentRunner; + if (!runner) return true; + void (async () => { + try { + for await (const _event of runner.run(command)) { + // Draining only — see `cancel()`'s identical shape above. A + // `"resolvePermission"` command yields no events of its own; the + // stage's own already-open stream is what continues. + } + } catch { + // Same posture as `cancel()`: a runner throwing on an answer must + // not become an unhandled rejection here. + } + })(); + return true; + } + + /** Ends a chain — immediately if it is paused at a checkpoint (fully + * within this runner's control), or by cancelling the currently running + * stage's own run otherwise. Mid-stage cancellation reuses the existing + * single-stage convention (`RunController.cancel()`: re-send a + * `"cancel"`-kind `Command` to the same runner) — that runner (see + * `agent-runner.ts`'s `createAgentRunner`) now aborts the `AbortSignal` + * it gave that run's adapter, which every adapter forwards to + * `spawnAndStream`, which terminates the spawned process tree (not only + * the direct child — see `agents/shared.ts`). The chain stops advancing + * to a further stage AND the underlying CLI process is terminated. + * Returns `false` only if `runId` names no active chain at all. */ + cancel(runId: string): boolean { + const state = this.active.get(runId); + if (!state) return false; + + state.cancelRequested = true; + if (state.pendingCheckpoint) { + const { resolve } = state.pendingCheckpoint; + state.pendingCheckpoint = undefined; + resolve("cancelled"); + return true; + } + if (state.currentRunner && state.currentCommand) { + const runner = state.currentRunner; + // `state.cancelReason` is set before every ceiling's `cancel()` and + // left unset by a person's, so this is exactly the distinction the + // audit entry needs and cannot otherwise make. + const cancelCommand: Command = { + ...state.currentCommand, + kind: "cancel", + ...(state.cancelReason !== undefined ? { reason: state.cancelReason } : {}), + }; + void (async () => { + try { + for await (const _event of runner.run(cancelCommand)) { + // Draining only. Forwarding these events to a listener is each + // single-stage run's own concern (mirrors `RunController`); the + // chain's own event stream already reflects "ending" via the + // `cancelled` event `runChain` emits once the current stage's + // `run()` call above returns. + } + } catch { + // A runner throwing synchronously on `"cancel"` must not become + // an unhandled rejection here — see `resolvePermission()`'s + // identical guard below. + } + })(); + } + return true; + } + + /** Whether this runner holds an active chain under `runId`, so a host can + * route a request about that run here rather than to a single-stage + * runner that never heard of it. */ + holds(runId: string): boolean { + return this.active.has(runId); + } + + /** Asks a chain to stop where its work is sound, rather than killing it as + * `cancel()` does (a-change-is-run-from-its-card, ADR 0028). + * + * - Waiting at a checkpoint, it ends at once. + * - Inside a stage, the stage ends at the first of: a marker naming a task + * other than the last one named, the count of ticked tasks rising, or + * the stage's own end. A stage waiting on a permission is answered + * `deny` and ends at once. + * - Between stages, no further stage starts. + * + * Each way yields `stopRequested` with outcome `asked` on the chain's own + * stream, then ends `cancelled` with no reason. `by` is the host's + * configured git identity where the caller gives none. A second request + * while one is pending changes nothing. Returns `false` for a run this + * runner does not have. + * + * `messageId` is the signed request's, where the stop was asked through + * the channel from another worktree; the chain's ending entry carries it + * (a-run-elsewhere-can-be-asked-to-stop). */ + /** Takes a note or a question for a run this runner has, to be handed to + * the agent when the next stage starts. + * + * Held rather than delivered now: an agent reads its prompt when its + * stage begins, and this product drives no agent that can be spoken to + * in the middle of one. A person who needs the run to act now has a stop + * (the-operator-can-say-something-to-a-run). + * + * Returns `false` for a run this runner does not have. */ + deliverMessage(runId: string, message: ChainMessage): boolean { + const state = this.active.get(runId); + if (!state) return false; + if (state.pendingMessages.some((held) => held.messageId === message.messageId)) return true; + state.pendingMessages.push(message); + return true; + } + + requestStop(runId: string, reason: string, by?: string, messageId?: string, afterTask?: string): boolean { + const state = this.active.get(runId); + if (!state) return false; + if (state.stopRequest !== undefined) return true; + if (afterTask !== undefined) { + // Held, not pending: the run keeps working until the task it names + // is done. The first held request wins, as the first stop does. + if (state.heldStop === undefined) { + state.heldStop = { + reason, + ...(by !== undefined ? { by } : {}), + ...(messageId !== undefined ? { messageId } : {}), + afterTask, + }; + state.stopWake?.(); + } + return true; + } + state.stopRequest = { + reason, + ...(by !== undefined ? { by } : {}), + ...(messageId !== undefined ? { messageId } : {}), + ...(afterTask !== undefined ? { afterTask } : {}), + announced: false, + }; + if (state.pendingCheckpoint) { + const { resolve } = state.pendingCheckpoint; + state.pendingCheckpoint = undefined; + resolve("stopped"); + return true; + } + state.stopWake?.(); + return true; + } + + /** A held request comes due: its task was ticked, its agent named a task + * after it, or the change's list does not have it. + * + * The first two become the stop this runner already knows how to honour. + * The third is refused and recorded, and the run goes on: a task nobody + * can find must not silently mean "stop now", which would end a run the + * operator meant to keep, nor "never stop", which would leave a request + * that never lands (a-run-is-told-where-to-stop). */ + private heldStopIsDue( + command: Command, + state: ChainState, + due: { task: string; why: "ticked" | "passed" | "absent" }, + ): void { + const held = state.heldStop; + if (held === undefined) return; + state.heldStop = undefined; + if (due.why === "absent") { + this.deps.auditLog?.record({ + runId: command.runId, + agent: CHAIN_ENDING_AGENT_NAME, + // `message`, as the channel's other refusals are recorded: nothing + // ran and nothing ended, something was said. + outcome: "message", + cwd: command.cwd, + timestamp: nowIso(), + changeDir: command.context.changeDir, + reason: say("OSW-TSK-101", { task: due.task }).text, + }); + return; + } + this.requestStop(command.runId, held.reason, held.by, held.messageId); + const request = state.stopRequest; + if (request !== undefined) request.afterTask = held.afterTask; + } + + /** Yields the one `stopRequested` a requested stop produces, the first + * time it is asked to; after that, nothing. */ + private async *announceStop(command: Command, state: ChainState): AsyncGenerator { + const stop = state.stopRequest; + if (stop === undefined || stop.announced) return; + stop.announced = true; + if (stop.by === undefined) { + try { + const identity = await (this.deps.createGitWrapper ?? createGitWrapper)({ cwd: command.cwd }).configuredIdentity(); + if (identity) stop.by = identity; + } catch { + // No identity is attribution missing, not a reason to refuse a stop. + } + } + yield { + kind: "stopRequested", + runId: command.runId, + timestamp: nowIso(), + reason: stop.reason, + ...(stop.by !== undefined ? { by: stop.by } : {}), + outcome: "asked", + }; + } + + /** Adapts this runner to the generic `AgentRunner` shape, for hosts that + * dispatch through a single-runner-at-a-time abstraction already built + * for single-stage commands (e.g. `packages/extension/src/run- + * controller.ts`'s `RunController`, which tracks one active `{runner, + * command}` pair and re-sends a `"cancel"`-kind `Command` to it on + * `cancel()`). Unlike `run()` above, the returned `AgentRunner` also + * accepts a `"cancel"` command — routed to this instance's own + * `cancel()` rather than rejected — so such a host's generic cancel path + * works unmodified against an active chain. Yields no events for + * `"cancel"` itself: the chain's own `run()` stream (already being + * consumed elsewhere, by construction, since a "cancel" can only target + * a chain already running) emits `"cancelled"` when it takes effect. */ + asAgentRunner(): AgentRunner { + return { + run: (command: Command): AsyncIterable => { + if (command.kind === "cancel") { + const known = this.cancel(command.runId); + // Reports that the request registered. The chain's own stream + // says `cancelled` later, once the stage's run has actually + // ended — which, since cancel-reports-what-happened, means once + // its process is gone. Returning nothing here left the panel + // with no sign the click had landed, on exactly the path the + // 2026-09-03 report came from. + const runId = command.runId; + return (async function* cancelling(): AsyncGenerator { + yield { + kind: "cancelling", + runId, + timestamp: nowIso(), + attempted: known ? "termination-requested" : "nothing-to-cancel", + }; + })(); + } + if (command.kind === "stop") { + // A run this runner has announces its stop on the chain's own + // stream, which the status record and the host that sent this + // already read; answering here as well would say it twice. Only a + // stop that finds no run is answered on this stream. + const reason = command.reason ?? ""; + const known = this.requestStop(command.runId, reason); + const runId = command.runId; + return (async function* stopping(): AsyncGenerator { + if (!known) yield { kind: "stopRequested", runId, timestamp: nowIso(), reason, outcome: "nothing-to-stop" }; + })(); + } + if (command.kind === "resolvePermission") { + // Routes to the stage's own runner rather than letting it reach + // `run()`, which accepts only `"chain"` — see `resolvePermission()` + // above. No event is yielded here, mirroring `agent-runner.ts`'s + // own `"resolvePermission"` branch: the stage's already-open + // stream (a separate, still-open call to `run()` for the same + // runId) is what continues. + this.resolvePermission(command); + return (async function* noEvents(): AsyncGenerator { })(); + } + return this.run(command); + }, + }; + } + + private async *runChain(command: Command, state: ChainState): AsyncGenerator { + const { cwd, context, runId } = command; + yield { kind: "started", runId, timestamp: nowIso(), command: "chain", cwd }; + + const changeName = changeNameFromDir(context.changeDir); + if (!changeName) { + yield failedEvent(runId, say("OSW-RUN-001")); + return; + } + + let harnessConfig: HarnessConfig; + try { + harnessConfig = await resolveHarnessConfig(cwd, changeName); + } catch (error) { + yield failedEvent(runId, error instanceof Error ? error.message : String(error)); + return; + } + + if (harnessConfig.autonomyLevel === "assisted") { + yield failedEvent(runId, say("OSW-RUN-002")); + return; + } + + if (harnessConfig.autonomyLevel === "autonomous") { + // Re-derive from the per-change file directly rather than trusting + // the already-merged `harnessConfig` — see design.md, "checkpoints... + // provenance is re-derived, not trusted from the merged config". + let changeOverride: Partial | undefined; + try { + changeOverride = await readChangeHarnessConfig(cwd, changeName); + } catch (error) { + yield failedEvent(runId, error instanceof Error ? error.message : String(error)); + return; + } + if (changeOverride?.autonomyLevel !== "autonomous") { + yield failedEvent(runId, say("OSW-RUN-003", { change: changeName })); + return; + } + } + + let startStage: ChainStage; + try { + startStage = await determineStartStage(cwd, changeName, context.changeDir); + } catch (error) { + yield failedEvent(runId, error instanceof Error ? error.message : String(error)); + return; + } + + // The sequence is what this change declares, not a constant — ADR + // 0021. Steps are inserted AFTER the resume slice, so a step + // anchored to a stage that already happened does not run, and one + // anchored to the stage the chain resumes at does: reaching that + // stage is exactly what it was placed before. + // A stage `skipStages` leaves out does not run, and the timeline says + // so, so a transcript still reads like every other + // (a-done-change-carries-on). + const resumed = CHAIN_STAGES.slice(CHAIN_STAGES.indexOf(startStage)); + for (const stage of resumed) { + if (skipsStage(harnessConfig, stage)) { + yield { kind: "progress", runId, timestamp: nowIso(), message: `${stage} skipped: skipStages leaves it out` }; + } + } + const sequence = insertDeclaredSteps( + resumed.filter((stage) => !skipsStage(harnessConfig, stage)), + harnessConfig.steps, + ); + + // Populated around the "apply" stage only (see `captureApplyCheckpoint`/ + // `readApplyCheckpoint`), and handed to the "verify" stage's own + // Command when that stage runs. Stays `undefined` for any chain that + // doesn't run "apply" itself (e.g. resuming directly at "verify") — a + // verify stage with no delta available runs with the prompt it would + // have produced before this capability existed, per security.ts's + // absent-field path. + let verifiedDelta: VerifiedDeltaEntry[] | undefined; + + // Set when `archive` has run and the review gate says `git` must not. + // The git stage is then skipped while the rest of the sequence — a + // step a change declared after `archive` — still runs, and the chain + // reports the archive it actually performed. + let gitStageSkipped = false; + let archivedSummary: string | undefined; + + for (let index = 0; index < sequence.length; index += 1) { + const entry = sequence[index] as ChainEntry; + if (entry.kind === "stage" && entry.stage === "git" && gitStageSkipped) continue; + if (entry.kind === "step") { + const stepOk = yield* this.runDeclaredStep(entry.declaration, command, state, changeName); + if (!stepOk) return; + continue; + } + const stage = entry.stage; + const hasNextStage = index < sequence.length - 1; + + // Checked BEFORE the stage starts, never during it — a stage + // already running is never interrupted by this check (ADR 0018 + // decision 7: a run's cost is not known until it ends). Do not + // "also check during the run" here; that is exactly the mid-run + // interruption ADR 0018 rejects. + const budgetReason = await this.checkBudget(harnessConfig, context.changeDir); + if (budgetReason) { + yield failedEvent(runId, budgetReason); + return; + } + + // The run ceiling, checked before a stage starts as well as during + // one: a chain whose time is already spent must not start further + // work, and reaching the ceiling is a rule firing rather than a + // defect — so it cancels with a reason, where the budget check + // above fails. The two differ deliberately; see design.md. + const runSeconds = harnessConfig.timeout?.maxRunSeconds; + if (runSeconds !== undefined && state.elapsedMs >= runSeconds * 1000) { + const spent = say("OSW-RUN-206", { seconds: runSeconds, spent: formatSeconds(state.elapsedMs), stage }); + yield { kind: "cancelled", runId, timestamp: nowIso(), reason: spent.text, code: spent.code }; + return; + } + + // Re-derive the high-impact gate from the change's own file + // immediately before archive moves that file out of the active + // changes directory. Reading it after a successful archive always + // resolves to "not configured" and silently skips the git stage. + let shouldRunGitAfterArchive: boolean | undefined; + if (stage === "archive" && nextStageAfter(sequence, index + 1) === "git") { + try { + shouldRunGitAfterArchive = await this.shouldRunGitStage(cwd, changeName); + } catch (error) { + yield failedEvent(runId, error instanceof Error ? error.message : String(error)); + return; + } + } + + // Announced only once every check that could refuse this stage has + // passed, so "announced" always means "actually ran". A stage + // refused at the budget ceiling above returns before reaching here + // and is never announced as started — a display that showed it + // would be naming a stage that spent nothing. + const priorAttempts = state.attemptsByStage.get(stage) ?? 0; + yield { + kind: "stageStarted", + runId, + timestamp: nowIso(), + stage, + // "" for the stages that run no agent at all ("archive", "git"), + // matching `checkpoint`'s `nextAgentId` convention rather than + // inventing a second spelling for the same idea. + agentId: stageAgentOf(stage, harnessConfig, state), + // Absent on a first attempt, which is every chain that has not + // come back here — a surface renders those exactly as it always + // did. + ...(priorAttempts > 0 ? { attempt: priorAttempts + 1 } : {}), + ...(state.returnReason !== undefined ? { previousAttemptReason: state.returnReason } : {}), + }; + state.returnReason = undefined; + + const applyCheckpoint = stage === "apply" ? await this.captureApplyCheckpoint(cwd) : undefined; + // Counted beside the checkpoint, so "did the run tick anything" is + // asked of the same moment as "did the run change anything". + const tasksBeforeApply = stage === "apply" ? await countTasks(context.changeDir) : undefined; + + // Unlike the budget check above, this one can act on a stage that + // is already running: elapsed time is known during a run where a + // run's cost is not, which is the whole reason a time ceiling + // exists (see run-has-a-time-limit's design.md). The timer calls + // `cancel()` rather than killing the child directly — `cancel()` + // terminates the process tree, resolves a pending checkpoint and + // sets the flag this loop reads, and reimplementing two of those + // three is how a chain keeps walking after its stage was killed. + // Absent means one attempt, which is what every chain did before + // this setting existed. A stage is attempted again only when a + // ceiling cut it and attempts remain — never after it failed on its + // own merits, which retrying would only repeat. + const maxAttempts = harnessConfig.maxStageAttempts ?? 1; + let outcome: "completed" | "failed" | "cancelled" | "checks-failed" = "failed"; + // Read from the chain rather than started at zero: a chain that + // returned here from `verify` has already spent attempts on this + // stage, and a counter local to this loop would forget them. + let attempt = state.attemptsByStage.get(stage) ?? 0; + while (attempt < maxAttempts) { + attempt += 1; + state.attemptsByStage.set(stage, attempt); + state.cancelReason = undefined; + state.cancelCode = undefined; + state.lastStageUsage = undefined; + const stageStartedAt = Date.now(); + state.operatorWaitMs = 0; + state.operatorWaitSince = undefined; + const stageDeadlineMs = this.stageDeadlineMs(harnessConfig, state); + /** How long the stage has waited for the operator so far. */ + const waitedMs = () => state.operatorWaitMs + (state.operatorWaitSince === undefined ? 0 : Date.now() - state.operatorWaitSince); + let stageTimer: ReturnType | undefined; + // Measured on the stage's own time: where it waited for the + // operator, the timer is set again for what is left (ADR 0042). + const armStageTimer = (deadlineMs: number, inMs: number): void => { + stageTimer = setTimeout(() => { + const worked = Date.now() - stageStartedAt - waitedMs(); + if (worked < deadlineMs) { + armStageTimer(deadlineMs, deadlineMs - worked); + return; + } + const timedOut = describeTimeout(harnessConfig, state, stage); + state.cancelReason = timedOut.text; + state.cancelCode = timedOut.code; + this.cancel(runId); + }, inMs); + }; + if (stageDeadlineMs !== undefined) armStageTimer(stageDeadlineMs, stageDeadlineMs); + + try { + outcome = yield* this.runStage(stage, hasNextStage, harnessConfig, command, state, verifiedDelta); + } finally { + if (stageTimer) clearTimeout(stageTimer); + // Added whether the stage completed, failed or was cut: all + // three spent the time, and a ceiling that forgave the attempts + // it cut would let a chain retry its way past the very ceiling + // it was given. The time it waited for the operator is not its + // own (ADR 0042). + state.elapsedMs += Date.now() - stageStartedAt - waitedMs(); + state.operatorWaitSince = undefined; + } + + // A failure from the stage's agent, which `runStage` held: under + // `act` the supervisor may try the stage again, and otherwise it is + // yielded here exactly as it came (the-supervisor-changes-agents). + const held = state.heldFailure; + state.heldFailure = undefined; + if (held !== undefined && outcome === "failed") { + if (yield* this.superviseHeldFailure(stage, held, harnessConfig, state, command, attempt, maxAttempts)) continue; + break; + } + // An agent that said `failed` and then ended some other way: said + // in the order it came, as before anything was held. + if (held !== undefined) yield held; + + const cutByCeiling = outcome === "cancelled" && state.cancelReason !== undefined; + if (!cutByCeiling) break; + // The chain's own cancel flag was set by the timer calling + // `cancel()`; clearing it is what makes a further attempt + // possible, and it is cleared only on this path — a person's + // cancel leaves it set and ends the chain. + state.cancelRequested = false; + if (attempt >= maxAttempts) { + const spentAttempts = say("OSW-RUN-209", { stage, attempts: attempt, max: maxAttempts, reason: state.cancelReason ?? "" }); + yield { kind: "cancelled", runId, timestamp: nowIso(), reason: spentAttempts.text, code: spentAttempts.code }; + return; + } + yield { + kind: "stageStarted", + runId, + timestamp: nowIso(), + stage, + agentId: stageAgentOf(stage, harnessConfig, state), + attempt: attempt + 1, + previousAttemptReason: state.cancelReason, + }; + } + + // A stop asked for while the stage ran, which then ended on its own + // before a task boundary came: no further stage starts + // (a-change-is-run-from-its-card). + if (state.stopRequest !== undefined && (outcome === "checks-failed" || (outcome === "completed" && hasNextStage))) { + yield* this.announceStop(command, state); + yield { kind: "cancelled", runId, timestamp: nowIso() }; + return; + } + + // Checked after the stage, never during it: a run's cost is not + // known until it ends, so this stops the chain rather than the + // stage. The ceiling that stops a stage mid-run is `timeout`. + const stageSpendReason = describeStageOverspend(harnessConfig, state.lastStageUsage, stage); + if (stageSpendReason) { + yield failedEvent(runId, stageSpendReason); + return; + } + + if (stage === "apply" && applyCheckpoint && outcome === "completed") { + const finalized = await this.readApplyCheckpoint(applyCheckpoint); + verifiedDelta = finalized.delta; + const tickedNothing = await describeApplyThatTickedNothing(tasksBeforeApply, context.changeDir, verifiedDelta); + if (tickedNothing !== undefined) { + yield { kind: "progress", runId, timestamp: nowIso(), message: tickedNothing.text, code: tickedNothing.code }; + } + // Nothing changed and nothing ticked, with work of its own open: the + // chain ends here, before verify and archive run for nothing. + if (finalized.read && finalized.delta === undefined) { + const didNothing = await describeApplyThatDidNothing(tasksBeforeApply, context.changeDir, cwd, changeName); + if (didNothing !== undefined) { + yield failedEvent(runId, didNothing); + return; + } + } + } + + // A failing mechanical check at `verify` is the clearest statement + // this chain can produce that earlier work is unfinished, so it + // takes the same backward edge an unchecked task does. The gate + // that produced it deliberately did not invoke the verifying agent, + // and returning to `apply` spends no verifying run either — the + // gate's reason for existing is preserved, not traded away. + if (outcome === "checks-failed") { + const summary = state.checkFailureSummary ?? ""; + state.checkFailureSummary = undefined; + const applyIndex = sequence.findIndex((item) => item.kind === "stage" && item.stage === "apply"); + const applyMaxAttempts = harnessConfig.maxStageAttempts ?? 1; + const applyAttempts = state.attemptsByStage.get("apply") ?? 0; + // The same guard, for the same reason, as the unchecked-task edge + // below: nothing configured means one attempt, which means no + // return is possible, and then this must behave exactly as it did + // before a return existed. + const canReturn = applyIndex !== -1 && applyMaxAttempts > 1 && applyAttempts < applyMaxAttempts; + if (!canReturn) { + yield failedEvent(runId, say("OSW-VAL-121", { summary })); + return; + } + state.returnReason = say("OSW-VAL-121", { summary }).text; + index = applyIndex - 1; // the loop's own increment moves it to `apply` + continue; + } + + if (outcome !== "completed") return; + + // A review that asks for changes has the plan updated before apply, + // once, on the review's own agent; the update is part of the review + // stage, so the chain keeps its six stages (ADR 0041). A review that + // says the plan is ready, or says nothing, goes on as before. + if (stage === "review" && state.reviewVerdict === "changes-needed") { + state.reviewVerdict = undefined; + yield { kind: "progress", runId, timestamp: nowIso(), message: "the review asks for changes: updating the plan before apply" }; + yield { + kind: "stageStarted", + runId, + timestamp: nowIso(), + stage, + agentId: stageAgentOf(stage, harnessConfig, state), + updating: true, + }; + const updateStartedAt = Date.now(); + const updated = yield* this.runStage(stage, hasNextStage, harnessConfig, command, state, verifiedDelta, "update"); + // The update's time and spend count toward the review stage's, as + // its audit entries do; a failure `runStage` held is said here, + // since the update is not tried again. + state.elapsedMs += Date.now() - updateStartedAt; + const heldUpdateFailure = state.heldFailure; + state.heldFailure = undefined; + if (heldUpdateFailure !== undefined) yield heldUpdateFailure; + if (updated !== "completed") return; + const updateSpendReason = describeStageOverspend(harnessConfig, state.lastStageUsage, stage); + if (updateSpendReason) { + yield failedEvent(runId, updateSpendReason); + return; + } + } + + // The one backward edge in an otherwise forward-only chain, and it + // exists because `verify` is the only stage that produces a + // machine-checked statement that earlier work is unfinished: it + // writes each declared check's result onto that task's own + // checkbox, so a failing check unchecks the task. Walking on to + // `archive` from here means being refused by it — the chain would + // detect unfinished work correctly and then stop with an error + // instead of finishing it. + if (stage === "verify") { + const applyIndex = sequence.findIndex((item) => item.kind === "stage" && item.stage === "apply"); + const tasks = await countTasks(context.changeDir); + const applyMaxAttempts = harnessConfig.maxStageAttempts ?? 1; + // Nothing configured means one attempt, which means no return is + // possible — and then this must stay out of the way entirely, so + // `archive` refuses exactly as it always has. Reporting a + // different failure here would change what every existing + // configuration does, which is the regression this guard prevents. + if (tasks && tasks.unchecked > 0 && applyMaxAttempts > 1) { + const applyAttempts = state.attemptsByStage.get("apply") ?? 0; + const unfinished = await unfinishedTaskTexts(cwd, changeName); + if (applyIndex === -1 || applyAttempts >= applyMaxAttempts) { + // Named, not counted. The reader is about to take this over, + // and `archive`'s own refusal already tells them how many — + // which of them is what costs a file to find out. + yield failedEvent( + runId, + applyIndex === -1 + ? say("OSW-RUN-101", { count: tasks.unchecked, tasks: unfinished }) + : say("OSW-RUN-102", { count: tasks.unchecked, attempts: applyMaxAttempts, tasks: unfinished }), + ); + return; + } + state.returnReason = `verification left ${tasks.unchecked} task(s) unchecked`; + index = applyIndex - 1; // the loop's own increment moves it to `apply` + continue; + } + } + if (!hasNextStage) return; + + if (stage === "archive") { + archivedSummary = `archived ${changeName}`; + if (nextStageAfter(sequence, index + 1) === "git" && !shouldRunGitAfterArchive) { + // Ending here is right when `git` is all that remains, which is + // every chain that declares no step after `archive`. Where a + // declared step does remain, only the git stage is skipped — + // returning would silently drop a step the change asked for, + // which is the "setting nothing reads" this project refuses. + if (!sequence.slice(index + 1).some((later) => later.kind === "step")) { + yield { kind: "completed", runId, timestamp: nowIso(), summary: archivedSummary }; + return; + } + gitStageSkipped = true; + } + } + + if (state.cancelRequested) { + yield { kind: "cancelled", runId, timestamp: nowIso(), ...cancelledBy(state) }; + return; + } + + // Names the next PART, step or stage alike: a chain that announced + // "next: archive" while a declared wait stood between them would be + // describing a sequence it is not about to run. + const nextEntry = sequence[index + 1]; + // Unreachable: `hasNextStage` above already returned otherwise. + if (nextEntry === undefined) return; + const nextStage = nextEntry.kind === "stage" ? nextEntry.stage : nextEntry.declaration.step; + const requireConfirmation = harnessConfig.autonomyLevel === "semi-autonomous" + && harnessConfig.checkpoints?.requireConfirmationBetweenSteps !== false; + + if (requireConfirmation) { + // `state.pendingCheckpoint` is registered BEFORE yielding the + // checkpoint event, not after: a real consumer (or this module's + // own tests) reacts to a yielded event synchronously, before this + // generator gets a chance to resume and run any code that comes + // after the `yield`. Registering the resolver first closes that + // window — `confirmCheckpoint()`/`cancel()` called the instant the + // event is observed will always find a pending resolver to settle, + // rather than racing a promise that has not been constructed yet. + const checkpointPromise = new Promise((resolve) => { + state.pendingCheckpoint = { resolve }; + }); + yield { + kind: "checkpoint", + runId, + timestamp: nowIso(), + stage, + nextStage, + // "archive"/"git" have no agent (mechanical, or a dedicated + // non-agent sequence) — "" reads as "no agent required for the + // next stage", not "unknown". Reused from harness-config.ts + // rather than re-listing the two stage names here — see + // harness-step-agent.ts's `HarnessStepAgentStage` comment for + // why they are kept on one list, together. + nextAgentId: stageAgentOf(nextStage, harnessConfig, state), + }; + const checkpointOutcome = await checkpointPromise; + if (checkpointOutcome === "cancelled") { + yield { kind: "cancelled", runId, timestamp: nowIso(), ...cancelledBy(state) }; + return; + } + if (checkpointOutcome === "stopped") { + yield* this.announceStop(command, state); + yield { kind: "cancelled", runId, timestamp: nowIso() }; + return; + } + } else { + yield { kind: "stageCompleted", runId, timestamp: nowIso(), stage, nextStage }; + } + // A stop asked for between stages — while a mechanical stage ran, or + // as a stage handed over — starts no further stage. + if (state.stopRequest !== undefined) { + yield* this.announceStop(command, state); + yield { kind: "cancelled", runId, timestamp: nowIso() }; + return; + } + } + + // Reached only when the last part of the sequence was a declared step + // (ADR 0021): a stage that is last returns out of the loop above, + // having yielded its own terminal event. A step does not, because it + // is not the thing whose completion the chain reports — so without + // this, a chain ending in a step would end with no terminal event at + // all, and every consumer would be left waiting on a run that had + // finished. + yield { + kind: "completed", + runId, + timestamp: nowIso(), + summary: archivedSummary ?? `finished ${changeName}`, + }; + } + + /** Returns a failure reason naming the budget, or `undefined` when the + * stage is free to start. `undefined` whenever there is nothing to + * check against: no `budget` configured, no `listAuditEntries` + * dependency supplied, or no recorded usage yet for this change — see + * spec.md, "A configured budget stops work at stage boundaries" and + * task 8.5 (runs with no `usage` contribute nothing to the total, so a + * change whose runs are all unmeasured never trips the ceiling). */ + /** Milliseconds until the running stage must be cut, or `undefined` + * when nothing bounds it. The smaller of the stage ceiling and what is + * left of the run ceiling — whichever would fire first is the one that + * does, so a stage never outlives the chain's own limit. */ + private stageDeadlineMs(config: HarnessConfig, state: ChainState): number | undefined { + const candidates: number[] = []; + const stageSeconds = config.timeout?.maxStageSeconds; + if (stageSeconds !== undefined) candidates.push(stageSeconds * 1000); + const runSeconds = config.timeout?.maxRunSeconds; + if (runSeconds !== undefined) candidates.push(Math.max(0, runSeconds * 1000 - state.elapsedMs)); + return candidates.length === 0 ? undefined : Math.min(...candidates); + } + + private async checkBudget(harnessConfig: HarnessConfig, changeDir: string): Promise { + const budget = harnessConfig.budget; + const perUnit = Object.entries(budget?.maxCost ?? {}); + if (!budget || (budget.maxCostUsd === undefined && budget.maxTokens === undefined && perUnit.length === 0)) { + return undefined; + } + if (!this.deps.listAuditEntries) return undefined; + + const entries = await this.deps.listAuditEntries(); + const total = totalForChange(buildUsageReport(entries).totalsByChange, changeDir); + if (!total) return undefined; + + if (budget.maxCostUsd !== undefined && total.costUsd >= budget.maxCostUsd) { + return say("OSW-RUN-201", { spent: total.costUsd.toFixed(2), ceiling: budget.maxCostUsd.toFixed(2) }); + } + if (budget.maxTokens !== undefined) { + const totalTokens = total.inputTokens + total.outputTokens; + if (totalTokens >= budget.maxTokens) { + return say("OSW-RUN-202", { spent: totalTokens, ceiling: budget.maxTokens }); + } + } + // A ceiling per unit of account, each against its own unit's total: + // nothing is converted and nothing is summed across units + // (a-run-budget-has-a-unit). + for (const [unit, ceiling] of perUnit) { + const spent = total.costByUnit[unitKey(unit)] ?? 0; + if (spent >= ceiling) { + return say("OSW-RUN-203", { spent, unit, ceiling }); + } + } + return undefined; + } + + /** Best-effort start of the "apply" stage's own checkpoint, so its delta + * can later be handed to "verify" — never lets checkpointing stop the + * chain: a size-limit error (`captureCheckpoint` throws when the + * workspace exceeds its configured limits) or any other failure here + * just means "verify" runs without a delta, exactly as if this + * capability didn't exist (see security.ts's absent-field path). */ + private async captureApplyCheckpoint(cwd: string): Promise { + try { + return await captureCheckpoint(cwd); + } catch { + return undefined; + } + } + + /** Turns a finalized "apply" checkpoint into the `VerifiedDeltaEntry[]` + * shape `security.ts` renders into the "verify" stage's prompt — content + * comes from the checkpoint's own before/after snapshots, never from + * `GitWrapper.diff()` (see design.md's rejected alternative). Best-effort + * for the same reason `captureApplyCheckpoint` is, and says whether the + * checkpoint could be read: "no change" and "could not tell" are different + * facts, and only the first may stop a chain + * (an-apply-that-ticks-nothing-ends-the-chain). */ + private async readApplyCheckpoint(checkpoint: WorkbenchCheckpoint): Promise<{ read: boolean; delta: VerifiedDeltaEntry[] | undefined }> { + try { + const delta = await finalizeCheckpoint(checkpoint); + if (delta.length === 0) return { read: true, delta: undefined }; + return { + read: true, + delta: delta.map((entry) => ({ + path: entry.path, + kind: entry.kind, + before: checkpoint.before.get(entry.path)?.content.toString("utf8"), + after: checkpoint.after?.get(entry.path)?.content.toString("utf8"), + })), + }; + } catch { + return { read: false, delta: undefined }; + } + } + + /** Runs one stage to its own terminal outcome. For an intermediate stage + * (`hasNextStage`), the stage's own raw `"completed"` event is + * deliberately swallowed rather than forwarded — per ADR 0012, + * `"completed"`/`"failed"`/`"cancelled"` are reserved, for a chain run, + * for when the WHOLE CHAIN ends, not each stage; the caller replaces a + * swallowed `"completed"` with `"checkpoint"`/`"stageCompleted"`. A + * `"failed"`/`"cancelled"` stage outcome always ends the whole chain, so + * it is forwarded as-is regardless of `hasNextStage`. */ + /** What the supervisor does about a stage whose agent failed + * (the-supervisor-changes-agents, design.md decision 5). Under `act`, with + * an attempt left and a repeat or a move its policy allows, it says so, + * records it, announces the next attempt and returns `true`; the attempt + * loop goes on. Otherwise it yields the failure, unchanged, and returns + * `false` — under `act` after saying why the stage was not tried again. */ + private *superviseHeldFailure( + stage: ChainStage, + failure: Extract, + harnessConfig: HarnessConfig, + state: ChainState, + command: Command, + attempt: number, + maxAttempts: number, + ): Generator { + const { runId, cwd, context } = command; + const supervisor = resolveSupervisor(harnessConfig.supervisor); + // A person's cancel or stop, or a stage that already spent past its + // ceiling, ends the chain as it did before `act` existed. + if ( + supervisor.mode !== "act" + || !isHarnessStepAgentStage(stage) + || state.cancelRequested + || state.stopRequest !== undefined + || describeStageOverspend(harnessConfig, state.lastStageUsage, stage) !== undefined + ) { + yield failure; + return false; + } + const current = stageAgentOf(stage, harnessConfig, state) || DEFAULT_AGENT_ID; + const decision = superviseFailure({ + stage, + current, + tried: state.triedAgents.get(stage) ?? [current], + diagnosis: failure.diagnosis, + supervisor, + }); + const goesOn = decision.action !== "none" && attempt < maxAttempts; + const because = decision.action === "none" + ? decision.why + : goesOn + ? decision.because + : `${decision.because}, but ${stage} has had ${attempt} attempt(s), the maximum configured (maxStageAttempts: ${maxAttempts})`; + const action = goesOn ? decision.action : "none"; + const to = goesOn && decision.action === "move" ? decision.agent : undefined; + const message = action === "move" + ? `the supervisor moved ${stage} from ${current} to ${to}: ${because}` + : action === "repeat" + ? `the supervisor repeats ${stage} on ${current}: ${because}` + : `the supervisor did not try ${stage} again: ${because}`; + yield { kind: "progress", runId, timestamp: nowIso(), message }; + this.deps.auditLog?.record({ + runId, + agent: SUPERVISOR_AGENT_NAME, + outcome: "message", + cwd, + timestamp: nowIso(), + changeDir: context.changeDir, + stage, + reason: message, + supervisorDecision: { action, stage, from: current, ...(to !== undefined ? { to } : {}), because }, + }); + if (!goesOn) { + yield failure; + return false; + } + if (to !== undefined) state.agentOverride.set(stage, to); + yield { + kind: "stageStarted", + runId, + timestamp: nowIso(), + stage, + agentId: stageAgentOf(stage, harnessConfig, state), + attempt: attempt + 1, + previousAttemptReason: message, + }; + return true; + } + + private async *runStage( + stage: ChainStage, + hasNextStage: boolean, + harnessConfig: HarnessConfig, + command: Command, + state: ChainState, + verifiedDelta: VerifiedDeltaEntry[] | undefined, + /** The command the stage runs instead of its own: `update`, where a + * review asked for changes (ADR 0041). */ + kindOverride?: CommandKind, + ): AsyncGenerator { + const { cwd, context, runId } = command; + + if (stage === "git") { + return yield* this.runGitStage(command, harnessConfig, hasNextStage); + } + + if (stage === "archive") { + // The archive stage is irreversible, and a stage exiting successfully + // is not evidence the work was done — an agent process can exit `0` + // having changed nothing. Refuse on anything short of "every task + // checked", including a task count that cannot be read at all. + const tasks = await countTasks(context.changeDir); + if (!tasks || tasks.unchecked > 0) { + const changeName = changeNameFromDir(context.changeDir); + // Named, not only counted — the same reasoning, and the same + // function, as the return from verification: whoever reads this is + // about to take the work over. See a-done-task-is-ticked. + const unfinished = tasks ? await unfinishedTaskTexts(cwd, changeName) : ""; + yield failedEvent( + runId, + tasks + ? `cannot archive "${changeName}": ${tasks.unchecked} task(s) still unchecked (${unfinished}); complete or verify them, then archive` + : `cannot archive "${changeName}": its tasks.md could not be read, so task completion is unknown; verify the change, then archive`, + ); + return "failed"; + } + try { + await archiveChange(changeNameFromDir(context.changeDir), { cwd }); + } catch (error) { + yield failedEvent(runId, error instanceof Error ? error.message : String(error)); + return "failed"; + } + if (!hasNextStage) { + yield { kind: "completed", runId, timestamp: nowIso(), summary: `archived ${changeNameFromDir(context.changeDir)}` }; + } + return "completed"; + } + + let verifyCheckOutcome: MechanicalCheckRunOutcome | undefined; + if (stage === "verify") { + // Mechanical checks (task-checklist.ts's `check` declarations) run + // BEFORE the verifying agent — task 3.1/3.3. A failure here skips + // the agent entirely: asking a model to review work that a + // mechanical check already found broken spends a run to learn what + // an exit code already said. A change whose tasks.md declares no + // checks (`ranAny: false`) falls straight through unchanged — task + // 3.5. + try { + verifyCheckOutcome = await runMechanicalChecksForVerify(cwd, context.changeDir); + // Recorded before the gate below, which returns without invoking + // the verifying agent — so the failing case, which records + // nothing today, is exactly the one this must not miss. + this.recordVerifyChecks(command, verifyCheckOutcome, harnessConfig, state); + } catch (error) { + yield failedEvent(runId, error instanceof Error ? error.message : String(error)); + return "failed"; + } + if (verifyCheckOutcome.failed.length > 0) { + // Recorded, not reported. A failing check is the clearest + // statement this chain can produce that earlier work is + // unfinished — it is what unchecks the task in the first place — + // so where another `apply` attempt is available the work goes + // back rather than the chain ending. Only the loop knows whether + // one is, so it yields the event: see verify-sends-work-back tasks.md section 6. + state.checkFailureSummary = verifyCheckOutcome.failed + .map((entry) => `${entry.check.name}${entry.check.param ? `(${entry.check.param})` : ""}: ${entry.result.reason}`) + .join("; "); + return "checks-failed"; + } + } + + // A stage the supervisor moved runs its new agent with that agent's own + // defaults: the old entry's model, effort, budget and custom agent were + // chosen for another agent (the-supervisor-changes-agents). + const stepAgent = harnessConfig.stepAgents[stage]; + const movedTo = state.agentOverride.get(stage); + const { agent: agentId, model, effort, budget, customAgent } = movedTo !== undefined + ? { agent: movedTo, model: undefined, effort: undefined, budget: undefined, customAgent: undefined } + : stepAgent === undefined + ? { agent: undefined, model: undefined, effort: undefined, budget: undefined, customAgent: undefined } + : normalizeStepAgent(stepAgent); + const runner = this.deps.resolveRunner(agentId, cwd); + if (!runner) { + yield failedEvent(runId, say("OSW-AGT-221", { stage })); + return "failed"; + } + const ranOn = agentId ?? DEFAULT_AGENT_ID; + const tried = state.triedAgents.get(stage) ?? []; + if (!tried.includes(ranOn)) state.triedAgents.set(stage, [...tried, ranOn]); + + // Only the "verify" stage's context carries a delta and/or an + // "established checks" section — every other stage keeps the exact + // same `context` object the top-level command was given, so its + // prompt stays byte-identical to before this stage existed (see + // security.ts, buildVerifiedDeltaSection's absent-field path). + let stageContext: CommandContext = context; + if (stage === "verify") { + if (verifiedDelta) stageContext = { ...stageContext, verifiedDelta }; + if (verifyCheckOutcome && verifyCheckOutcome.ranAny && verifyCheckOutcome.passed.length > 0) { + const establishedSection = buildEstablishedChecksSection(verifyCheckOutcome.passed); + stageContext = { + ...stageContext, + promptContext: stageContext.promptContext + ? `${stageContext.promptContext}\n\n${establishedSection}` + : establishedSection, + }; + } + } + // What the operator said while the run was working, handed over now + // because a stage's start is the only way words reach an agent + // (the-operator-can-say-something-to-a-run). + const taken = state.pendingMessages.splice(0, state.pendingMessages.length); + if (taken.length > 0) { + const section = buildOperatorMessagesSection(taken); + stageContext = { + ...stageContext, + promptContext: stageContext.promptContext ? `${stageContext.promptContext}\n\n${section}` : section, + }; + for (const message of taken) { + this.deps.auditLog?.record({ + runId, + agent: "chain", + outcome: "message", + cwd, + timestamp: nowIso(), + changeDir: context.changeDir, + operatorMessage: { messageId: message.messageId, kind: message.kind, from: message.from, stage }, + }); + } + state.awaitingAnswer = taken.filter((message) => message.kind === "ask"); + } else { + state.awaitingAnswer = []; + } + + // `stage` travels beside `model`/`effort`/`budget`, which the chain + // already sets here — it is what lets an audit entry say which stage + // spent what, since every stage runs under the chain's own runId. + const stageCommand: Command = { kind: kindOverride ?? CHAIN_STAGE_COMMAND[stage], cwd, context: stageContext, runId, agentId, model, effort, budget, customAgent, stage }; + state.currentRunner = runner; + state.currentCommand = stageCommand; + + // Set once a permission request has been intercepted under + // `"autonomous"` (task 4.1) — from that point on, the stage's own + // `"failed"`/`"cancelled"` terminal events are suppressed, so the + // chain reports the one outcome this interception already yielded, + // not a `"failed"` followed by the `"cancelled"` produced by ending + // the process below (see design.md, "Under autonomous, the stage + // fails and the process is ended", step 4). + let autonomousPermissionFailure = false; + let outcome: "completed" | "failed" | "cancelled" = "completed"; + /** What the agent said at the end of this stage, for an answer: the + * stage's closing summary where it has one, and otherwise the tail of + * what it streamed. */ + let said: string | undefined; + let saidTail = ""; + const remember = (text: string): void => { + if (text.trim().length === 0) return; + saidTail = `${saidTail}${text}`.slice(-ANSWER_WORDS_LIMIT); + }; + // A stop asked for while this stage runs ends it at a sound point + // (a-change-is-run-from-its-card). Where is decided by + // `untilStopBoundary`, the same way for a chain's stage as for a + // single-stage run. + const events = untilStopBoundary({ + events: runner.run(stageCommand), + changeDir: context.changeDir, + stopAsked: () => state.stopRequest !== undefined, + stopAfterTask: () => state.heldStop?.afterTask, + onStopAfterDue: (due) => this.heldStopIsDue(command, state, due), + onWake: (wake) => { + state.stopWake = wake; + }, + announce: () => this.announceStop(command, state), + denyPermission: (requestId) => drainInBackground(runner.run({ + ...stageCommand, + kind: "resolvePermission", + permissionRequestId: requestId, + permissionOutcome: "deny", + })), + endRun: () => drainInBackground(runner.run({ ...stageCommand, kind: "cancel" })), + // Under `autonomous` a permission request fails the stage below, as it + // always has. + mayDenyPermissions: harnessConfig.autonomyLevel !== "autonomous", + }); + try { + for await (const event of events) { + // A wait for the operator's answers begins with `awaitingAnswers` + // and ends with whatever the stage does next (ADR 0042). + if (event.kind === "awaitingAnswers") { + state.operatorWaitSince ??= Date.now(); + } else if (state.operatorWaitSince !== undefined && event.kind !== "question" && event.kind !== "questionAnswered") { + state.operatorWaitMs += Date.now() - state.operatorWaitSince; + state.operatorWaitSince = undefined; + } + if (event.kind === "permissionRequest" && harnessConfig.autonomyLevel === "autonomous" && !autonomousPermissionFailure) { + autonomousPermissionFailure = true; + outcome = "failed"; + yield event; + yield failedEvent(runId, say("OSW-PRM-101", { request: event.description })); + // Ends the stage's process rather than abandoning the generator + // (the rejected alternative in design.md) — `break`ing here would + // leave the ACP driver's permission handler parked on a promise + // nobody can resolve, and the CLI subprocess would outlive the + // chain. + const cancelCommand: Command = { ...stageCommand, kind: "cancel" }; + void (async () => { + try { + for await (const _event of runner.run(cancelCommand)) { + // Draining only — same shape as `cancel()`/`resolvePermission()`. + } + } catch { + // Must not become an unhandled rejection. + } + })(); + continue; + } + if (autonomousPermissionFailure) continue; + if (event.kind === "completed") { + outcome = "completed"; + // What the review said of the plan, for the update that may + // follow it (ADR 0041). + if (stage === "review" && kindOverride === undefined) state.reviewVerdict = event.reviewVerdict; + // What this stage said, kept for a question that is waiting on + // it (the-operator-can-say-something-to-a-run). + if (typeof event.summary === "string" && event.summary.trim().length > 0) said = event.summary.trim(); + if (hasNextStage) continue; + yield event; + continue; + } + if (event.kind === "stdout") remember(event.chunk); + if (event.kind === "agentUpdate") { + remember(readAcpStreamedText(event.update)?.text ?? ""); + // The one ceiling besides `timeout` that can stop a stage + // already running: the gauge arrives during the run, where a + // cost only arrives at its end (a-run-can-outgrow-its-context). + const ceiling = harnessConfig.budget?.maxContextShare; + const gauge = ceiling === undefined ? undefined : readAcpContextGauge(event.update); + if (ceiling !== undefined && gauge !== undefined && gauge.share > ceiling && state.cancelReason === undefined) { + state.cancelReason = describeContextShare(gauge, ceiling); + state.cancelCode = undefined; + this.cancel(runId); + } + } + if (event.kind === "usageReported") state.lastStageUsage = event.usage; + if (event.kind === "failed") { + // Held, not yielded: only the attempt loop knows whether the + // supervisor tries the stage again, and a stage that goes on must + // not have said `failed` (the-supervisor-changes-agents). + outcome = "failed"; + state.heldFailure = event; + continue; + } + if (event.kind === "cancelled") { + outcome = "cancelled"; + // The runner reports that its process is gone; only the chain + // knows a rule caused that rather than a person, so the reason + // is attached here rather than invented downstream. + yield state.cancelReason ? { ...event, ...cancelledBy(state) } : event; + continue; + } + yield event; + } + } finally { + state.stopWake = undefined; + state.currentRunner = undefined; + state.currentCommand = undefined; + } + await this.answerWhatWasAsked(state, stage, runId, said ?? (saidTail.trim() || undefined), outcome); + return outcome; + } + + /** Answers every question this stage carried, with what the agent said. + * + * There is no separate turn and no second model call: the question was in + * the prompt, and the stage's own closing words are the reply. A stage + * that said nothing answers with how it ended, which is still an answer - + * silence would leave a person waiting for one that never comes. */ + private async answerWhatWasAsked( + state: ChainState, + stage: ChainStage, + runId: string, + said: string | undefined, + outcome: "completed" | "failed" | "cancelled", + ): Promise { + const asked = state.awaitingAnswer.splice(0, state.awaitingAnswer.length); + if (asked.length === 0 || this.deps.answerMessage === undefined) return; + const words = said ?? `the ${stage} stage ended ${outcome} without a closing summary`; + for (const question of asked) { + try { + await this.deps.answerMessage({ to: question.fromKeyId, answers: question.messageId, words, stage, runId }); + } catch { + // An answer that cannot be written must not end the run. The audit + // says the question was delivered; the person can look there. + } + } + } + + private async shouldRunGitStage(workspaceRoot: string, changeName: string): Promise { + const changeOverride = await readChangeHarnessConfig(workspaceRoot, changeName); + return changeOverride?.reviewGate?.mode === "agent-sufficient"; + } + + private buildGitStageAllowlist(harnessConfig: HarnessConfig): AllowlistConfig { + const remotes = harnessConfig.gitStageAllowlist?.remotes ?? []; + const branches = harnessConfig.gitStageAllowlist?.branches ?? []; + return { + [GIT_STAGE_AGENT_NAME]: [ + { + executable: "git", + argsAllowed: (args: string[]) => { + if (args[0] !== "push") return false; + const remote = args[1] ?? ""; + const branch = args[2] ?? ""; + return matchesPattern(remote, remotes) && matchesPattern(branch, branches); + }, + }, + { + executable: "gh", + argsAllowed: (args: string[]) => { + if (args[0] !== "pr") return false; + if (args[1] === "create") { + const headIndex = args.indexOf("--head"); + const baseIndex = args.indexOf("--base"); + const headBranch = headIndex >= 0 ? args[headIndex + 1] ?? "" : ""; + const baseBranch = baseIndex >= 0 ? args[baseIndex + 1] ?? "" : ""; + return matchesPattern(DEFAULT_GIT_REMOTE, remotes) + && matchesPattern(headBranch, branches) + && matchesPattern(baseBranch, branches); + } + if (args[1] === "merge") { + return matchesPattern(DEFAULT_GIT_REMOTE, remotes); + } + return false; + }, + }, + ], + }; + } + + /** Runs one declared step, reporting it on the chain's own timeline. + * Returns `false` when the chain must stop. + * + * The time a waiting step spends is deliberately NOT added to + * `state.elapsedMs`: a chain waiting for something outside itself is + * not consuming anything, exactly as a chain paused at a checkpoint is + * not, and counting it would fire the run-time ceiling on chains + * behaving exactly as they were configured. Each wait carries its own + * maximum duration instead, so a chain still never waits forever. */ + private async *runDeclaredStep( + declaration: HarnessChainStep, + command: Command, + state: ChainState, + changeName: string, + ): AsyncGenerator { + const { runId, cwd } = command; + yield { + kind: "stageStarted", + runId, + timestamp: nowIso(), + // A step names itself in the field a stage names itself in — one + // timeline, so a surface that renders stages renders this with no + // new event kind to learn. + stage: declaration.step, + // The convention `archive` and `git` already use for a part of the + // chain that invokes no agent, rather than a second spelling. + agentId: "", + }; + + const startedAt = Date.now(); + let result; + try { + result = await runChainStep(declaration.step, declaration.param, { + workspaceRoot: cwd, + changeName, + maxWaitMs: declaration.maxWaitSeconds !== undefined + ? declaration.maxWaitSeconds * 1000 + : DEFAULT_CHAIN_STEP_MAX_WAIT_MS, + }); + } catch (error) { + yield failedEvent(runId, `step "${declaration.step}" failed: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + + if (!isWaitingStep(declaration.step)) { + state.elapsedMs += Date.now() - startedAt; + } + + if (!result.ok) { + yield failedEvent(runId, `step "${declaration.step}" did not succeed: ${result.reason}`); + return false; + } + + yield { kind: "progress", runId, timestamp: nowIso(), message: `${declaration.step}: ${result.reason}` }; + return true; + } + + /** Records what `verify`'s declared checks found. + * + * Written by this runner rather than by an agent run, for the reason + * `recordGitAction` exists: mechanical work that no agent performed + * still belongs in the record. And it matters most in the case that + * records nothing today — a `verify` whose checks failed never invokes + * the verifying agent, so the run that found the most left no trace. + * + * A change declaring no checks records nothing at all: an entry saying + * none ran is indistinguishable from one saying none failed. + * + * `agent` names the writer, which is this runner — so the entry also + * carries `checkedAgent`, the agent whose work these checks examined. + * Without it the only thing a per-agent readback could group by was + * the pseudo-agent, and it produced one row naming nobody however many + * agents had run. See + * quality-is-charged-to-the-agent-whose-work-was-checked. */ + private recordVerifyChecks( + command: Command, + outcome: MechanicalCheckRunOutcome, + harnessConfig: HarnessConfig, + state: Pick, + ): void { + if (!outcome.ranAny) return; + const failures = outcome.failed + .map((entry) => `${entry.check.name}${entry.check.param ? `(${entry.check.param})` : ""}: ${entry.result.reason}`) + .join("; "); + this.deps.auditLog?.record({ + runId: command.runId, + agent: VERIFY_CHECKS_AGENT_NAME, + outcome: outcome.failed.length === 0 ? "completed" : "failed", + cwd: command.cwd, + timestamp: nowIso(), + changeDir: command.context.changeDir, + stage: "verify", + checkedAgent: resolvedApplyAgent(harnessConfig, state), + checksRan: outcome.passed.length + outcome.failed.length, + checksFailed: outcome.failed.length, + ...(failures.length > 0 ? { reason: failures } : {}), + }); + } + + private recordGitAction( + command: Command, + invocation: AdapterInvocation, + outcome: "blocked" | "started" | "completed" | "failed", + details?: { reason?: string; summary?: string }, + ): void { + this.deps.auditLog?.record({ + runId: command.runId, + agent: GIT_STAGE_AGENT_NAME, + outcome, + cwd: command.cwd, + timestamp: nowIso(), + changeDir: command.context.changeDir, + invocation, + reason: details?.reason, + summary: details?.summary, + }); + } + + private async *runGitStage( + command: Command, + harnessConfig: HarnessConfig, + hasNextStage: boolean, + ): AsyncGenerator { + const allowlistRules = harnessConfig.gitStageAllowlist; + if (!allowlistRules) { + yield failedEvent(command.runId, "git stage requires a per-change gitStageAllowlist"); + return "failed"; + } + + const git = (this.deps.createGitWrapper ?? createGitWrapper)({ cwd: command.cwd }); + // The forge `origin` is on: GitHub, GitLab or Gitea (github-without-gh). + const prGateway = this.deps.createPullRequestGateway + ? this.deps.createPullRequestGateway({ cwd: command.cwd }) + : await pullRequestGatewayFor(command.cwd); + const allowlist = this.buildGitStageAllowlist(harnessConfig); + + const branch = await git.currentBranch(); + if (!branch) { + yield failedEvent(command.runId, "git stage failed: could not resolve current branch"); + return "failed"; + } + + // What the stages left uncommitted goes with the push: a push of a + // branch that holds none of the change's work leaves the server without + // it (a-change-is-committed-where-it-is-made). Never on the default + // branch, where nothing of a change is committed (ADR 0043). + if (branch !== DEFAULT_PR_BASE_BRANCH && branch !== "master") { + const changeName = changeNameFromDir(command.context.changeDir); + const message = `${changeName}: commit what the stages left`; + const commitInvocation: AdapterInvocation = { kind: "process", executable: "git", args: ["commit", "-m", message] }; + try { + const commit = await (this.deps.commitWhatIsLeft ?? commitWhatIsLeft)(command.cwd, message); + if (commit !== undefined) this.recordGitAction(command, commitInvocation, "completed", { summary: `committed ${commit.slice(0, 8)}` }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.recordGitAction(command, commitInvocation, "failed", { reason }); + yield failedEvent(command.runId, say("OSW-GIT-103", { why: reason })); + return "failed"; + } + } + + const pushInvocation: AdapterInvocation = { + kind: "process", + ...buildGitPushInvocation(DEFAULT_GIT_REMOTE, branch), + }; + const pushDecision = checkAllowlist(GIT_STAGE_AGENT_NAME, pushInvocation, allowlist); + if (!pushDecision.allowed) { + this.recordGitAction(command, pushInvocation, "blocked", { reason: pushDecision.reason }); + yield failedEvent(command.runId, `git stage failed at push: ${pushDecision.reason ?? "blocked by allowlist"}`); + return "failed"; + } + this.recordGitAction(command, pushInvocation, "started"); + try { + await git.push(DEFAULT_GIT_REMOTE, branch); + this.recordGitAction(command, pushInvocation, "completed", { summary: `pushed ${branch}` }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.recordGitAction(command, pushInvocation, "failed", { reason }); + yield failedEvent(command.runId, `git stage failed at push: ${reason}`); + return "failed"; + } + + const createInvocation: AdapterInvocation = { + kind: "process", + ...buildGhPrCreateInvocation(branch, DEFAULT_PR_BASE_BRANCH), + }; + const createDecision = checkAllowlist(GIT_STAGE_AGENT_NAME, createInvocation, allowlist); + if (!createDecision.allowed) { + this.recordGitAction(command, createInvocation, "blocked", { reason: createDecision.reason }); + yield failedEvent(command.runId, `git stage failed at pull-request creation: ${createDecision.reason ?? "blocked by allowlist"}`); + return "failed"; + } + + this.recordGitAction(command, createInvocation, "started"); + let pr: { number: number; url: string }; + try { + pr = await prGateway.createPullRequest(branch, DEFAULT_PR_BASE_BRANCH); + this.recordGitAction(command, createInvocation, "completed", { summary: `created PR ${pr.url}` }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.recordGitAction(command, createInvocation, "failed", { reason }); + yield failedEvent(command.runId, `git stage failed at pull-request creation: ${reason}`); + return "failed"; + } + + let checks; + try { + checks = await prGateway.waitForChecks(pr.number); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + yield failedEvent(command.runId, `git stage failed while checking pull-request checks: ${reason}`); + return "failed"; + } + if (checks.state !== "pass") { + const suffix = checks.reason ? `: ${checks.reason}` : ""; + yield failedEvent(command.runId, `git stage failed at pull-request checks${suffix}`); + return "failed"; + } + + const mergeInvocation: AdapterInvocation = { + kind: "process", + ...buildGhPrMergeInvocation(pr.number), + }; + const mergeDecision = checkAllowlist(GIT_STAGE_AGENT_NAME, mergeInvocation, allowlist); + if (!mergeDecision.allowed) { + this.recordGitAction(command, mergeInvocation, "blocked", { reason: mergeDecision.reason }); + yield failedEvent(command.runId, `git stage failed at pull-request merge: ${mergeDecision.reason ?? "blocked by allowlist"}`); + return "failed"; + } + + this.recordGitAction(command, mergeInvocation, "started"); + try { + await prGateway.mergePullRequest(pr.number); + this.recordGitAction(command, mergeInvocation, "completed", { summary: `merged PR ${pr.url}` }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.recordGitAction(command, mergeInvocation, "failed", { reason }); + yield failedEvent(command.runId, `git stage failed at pull-request merge: ${reason}`); + return "failed"; + } + + if (!hasNextStage) { + yield { kind: "completed", runId: command.runId, timestamp: nowIso(), summary: `merged ${pr.url}` }; + } + return "completed"; + } +} diff --git a/packages/core/src/harness-config-findings.test.ts b/packages/core/src/harness-config-findings.test.ts index 94ca71ed..d2de6b73 100644 --- a/packages/core/src/harness-config-findings.test.ts +++ b/packages/core/src/harness-config-findings.test.ts @@ -26,6 +26,7 @@ describe("groupHarnessFindings", () => { kind: "ceiling-cannot-act", agent: "claude-cli", stages: ["propose", "review", "apply", "verify"], + code: "OSW-HRN-106", message: '"claude-cli" reports no usage at all, so no spending ceiling can act on "propose", "review", "apply" and "verify" however large the spend.', }]); }); @@ -84,6 +85,7 @@ describe("findHarnessConfigLimits", () => { const finding = findings.find((f) => f.stage === "apply"); expect(finding?.kind).toBe("ceiling-cannot-act"); + expect(finding?.code).toBe("OSW-HRN-108"); expect(finding?.message).toContain("reports tokens and no cost"); }); @@ -282,6 +284,7 @@ describe("findHarnessConfigLimits - an agent that edits no file", () => { expect(groupHarnessFindings(cannotWrite)).toEqual([expect.objectContaining({ agent: "local-llm", stages: ["propose", "verify"], + code: "OSW-HRN-103", message: expect.stringContaining('"local-llm" answers in text and edits no file, so "propose" and "verify" on it writes nothing'), })]); }); diff --git a/packages/core/src/harness-config-findings.ts b/packages/core/src/harness-config-findings.ts index bf69d2aa..9f4a30dc 100644 --- a/packages/core/src/harness-config-findings.ts +++ b/packages/core/src/harness-config-findings.ts @@ -14,6 +14,7 @@ import { HARNESS_AGENT_CAPABILITIES, isHarnessStepAgentStage, normalizeStepAgent } from "./harness-step-agent.js"; import type { HarnessConfig } from "./harness-config.js"; import { skipsStage, STAGES, type HarnessStage } from "./harness-stage.js"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; export type HarnessFindingKind = /** A configured ceiling has nothing to compare on this stage's agent. */ @@ -32,10 +33,17 @@ export interface HarnessFinding { kind: HarnessFindingKind; stage: HarnessStage; agent: string; + /** The register's identifier for the sentence below. */ + code: MessageCode; /** What cannot happen, and why — never what to do instead. */ message: string; } +/** One finding, its words from the register under their identifier. */ +function finding(kind: HarnessFindingKind, stage: HarnessStage, agent: string, said: SaidMessage): HarnessFinding { + return { kind, stage, agent, code: said.code, message: said.text }; +} + const DEFAULT_AGENT_LABEL = "the default agent"; function agentForStage(config: HarnessConfig, stage: HarnessStage): string | undefined { @@ -77,13 +85,7 @@ export function findHarnessConfigLimits(config: HarnessConfig): HarnessFinding[] // implementation, the ticked or unticked tasks - and an agent that only // answers in text leaves none (the-local-model-is-offered-where-it-is-set). if (stage !== "review" && HARNESS_AGENT_CAPABILITIES[agent]?.editsFiles === false) { - findings.push({ - kind: "agent-cannot-write", - stage, - agent, - message: `"${agent}" answers in text and edits no file, so "${stage}" on it writes nothing:` - + " the proposal, the implementation and the ticked tasks are files. It can review.", - }); + findings.push(finding("agent-cannot-write", stage, agent, say("OSW-HRN-103", { agent, stage }))); } // A context ceiling is answered by a different question from a @@ -95,60 +97,30 @@ export function findHarnessConfigLimits(config: HarnessConfig): HarnessFinding[] // has watched might send one, and warning about that would be a // guess dressed as a finding. if (contextCeiling !== undefined && HARNESS_AGENT_CAPABILITIES[agent]?.contextGauge === "none") { - findings.push({ - kind: "ceiling-cannot-act", - stage, - agent, - message: `"${agent}" never says how full its context is, so budget.maxContextShare cannot act on "${stage}".` - + " That ceiling reads a figure only an ACP agent sends.", - }); + findings.push(finding("ceiling-cannot-act", stage, agent, say("OSW-HRN-104", { agent, stage }))); } if (reports === "unknown") { - findings.push({ - kind: "reporting-unknown", - stage, - agent, - message: `"${agent}" has never been observed reporting its usage here, so whether a spending ceiling` - + ` can act on "${stage}" is not known. A time ceiling needs no report and applies regardless.`, - }); + findings.push(finding("reporting-unknown", stage, agent, say("OSW-HRN-105", { agent, stage }))); continue; } if (reports === "none") { if (hasCostCeiling || hasTokenCeiling) { - findings.push({ - kind: "ceiling-cannot-act", - stage, - agent, - message: `"${agent}" reports no usage at all, so no spending ceiling can act on "${stage}"` - + " however large the spend.", - }); + findings.push(finding("ceiling-cannot-act", stage, agent, say("OSW-HRN-106", { agent, stage }))); } // A context ceiling bounds a stage this agent runs where the agent // does send a gauge, so the stage is not unbounded after all. const contextBinds = contextCeiling !== undefined && HARNESS_AGENT_CAPABILITIES[agent]?.contextGauge === "sends"; if (!hasTimeCeiling && !contextBinds) { - findings.push({ - kind: "stage-unbounded", - stage, - agent, - message: `"${stage}" can run without any bound: "${agent}" reports nothing for a spending ceiling` - + " to compare, and no timeout is configured. A time ceiling is the only one that applies here.", - }); + findings.push(finding("stage-unbounded", stage, agent, say("OSW-HRN-107", { agent, stage }))); } continue; } if (reports === "tokens-only" && hasCostCeiling) { - findings.push({ - kind: "ceiling-cannot-act", - stage, - agent, - message: `"${agent}" reports tokens and no cost, so a cost ceiling cannot act on "${stage}".` - + " A token ceiling is the one that can.", - }); + findings.push(finding("ceiling-cannot-act", stage, agent, say("OSW-HRN-108", { agent, stage }))); } // A ceiling in a unit this stage is not billed in cannot act on it, @@ -159,25 +131,12 @@ export function findHarnessConfigLimits(config: HarnessConfig): HarnessFinding[] const billedIn = HARNESS_AGENT_CAPABILITIES[agent]?.budgetField === "maxAiCredits" ? "credits" : "usd"; for (const [unit] of perUnit) { if (unit.trim().toLowerCase() === billedIn) continue; - findings.push({ - kind: "ceiling-cannot-act", - stage, - agent, - message: `budget.maxCost sets a ceiling in "${unit}", and "${agent}" is billed in ${billedIn === "usd" ? "dollars" : "credits"},` - + ` so that ceiling cannot act on "${stage}". The ceiling in its own unit is the one that can.`, - }); + findings.push(finding("ceiling-cannot-act", stage, agent, say("OSW-HRN-109", { unit, agent, billing: billedIn === "usd" ? "dollars" : "credits", stage }))); } } if (reports === "cost-and-tokens" && hasTokenCeiling) { - findings.push({ - kind: "ceiling-rarely-acts", - stage, - agent, - message: `A token ceiling counts input plus output only, and "${agent}" moves most of its tokens` - + ` through cache — a measured run recorded 1,693,507 cache tokens against 8,322 counted ones, so` - + ` a token ceiling will rarely act on "${stage}". A cost ceiling acts on it directly.`, - }); + findings.push(finding("ceiling-rarely-acts", stage, agent, say("OSW-HRN-110", { agent, stage }))); } } @@ -191,6 +150,8 @@ export interface HarnessFindingGroup { agent: string; /** The stages, in the order they run. */ stages: HarnessStage[]; + /** The register's identifier for the sentence below. */ + code: MessageCode; /** The finding's own sentence, naming every stage in it. */ message: string; } @@ -209,13 +170,13 @@ export function groupHarnessFindings(findings: readonly HarnessFinding[]): Harne const template = finding.message.split(`"${finding.stage}"`).join(STAGE_SLOT); const same = groups.find((group) => group.kind === finding.kind && group.agent === finding.agent && group.template === template); if (same === undefined) { - groups.push({ kind: finding.kind, agent: finding.agent, stages: [finding.stage], message: finding.message, template }); + groups.push({ kind: finding.kind, agent: finding.agent, stages: [finding.stage], code: finding.code, message: finding.message, template }); continue; } same.stages.push(finding.stage); same.message = template.split(STAGE_SLOT).join(listOfStages(same.stages)); } - return groups.map(({ kind, agent, stages, message }) => ({ kind, agent, stages, message })); + return groups.map(({ kind, agent, stages, code, message }) => ({ kind, agent, stages, code, message })); } /** Where a stage's name stood in a finding's words; no finding writes it. */ diff --git a/packages/core/src/harness-config.test.ts b/packages/core/src/harness-config.test.ts index eb186d42..db13da93 100644 --- a/packages/core/src/harness-config.test.ts +++ b/packages/core/src/harness-config.test.ts @@ -2002,3 +2002,20 @@ describe("archive.whenLanded", () => { }); }); +// every-message-has-an-identifier: the validator's messages carry their +// register identifier beside the register words. +describe("InvalidHarnessConfigError identifiers", () => { + it("carries the register code beside the register words", async () => { + const root = await temporaryRoot(); + + const globalGate = new GlobalAgentSufficientReviewGateError(); + expect(globalGate.code).toBe("OSW-HRN-001"); + expect(globalGate.message).toBe( + "reviewGate.mode \"agent-sufficient\" is only valid in a per-change harness.json, never in the global openspec/agent-harness.json", + ); + + await expect(writeGlobalHarnessConfig(root, { budget: { maxCostUsd: 0 } })) + .rejects.toMatchObject({ code: "OSW-HRN-066" }); + }); +}); + diff --git a/packages/core/src/harness-config.ts b/packages/core/src/harness-config.ts index 77199427..f511f635 100644 --- a/packages/core/src/harness-config.ts +++ b/packages/core/src/harness-config.ts @@ -28,6 +28,7 @@ import { type HarnessStepBudget, type HarnessTaskAgents, } from "./harness-step-agent.js"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; export { STAGES, type HarnessStage }; export { @@ -156,18 +157,17 @@ export function resolveSupervisor(value: HarnessSupervisor | undefined): Resolve * 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 { +export function actWithoutAutonomousProblem(own: Partial | undefined): SaidMessage | 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\""; + return say("OSW-HRN-007"); } -export function supervisorActProblem(config: Pick): string | undefined { +export function supervisorActProblem(config: Pick): SaidMessage | undefined { if (config.supervisor?.mode !== "act") return undefined; const attempts = config.maxStageAttempts ?? 1; return attempts > 1 ? undefined - : `supervisor.mode "act" repeats or moves a failed stage as another attempt of it, and maxStageAttempts is ${attempts}: set it above 1 in this change's harness.json, or choose "advise"`; + : say("OSW-HRN-008", { attempts }); } /** A cost/token ceiling `HarnessChainRunner` checks before starting each @@ -404,14 +404,14 @@ const STEP_AGENT_STAGES: readonly HarnessStepAgentStage[] = STAGES.filter(isHarn function assertValidMaxCost(maxCost: unknown): void { if (maxCost === undefined) return; if (typeof maxCost !== "object" || maxCost === null || Array.isArray(maxCost)) { - throw new InvalidHarnessConfigError("budget.maxCost must be an object of unit to ceiling"); + throw new InvalidHarnessConfigError(say("OSW-HRN-012")); } for (const [unit, ceiling] of Object.entries(maxCost as Record)) { if (unit.trim().length === 0) { - throw new InvalidHarnessConfigError("budget.maxCost has a ceiling under an empty unit"); + throw new InvalidHarnessConfigError(say("OSW-HRN-013")); } if (!(typeof ceiling === "number" && Number.isFinite(ceiling) && ceiling > 0)) { - throw new InvalidHarnessConfigError(`budget.maxCost.${unit} must be a positive number`); + throw new InvalidHarnessConfigError(say("OSW-HRN-014", { unit: unit })); } } } @@ -501,7 +501,7 @@ function migrateLegacyDispatchInConfig(raw: unknown): { value: unknown; reports: if (dispatch === "vscode-chat") { const { dispatch: _unused, ...rest } = entryRecord; migratedStepAgents[stage] = { ...rest, agent: VSCODE_CHAT_STEP_AGENT_ID }; - reports.push(`stepAgents.${stage}.dispatch "vscode-chat" was migrated to stepAgents.${stage}.agent "${VSCODE_CHAT_STEP_AGENT_ID}"`); + reports.push(say("OSW-HRN-010", { stage, agent: VSCODE_CHAT_STEP_AGENT_ID }).text); anyMigrated = true; } } @@ -541,19 +541,22 @@ function dropNoAgentStepAgents(raw: unknown): { value: unknown; reports: string[ return { value: { ...rootRecord, stepAgents: rest }, reports: droppedStages.map( - (stage) => `stepAgents.${stage} was dropped — "${stage}" is a mechanical stage and never invokes an agent`, + (stage) => say("OSW-HRN-011", { stage }).text, ), }; } function reportDispatchMigration(filePath: string, reports: readonly string[]): void { if (reports.length === 0) return; - console.warn(`Migrated legacy harness dispatch settings in ${filePath}: ${reports.join("; ")}`); + console.warn(say("OSW-HRN-009", { file: filePath, reports: reports.join("; ") }).text); } export class InvalidHarnessConfigError extends Error { - constructor(reason: string) { - super(`Invalid harness config: ${reason}`); + /** The register's identifier for what went wrong beside the words. */ + readonly code: MessageCode; + constructor(said: SaidMessage) { + super(said.text); + this.code = said.code; this.name = "InvalidHarnessConfigError"; } } @@ -563,7 +566,7 @@ export class InvalidHarnessConfigError extends Error { * never a valid global setting". */ export class GlobalAgentSufficientReviewGateError extends InvalidHarnessConfigError { constructor() { - super('reviewGate.mode "agent-sufficient" is only valid in a per-change harness.json, never in the global openspec/agent-harness.json'); + super(say("OSW-HRN-001")); this.name = "GlobalAgentSufficientReviewGateError"; } } @@ -573,7 +576,7 @@ export class GlobalAgentSufficientReviewGateError extends InvalidHarnessConfigEr * `GlobalAgentSufficientReviewGateError`'s exact pattern. */ export class GlobalAutonomousAutonomyLevelError extends InvalidHarnessConfigError { constructor() { - super('autonomyLevel "autonomous" is only valid in a per-change harness.json, never in the global openspec/agent-harness.json'); + super(say("OSW-HRN-002")); this.name = "GlobalAutonomousAutonomyLevelError"; } } @@ -583,7 +586,7 @@ export class GlobalAutonomousAutonomyLevelError extends InvalidHarnessConfigErro * `GlobalAutonomousAutonomyLevelError`. */ export class GlobalCheckpointsDisabledError extends InvalidHarnessConfigError { constructor() { - super('checkpoints.requireConfirmationBetweenSteps: false is only valid in a per-change harness.json, never in the global openspec/agent-harness.json'); + super(say("OSW-HRN-003")); this.name = "GlobalCheckpointsDisabledError"; } } @@ -592,7 +595,7 @@ export class GlobalCheckpointsDisabledError extends InvalidHarnessConfigError { * the same per-change-only guard pattern as other high-impact settings. */ export class GlobalGitAllowlistError extends InvalidHarnessConfigError { constructor() { - super("gitStageAllowlist is only valid in a per-change harness.json, never in the global openspec/agent-harness.json"); + super(say("OSW-HRN-004")); this.name = "GlobalGitAllowlistError"; } } @@ -607,10 +610,7 @@ export class GlobalGitAllowlistError extends InvalidHarnessConfigError { * which is a different piece of work in each. */ export class GlobalTaskAgentsError extends InvalidHarnessConfigError { constructor() { - super( - "taskAgents is only valid in a per-change harness.json, never in the global openspec/agent-harness.json" - + " — a task number names a task of one change", - ); + super(say("OSW-HRN-005")); this.name = "GlobalTaskAgentsError"; } } @@ -622,10 +622,7 @@ export class GlobalTaskAgentsError extends InvalidHarnessConfigError { * for itself forever. See ADR 0021 decision 6. */ export class GlobalChainStepsError extends InvalidHarnessConfigError { constructor() { - super( - "steps is only valid in a per-change harness.json, never in the global openspec/agent-harness.json" - + " — a declared step belongs to the chain of one change", - ); + super(say("OSW-HRN-006")); this.name = "GlobalChainStepsError"; } } @@ -657,9 +654,7 @@ function assertValidAgentEntry(label: string, entry: unknown, autonomyLevel: Har (key) => !(STEP_AGENT_KEYS as readonly string[]).includes(key), ); if (unknownEntryKey !== undefined) { - throw new InvalidHarnessConfigError( - `${label} has unknown key "${unknownEntryKey}" (accepted keys: ${formatAcceptedKeys(STEP_AGENT_KEYS)})`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-015", { label: label, key: unknownEntryKey, keys: formatAcceptedKeys(STEP_AGENT_KEYS) })); } agentId = (entry as { agent?: unknown }).agent; model = (entry as { model?: unknown }).model; @@ -670,29 +665,25 @@ function assertValidAgentEntry(label: string, entry: unknown, autonomyLevel: Har } if (typeof agentId !== "string" || agentId.length === 0) { - throw new InvalidHarnessConfigError(`${label} must be a non-empty string`); + throw new InvalidHarnessConfigError(say("OSW-HRN-016", { label: label })); } if (!KNOWN_AGENT_IDS.has(agentId)) { - throw new InvalidHarnessConfigError(`${label} references unknown agent id "${agentId}"`); + throw new InvalidHarnessConfigError(say("OSW-HRN-017", { label: label, agent: agentId })); } if (agentId === VSCODE_CHAT_STEP_AGENT_ID && autonomyLevel !== "assisted") { - throw new InvalidHarnessConfigError( - `${label} selects agent "${VSCODE_CHAT_STEP_AGENT_ID}", which is only valid under autonomyLevel "assisted" — a chain cannot use it`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-018", { label: label, agent: VSCODE_CHAT_STEP_AGENT_ID })); } if (model !== undefined) { if (agentId === VSCODE_CHAT_STEP_AGENT_ID) { - throw new InvalidHarnessConfigError( - `${label}.model cannot reach anything when agent "${VSCODE_CHAT_STEP_AGENT_ID}" dispatches to VS Code chat`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-019", { label: label, agent: VSCODE_CHAT_STEP_AGENT_ID })); } if (typeof model !== "string" || !MODEL_ID_PATTERN.test(model)) { - throw new InvalidHarnessConfigError(`${label}.model "${String(model)}" is not a valid model id`); + throw new InvalidHarnessConfigError(say("OSW-HRN-020", { label: label, model: String(model) })); } if (!acceptsModel(AGENT_DESCRIPTORS_BY_ID.get(agentId))) { - throw new InvalidHarnessConfigError(`${label} sets a model, but agent "${agentId}" does not accept one`); + throw new InvalidHarnessConfigError(say("OSW-HRN-021", { label: label, agent: agentId })); } } @@ -702,12 +693,10 @@ function assertValidAgentEntry(label: string, entry: unknown, autonomyLevel: Har const customAgent = (entry as { customAgent?: unknown }).customAgent; if (customAgent !== undefined) { if (agentId === VSCODE_CHAT_STEP_AGENT_ID) { - throw new InvalidHarnessConfigError( - `${label}.customAgent cannot reach anything when agent "${VSCODE_CHAT_STEP_AGENT_ID}" dispatches to VS Code chat`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-022", { label: label, agent: VSCODE_CHAT_STEP_AGENT_ID })); } if (typeof customAgent !== "string" || customAgent.trim().length === 0) { - throw new InvalidHarnessConfigError(`${label}.customAgent must be a non-empty string`); + throw new InvalidHarnessConfigError(say("OSW-HRN-023", { label: label })); } // The same character rule a model id obeys, for the same reason: // both reach the CLI as the value of a flag, a change's @@ -716,15 +705,10 @@ function assertValidAgentEntry(label: string, entry: unknown, autonomyLevel: Har // than two, so there is one thing to keep true — see design.md, // "One shape rule for every value that reaches argv". if (!MODEL_ID_PATTERN.test(customAgent)) { - throw new InvalidHarnessConfigError( - `${label}.customAgent "${customAgent}" must not begin with "-" and may contain only ` + - `letters, digits, ".", "_", ":" and "-"`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-024", { label: label, customagent: customAgent })); } if (!AGENT_DESCRIPTORS_BY_ID.get(agentId)?.customAgentFlag) { - throw new InvalidHarnessConfigError( - `${label} sets a custom agent, but agent "${agentId}" does not accept one`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-025", { label: label, agent: agentId })); } } @@ -732,71 +716,55 @@ function assertValidAgentEntry(label: string, entry: unknown, autonomyLevel: Har if (effort !== undefined) { if (agentId === VSCODE_CHAT_STEP_AGENT_ID) { - throw new InvalidHarnessConfigError( - `${label}.effort cannot reach anything when agent "${VSCODE_CHAT_STEP_AGENT_ID}" dispatches to VS Code chat`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-026", { label: label, agent: VSCODE_CHAT_STEP_AGENT_ID })); } if (typeof effort !== "string" || !HARNESS_EFFORT_VALUES.includes(effort as HarnessEffort)) { - throw new InvalidHarnessConfigError(`${label}.effort must be one of: ${HARNESS_EFFORT_VALUES.join(", ")}`); + throw new InvalidHarnessConfigError(say("OSW-HRN-027", { label: label, values: HARNESS_EFFORT_VALUES.join(", ") })); } const accepted = capabilities?.effort; if (!accepted || accepted.length === 0) { - throw new InvalidHarnessConfigError( - `${label} sets effort, but agent "${agentId}" has no command-line reasoning-effort control`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-028", { label: label, agent: agentId })); } if (!accepted.includes(effort as HarnessEffort)) { - throw new InvalidHarnessConfigError( - `${label}.effort "${effort}" is not accepted by agent "${agentId}" (accepted: ${accepted.join(", ")})`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-029", { label: label, effort: effort, agent: agentId, values: accepted.join(", ") })); } } if (budget !== undefined) { if (agentId === VSCODE_CHAT_STEP_AGENT_ID) { - throw new InvalidHarnessConfigError( - `${label}.budget cannot reach anything when agent "${VSCODE_CHAT_STEP_AGENT_ID}" dispatches to VS Code chat`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-030", { label: label, agent: VSCODE_CHAT_STEP_AGENT_ID })); } if (typeof budget !== "object" || budget === null || Array.isArray(budget)) { - throw new InvalidHarnessConfigError(`${label}.budget must be an object`); + throw new InvalidHarnessConfigError(say("OSW-HRN-031", { label: label })); } const budgetRecord = budget as Record; const unknownBudgetKey = Object.keys(budgetRecord).find( (key) => !(STEP_BUDGET_KEYS as readonly string[]).includes(key), ); if (unknownBudgetKey !== undefined) { - throw new InvalidHarnessConfigError( - `${label}.budget has unknown key "${unknownBudgetKey}" (accepted keys: ${formatAcceptedKeys(STEP_BUDGET_KEYS)})`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-032", { label: label, key: unknownBudgetKey, keys: formatAcceptedKeys(STEP_BUDGET_KEYS) })); } const { maxCostUsd, maxAiCredits } = budget as { maxCostUsd?: unknown; maxAiCredits?: unknown }; if (maxCostUsd === undefined && maxAiCredits === undefined) { - throw new InvalidHarnessConfigError(`${label}.budget must set maxCostUsd or maxAiCredits`); + throw new InvalidHarnessConfigError(say("OSW-HRN-033", { label: label })); } if (maxCostUsd !== undefined) { if (!(typeof maxCostUsd === "number" && Number.isFinite(maxCostUsd) && maxCostUsd > 0)) { - throw new InvalidHarnessConfigError(`${label}.budget.maxCostUsd must be a positive number`); + throw new InvalidHarnessConfigError(say("OSW-HRN-034", { label: label })); } if (capabilities?.budgetField !== "maxCostUsd") { - throw new InvalidHarnessConfigError( - `${label} sets budget.maxCostUsd, but agent "${agentId}" does not accept a cost cap in USD`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-035", { label: label, agent: agentId })); } } if (maxAiCredits !== undefined) { if (!(typeof maxAiCredits === "number" && Number.isInteger(maxAiCredits) && maxAiCredits > 0)) { - throw new InvalidHarnessConfigError(`${label}.budget.maxAiCredits must be a positive integer`); + throw new InvalidHarnessConfigError(say("OSW-HRN-036", { label: label })); } if (capabilities?.budgetField !== "maxAiCredits") { - throw new InvalidHarnessConfigError( - `${label} sets budget.maxAiCredits, but agent "${agentId}" does not accept a credit cap`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-037", { label: label, agent: agentId })); } if (maxAiCredits < COPILOT_MIN_AI_CREDITS) { - throw new InvalidHarnessConfigError( - `${label}.budget.maxAiCredits must be at least ${COPILOT_MIN_AI_CREDITS} (copilot-cli's own minimum)`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-038", { label: label, minimum: COPILOT_MIN_AI_CREDITS })); } } } @@ -806,16 +774,14 @@ function assertValidAgentEntry(label: string, entry: unknown, autonomyLevel: Har function assertValidStepAgents(value: unknown, autonomyLevel: HarnessAutonomyLevel): asserts value is HarnessStepAgents { if (value === undefined) return; if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InvalidHarnessConfigError("stepAgents must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-039")); } for (const [stage, entry] of Object.entries(value)) { if ((NO_AGENT_STAGES as readonly string[]).includes(stage)) { - throw new InvalidHarnessConfigError( - `stepAgents.${stage} is not accepted — "${stage}" is a mechanical stage and never invokes an agent`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-040", { stage: stage })); } if (!STEP_AGENT_STAGES.includes(stage as HarnessStepAgentStage)) { - throw new InvalidHarnessConfigError(`unknown stepAgents key "${stage}" (expected one of: ${STEP_AGENT_STAGES.join(", ")})`); + throw new InvalidHarnessConfigError(say("OSW-HRN-041", { stage: stage, stages: STEP_AGENT_STAGES.join(", ") })); } assertValidAgentEntry(`stepAgents.${stage}`, entry, autonomyLevel); } @@ -849,20 +815,16 @@ function assertValidTaskAgents( throw new GlobalTaskAgentsError(); } if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InvalidHarnessConfigError("taskAgents must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-042")); } for (const [taskNumber, entry] of Object.entries(value)) { if (!TASK_NUMBER_PATTERN.test(taskNumber)) { - throw new InvalidHarnessConfigError( - `taskAgents key "${taskNumber}" is not a task number (expected the leading number of a tasks.md line, such as "5.4")`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-043", { task: taskNumber })); } const label = `taskAgents."${taskNumber}"`; assertValidAgentEntry(label, entry, autonomyLevel); if (normalizeStepAgent(entry as HarnessStepAgent).agent === VSCODE_CHAT_STEP_AGENT_ID) { - throw new InvalidHarnessConfigError( - `${label} selects agent "${VSCODE_CHAT_STEP_AGENT_ID}", which cannot run one task — a delegated item's run spawns a CLI agent`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-044", { label: label, agent: VSCODE_CHAT_STEP_AGENT_ID })); } } } @@ -873,7 +835,7 @@ function assertValidAutonomyLevel( ): asserts value is HarnessAutonomyLevel | undefined { if (value === undefined) return; if (typeof value !== "string" || !AUTONOMY_LEVELS.includes(value as HarnessAutonomyLevel)) { - throw new InvalidHarnessConfigError(`autonomyLevel must be one of: ${AUTONOMY_LEVELS.join(", ")}`); + throw new InvalidHarnessConfigError(say("OSW-HRN-045", { levels: AUTONOMY_LEVELS.join(", ") })); } if (!isPerChangeFile && value === "autonomous") { throw new GlobalAutonomousAutonomyLevelError(); @@ -886,11 +848,11 @@ function assertValidReviewGate( ): asserts value is HarnessReviewGate | undefined { if (value === undefined) return; if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InvalidHarnessConfigError("reviewGate must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-046")); } const mode = (value as { mode?: unknown }).mode; if (typeof mode !== "string" || !REVIEW_GATE_MODES.includes(mode as HarnessReviewGateMode)) { - throw new InvalidHarnessConfigError(`reviewGate.mode must be one of: ${REVIEW_GATE_MODES.join(", ")}`); + throw new InvalidHarnessConfigError(say("OSW-HRN-047", { modes: REVIEW_GATE_MODES.join(", ") })); } if (!isPerChangeFile && mode === "agent-sufficient") { throw new GlobalAgentSufficientReviewGateError(); @@ -903,10 +865,10 @@ function assertValidReviewGate( function assertValidHints(value: unknown): asserts value is HarnessHints | undefined { if (value === undefined) return; if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InvalidHarnessConfigError("hints must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-048")); } if (typeof (value as { enabled?: unknown }).enabled !== "boolean") { - throw new InvalidHarnessConfigError("hints.enabled must be a boolean"); + throw new InvalidHarnessConfigError(say("OSW-HRN-049")); } } @@ -924,33 +886,33 @@ function assertValidSupervisor( ): asserts value is HarnessSupervisor | undefined { if (value === undefined) return; if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InvalidHarnessConfigError("supervisor must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-050")); } const record = value as Record; for (const key of Object.keys(record)) { if (!(SUPERVISOR_KEYS as readonly string[]).includes(key)) { - throw new InvalidHarnessConfigError(`supervisor has no key "${key}" (accepted keys: ${formatAcceptedKeys(SUPERVISOR_KEYS)})`); + throw new InvalidHarnessConfigError(say("OSW-HRN-051", { key: key, keys: formatAcceptedKeys(SUPERVISOR_KEYS) })); } } if (record.mode !== undefined && !SUPERVISOR_MODES.includes(record.mode as HarnessSupervisorMode)) { - throw new InvalidHarnessConfigError(`supervisor.mode must be one of: ${SUPERVISOR_MODES.join(", ")}`); + throw new InvalidHarnessConfigError(say("OSW-HRN-052", { modes: SUPERVISOR_MODES.join(", ") })); } if (record.mode === "act") { 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'); + throw new InvalidHarnessConfigError(say("OSW-HRN-053")); } } for (const key of ["silentAfterSeconds", "waitingAfterSeconds"] as const) { const seconds = record[key]; if (seconds !== undefined && (typeof seconds !== "number" || !Number.isInteger(seconds) || seconds <= 0)) { - throw new InvalidHarnessConfigError(`supervisor.${key} must be a positive whole number of seconds`); + throw new InvalidHarnessConfigError(say("OSW-HRN-054", { key: key })); } } for (const key of ["allowCostIncrease", "allowProviderChange"] as const) { if (record[key] === undefined) continue; - if (typeof record[key] !== "boolean") throw new InvalidHarnessConfigError(`supervisor.${key} must be a boolean`); + if (typeof record[key] !== "boolean") throw new InvalidHarnessConfigError(say("OSW-HRN-055", { key: key })); if (!isPerChangeFile) { - throw new InvalidHarnessConfigError(`supervisor.${key} is set in a change's own harness.json, never for the whole workspace`); + throw new InvalidHarnessConfigError(say("OSW-HRN-056", { key: key })); } } if (record.fallback !== undefined) assertValidFallback(record.fallback); @@ -960,24 +922,24 @@ function assertValidSupervisor( * for a stage that runs an agent. */ function assertValidFallback(value: unknown): void { if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InvalidHarnessConfigError("supervisor.fallback must be an object of stages"); + throw new InvalidHarnessConfigError(say("OSW-HRN-057")); } for (const [stage, agents] of Object.entries(value as Record)) { if (!(STAGES as readonly string[]).includes(stage) || !isHarnessStepAgentStage(stage as HarnessStage)) { - throw new InvalidHarnessConfigError(`supervisor.fallback.${stage} names no stage that runs an agent`); + throw new InvalidHarnessConfigError(say("OSW-HRN-058", { stage: stage })); } if (!Array.isArray(agents) || agents.length === 0 || agents.some((agent) => typeof agent !== "string")) { - throw new InvalidHarnessConfigError(`supervisor.fallback.${stage} must be a non-empty list of agent ids`); + throw new InvalidHarnessConfigError(say("OSW-HRN-059", { stage: stage })); } const seen = new Set(); for (const agent of agents as string[]) { if (agent === VSCODE_CHAT_STEP_AGENT_ID) { - throw new InvalidHarnessConfigError(`supervisor.fallback.${stage} may not name ${agent}: a chain cannot hand a stage it attempts again to the editor's chat`); + throw new InvalidHarnessConfigError(say("OSW-HRN-060", { stage: stage, agent: agent })); } if (!AGENT_REGISTRY.some((descriptor) => descriptor.id === agent)) { - throw new InvalidHarnessConfigError(`supervisor.fallback.${stage} names "${agent}", which is not a registered agent`); + throw new InvalidHarnessConfigError(say("OSW-HRN-061", { stage: stage, agent: agent })); } - if (seen.has(agent)) throw new InvalidHarnessConfigError(`supervisor.fallback.${stage} names "${agent}" twice`); + if (seen.has(agent)) throw new InvalidHarnessConfigError(say("OSW-HRN-062", { stage: stage, agent: agent })); seen.add(agent); } } @@ -989,12 +951,12 @@ function assertValidCheckpoints( ): asserts value is HarnessCheckpoints | undefined { if (value === undefined) return; if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InvalidHarnessConfigError("checkpoints must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-063")); } const requireConfirmationBetweenSteps = (value as { requireConfirmationBetweenSteps?: unknown }) .requireConfirmationBetweenSteps; if (typeof requireConfirmationBetweenSteps !== "boolean") { - throw new InvalidHarnessConfigError("checkpoints.requireConfirmationBetweenSteps must be a boolean"); + throw new InvalidHarnessConfigError(say("OSW-HRN-064")); } if (!isPerChangeFile && requireConfirmationBetweenSteps === false) { throw new GlobalCheckpointsDisabledError(); @@ -1022,23 +984,23 @@ function assertValidCheckpoints( function assertValidBudget(value: unknown): asserts value is HarnessBudget | undefined { if (value === undefined) return; if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InvalidHarnessConfigError("budget must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-065")); } const { maxCostUsd, maxTokens } = value as { maxCostUsd?: unknown; maxTokens?: unknown }; if (maxCostUsd !== undefined && !(typeof maxCostUsd === "number" && Number.isFinite(maxCostUsd) && maxCostUsd > 0)) { - throw new InvalidHarnessConfigError("budget.maxCostUsd must be a positive number"); + throw new InvalidHarnessConfigError(say("OSW-HRN-066")); } if (maxTokens !== undefined && !(typeof maxTokens === "number" && Number.isInteger(maxTokens) && maxTokens > 0)) { - throw new InvalidHarnessConfigError("budget.maxTokens must be a positive integer"); + throw new InvalidHarnessConfigError(say("OSW-HRN-067")); } const { maxStageCostUsd, maxStageTokens } = value as { maxStageCostUsd?: unknown; maxStageTokens?: unknown }; if (maxStageCostUsd !== undefined && !(typeof maxStageCostUsd === "number" && Number.isFinite(maxStageCostUsd) && maxStageCostUsd > 0)) { - throw new InvalidHarnessConfigError("budget.maxStageCostUsd must be a positive number"); + throw new InvalidHarnessConfigError(say("OSW-HRN-068")); } if (maxStageTokens !== undefined && !(typeof maxStageTokens === "number" && Number.isInteger(maxStageTokens) && maxStageTokens > 0)) { - throw new InvalidHarnessConfigError("budget.maxStageTokens must be a positive integer"); + throw new InvalidHarnessConfigError(say("OSW-HRN-069")); } // A share, not a count: refused outside (0, 1] so a person who meant // eighty percent and wrote 80 is told, rather than given a ceiling that @@ -1047,38 +1009,30 @@ function assertValidBudget(value: unknown): asserts value is HarnessBudget | und if (maxContextShare !== undefined && !(typeof maxContextShare === "number" && Number.isFinite(maxContextShare) && maxContextShare > 0 && maxContextShare <= 1)) { - throw new InvalidHarnessConfigError( - "budget.maxContextShare must be a share above 0 and at most 1, such as 0.8 for eighty percent", - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-070")); } // A stage ceiling above the whole-chain ceiling can never fire: the // chain ceiling stops the run first. Refused here for the same reason // `timeout` refuses the same shape — a setting that cannot fire is a // setting that lies about what bounds the run. if (typeof maxCostUsd === "number" && typeof maxStageCostUsd === "number" && maxStageCostUsd > maxCostUsd) { - throw new InvalidHarnessConfigError( - `budget.maxStageCostUsd (${maxStageCostUsd}) must not exceed budget.maxCostUsd (${maxCostUsd}),` - + " because the chain ceiling would stop the run first and the stage ceiling could never fire", - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-071", { stage: maxStageCostUsd, chain: maxCostUsd })); } if (typeof maxTokens === "number" && typeof maxStageTokens === "number" && maxStageTokens > maxTokens) { - throw new InvalidHarnessConfigError( - `budget.maxStageTokens (${maxStageTokens}) must not exceed budget.maxTokens (${maxTokens}),` - + " because the chain ceiling would stop the run first and the stage ceiling could never fire", - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-072", { stage: maxStageTokens, chain: maxTokens })); } } function assertValidTimeout(value: unknown): asserts value is HarnessTimeout | undefined { if (value === undefined) return; if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InvalidHarnessConfigError("timeout must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-073")); } const { maxRunSeconds, maxStageSeconds } = value as { maxRunSeconds?: unknown; maxStageSeconds?: unknown }; for (const [key, seconds] of [["maxRunSeconds", maxRunSeconds], ["maxStageSeconds", maxStageSeconds]] as const) { if (seconds === undefined) continue; if (!(typeof seconds === "number" && Number.isInteger(seconds) && seconds > 0)) { - throw new InvalidHarnessConfigError(`timeout.${key} must be a positive integer number of seconds`); + throw new InvalidHarnessConfigError(say("OSW-HRN-074", { key: key })); } } // A stage ceiling above the run ceiling can never fire — the run @@ -1089,27 +1043,22 @@ function assertValidTimeout(value: unknown): asserts value is HarnessTimeout | u && typeof maxStageSeconds === "number" && maxStageSeconds > maxRunSeconds ) { - throw new InvalidHarnessConfigError( - `timeout.maxStageSeconds (${maxStageSeconds}) must not exceed timeout.maxRunSeconds (${maxRunSeconds}),` - + " because the run ceiling would stop the chain first and the stage ceiling could never fire", - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-075", { stage: maxStageSeconds, run: maxRunSeconds })); } } function assertValidSkipStages(value: unknown): asserts value is HarnessSkippableStage[] | undefined { if (value === undefined) return; if (!Array.isArray(value)) { - throw new InvalidHarnessConfigError("skipStages must be a list of stage names"); + throw new InvalidHarnessConfigError(say("OSW-HRN-076")); } const seen = new Set(); for (const element of value) { if (!(SKIPPABLE_STAGES as readonly string[]).includes(element as string)) { - throw new InvalidHarnessConfigError( - `skipStages may leave out only "review"; "${String(element)}" is not a stage it can skip`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-077", { stage: String(element) })); } if (seen.has(element as string)) { - throw new InvalidHarnessConfigError(`skipStages names "${element}" twice`); + throw new InvalidHarnessConfigError(say("OSW-HRN-078", { stage: element })); } seen.add(element as string); } @@ -1118,7 +1067,7 @@ function assertValidSkipStages(value: unknown): asserts value is HarnessSkippabl function assertValidMaxStageAttempts(value: unknown): asserts value is number | undefined { if (value === undefined) return; if (!(typeof value === "number" && Number.isInteger(value) && value > 0)) { - throw new InvalidHarnessConfigError("maxStageAttempts must be a positive integer"); + throw new InvalidHarnessConfigError(say("OSW-HRN-079")); } } @@ -1131,24 +1080,22 @@ function assertValidGitStageAllowlist( throw new GlobalGitAllowlistError(); } if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InvalidHarnessConfigError("gitStageAllowlist must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-080")); } const record = value as Record; const unknownKey = Object.keys(record).find((key) => !(GIT_STAGE_ALLOWLIST_KEYS as readonly string[]).includes(key)); if (unknownKey !== undefined) { - throw new InvalidHarnessConfigError( - `gitStageAllowlist has unknown key "${unknownKey}" (accepted keys: ${formatAcceptedKeys(GIT_STAGE_ALLOWLIST_KEYS)})`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-081", { key: unknownKey, keys: formatAcceptedKeys(GIT_STAGE_ALLOWLIST_KEYS) })); } const remotes = record.remotes; const branches = record.branches; if (!Array.isArray(remotes) || remotes.length === 0 || remotes.some((item) => typeof item !== "string" || item.length === 0)) { - throw new InvalidHarnessConfigError("gitStageAllowlist.remotes must be a non-empty string array"); + throw new InvalidHarnessConfigError(say("OSW-HRN-082")); } if (!Array.isArray(branches) || branches.length === 0 || branches.some((item) => typeof item !== "string" || item.length === 0)) { - throw new InvalidHarnessConfigError("gitStageAllowlist.branches must be a non-empty string array"); + throw new InvalidHarnessConfigError(say("OSW-HRN-083")); } } @@ -1166,9 +1113,7 @@ function assertNoUnknownTopLevelKeys(value: Record): void { // and no guessing". The hint names a possibility, phrased as a // question, never an assertion (task 1.6). const hint = STAGES.includes(unknownKey as HarnessStage) ? ` Did you mean "stepAgents.${unknownKey}"?` : ""; - throw new InvalidHarnessConfigError( - `unrecognized top-level key "${unknownKey}" (accepted keys: ${formatAcceptedKeys(TOP_LEVEL_CONFIG_KEYS)}).${hint}`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-084", { key: unknownKey, keys: formatAcceptedKeys(TOP_LEVEL_CONFIG_KEYS), hint: hint })); } /** Validates a raw parsed JSON value as a partial `HarnessConfig`. @@ -1181,7 +1126,7 @@ function assertValidHarnessConfigInput( isPerChangeFile: boolean, ): asserts value is Partial { if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InvalidHarnessConfigError("root value must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-085")); } assertNoUnknownTopLevelKeys(value as Record); const input = value as Partial; @@ -1194,36 +1139,36 @@ function assertValidHarnessConfigInput( assertValidMaxCost((input as { budget?: { maxCost?: unknown } }).budget?.maxCost); const allowAgentMessages = (input as { allowAgentMessages?: unknown }).allowAgentMessages; if (allowAgentMessages !== undefined && typeof allowAgentMessages !== "boolean") { - throw new InvalidHarnessConfigError("allowAgentMessages must be a boolean"); + throw new InvalidHarnessConfigError(say("OSW-HRN-086")); } const branches = (input as { branches?: unknown }).branches; if (branches !== undefined) { if (branches === null || typeof branches !== "object" || Array.isArray(branches)) { - throw new InvalidHarnessConfigError("branches must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-087")); } for (const key of Object.keys(branches)) { if (key !== "rebaseWhenBehind" && key !== "followMain") { - throw new InvalidHarnessConfigError(`branches has no key "${key}"; the ones it takes are rebaseWhenBehind and followMain`); + throw new InvalidHarnessConfigError(say("OSW-HRN-088", { key: key })); } } for (const key of ["rebaseWhenBehind", "followMain"] as const) { const value = (branches as Record)[key]; if (value !== undefined && typeof value !== "boolean") { - throw new InvalidHarnessConfigError(`branches.${key} must be a boolean`); + throw new InvalidHarnessConfigError(say("OSW-HRN-089", { key: key })); } } } const archive = (input as { archive?: unknown }).archive; if (archive !== undefined) { if (archive === null || typeof archive !== "object" || Array.isArray(archive)) { - throw new InvalidHarnessConfigError("archive must be an object"); + throw new InvalidHarnessConfigError(say("OSW-HRN-090")); } for (const key of Object.keys(archive)) { - if (key !== "whenLanded") throw new InvalidHarnessConfigError(`archive has no key "${key}"; the one it takes is whenLanded`); + if (key !== "whenLanded") throw new InvalidHarnessConfigError(say("OSW-HRN-091", { key: key })); } const whenLanded = (archive as { whenLanded?: unknown }).whenLanded; if (whenLanded !== undefined && typeof whenLanded !== "boolean") { - throw new InvalidHarnessConfigError("archive.whenLanded must be a boolean"); + throw new InvalidHarnessConfigError(say("OSW-HRN-092")); } } assertValidBudget(input.budget); @@ -1248,29 +1193,25 @@ function assertValidChainSteps( if (value === undefined) return; if (!isPerChangeFile) throw new GlobalChainStepsError(); if (!Array.isArray(value)) { - throw new InvalidHarnessConfigError("steps must be an array"); + throw new InvalidHarnessConfigError(say("OSW-HRN-093")); } value.forEach((entry, index) => { const label = `steps[${index}]`; if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { - throw new InvalidHarnessConfigError(`${label} must be an object`); + throw new InvalidHarnessConfigError(say("OSW-HRN-094", { label: label })); } const record = entry as Record; for (const key of Object.keys(record)) { if (!(CHAIN_STEP_KEYS as readonly string[]).includes(key)) { - throw new InvalidHarnessConfigError( - `unrecognized key "${key}" in ${label} (accepted keys: ${formatAcceptedKeys(CHAIN_STEP_KEYS)})`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-095", { key: key, label: label, keys: formatAcceptedKeys(CHAIN_STEP_KEYS) })); } } const step = record.step; if (typeof step !== "string" || !isChainStepName(step)) { - throw new InvalidHarnessConfigError( - `${label}.step is not a declared step (expected one of: ${CHAIN_STEP_NAMES.join(", ")})`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-096", { label: label, steps: CHAIN_STEP_NAMES.join(", ") })); } // Exactly one position, naming exactly one fixed stage. Both or @@ -1280,30 +1221,24 @@ function assertValidChainSteps( const hasBefore = record.before !== undefined; const hasAfter = record.after !== undefined; if (hasBefore === hasAfter) { - throw new InvalidHarnessConfigError( - `${label} must state exactly one of "before" or "after"` - + (hasBefore ? ", not both" : ", and states neither"), - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-097", { label: label, suffix: (hasBefore ? ", not both" : ", and states neither") })); } const position = (hasBefore ? record.before : record.after) as unknown; if (typeof position !== "string" || !(STAGES as readonly string[]).includes(position)) { - throw new InvalidHarnessConfigError( - `${label}.${hasBefore ? "before" : "after"} must name a stage (expected one of: ${STAGES.join(", ")})` - + " — a step is placed against the fixed sequence, never against another declared step", - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-098", { label: label, position: hasBefore ? "before" : "after", stages: STAGES.join(", ") })); } if (record.param !== undefined && typeof record.param !== "string") { - throw new InvalidHarnessConfigError(`${label}.param must be a string`); + throw new InvalidHarnessConfigError(say("OSW-HRN-099", { label: label })); } if ((CHAIN_STEPS_REQUIRING_PARAM as readonly string[]).includes(step) && !record.param) { - throw new InvalidHarnessConfigError(`${label}.param is required for step "${step}"`); + throw new InvalidHarnessConfigError(say("OSW-HRN-100", { label: label, step: step })); } if (record.maxWaitSeconds !== undefined) { const seconds = record.maxWaitSeconds; if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) { - throw new InvalidHarnessConfigError(`${label}.maxWaitSeconds must be a positive number of seconds`); + throw new InvalidHarnessConfigError(say("OSW-HRN-101", { label: label })); } } }); @@ -1492,9 +1427,7 @@ export async function resolveHarnessConfig(workspaceRoot: string, changeName?: s const stage = step.before ?? step.after; if (stage !== undefined && skipsStage(merged, stage)) { const position = step.before !== undefined ? "before" : "after"; - throw new InvalidHarnessConfigError( - `steps[${index}] (${step.step}) is placed ${position} "${stage}", which skipStages leaves out; place it against another stage`, - ); + throw new InvalidHarnessConfigError(say("OSW-HRN-102", { index: index, step: step.step, position: position, stage: stage })); } }); diff --git a/packages/core/src/http-forges.test.ts b/packages/core/src/http-forges.test.ts index e1cdac23..7f8ddba3 100644 --- a/packages/core/src/http-forges.test.ts +++ b/packages/core/src/http-forges.test.ts @@ -63,7 +63,7 @@ describe("detectForge", () => { const forge = await forgeFor("/repo", { fetch, env: {}, remoteUrl: async () => `${GITEA}/root/demo.git` }); expect(forge.name).toBe("Gitea"); - expect(await forge.pullRequestsByBranch()).toEqual({ available: false, reason: "GITEA_TOKEN is not set" }); + expect(await forge.pullRequestsByBranch()).toEqual({ available: false, reason: "GITEA_TOKEN is not set", code: "OSW-NET-001" }); }); }); @@ -120,7 +120,7 @@ describe("the Gitea forge", () => { it("says what a refused token is", async () => { const { forge } = forgeWith([["GET", `${REPO}/pulls?`, 401, { message: "token is required" }]]); - expect(await forge.pullRequestsByBranch()).toEqual({ available: false, reason: "the GITEA_TOKEN was refused (401)" }); + expect(await forge.pullRequestsByBranch()).toEqual({ available: false, reason: "the GITEA_TOKEN was refused (401)", code: "OSW-NET-002" }); }); it("reads a branch from a head, whatever Gitea left in it", () => { diff --git a/packages/core/src/http-forges.ts b/packages/core/src/http-forges.ts index bd9b1131..3a9d6983 100644 --- a/packages/core/src/http-forges.ts +++ b/packages/core/src/http-forges.ts @@ -6,6 +6,7 @@ // `GITEA_TOKEN` - which is read when asked and never written anywhere. import { parseCheckStatus, pullRequestTimes, type BranchPullRequest, type MergeMethod, type BranchPullRequestState, type Forge, type PullRequestCheckStatus, type PullRequestRef, type PullRequestsByBranch } from "./gh-pr-gateway.js"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; export type FetchLike = (url: string, init?: { method?: string; headers?: Record; body?: string }) => Promise<{ ok: boolean; @@ -26,9 +27,14 @@ export interface HttpForgeOptions { sleep?: (ms: number) => Promise; } +/** The forge refused, in the register's words under their identifier. The + * status is absent where nothing was asked of the forge at all - no token + * to ask it with. */ class ForgeRefusal extends Error { - constructor(message: string, readonly status: number) { - super(message); + readonly code: MessageCode; + constructor(said: SaidMessage, readonly status?: number) { + super(said.text); + this.code = said.code; } } @@ -49,14 +55,29 @@ async function call(options: HttpForgeOptions, headers: Record, } catch { // Not JSON: said as it came, cut short. } - if (response.status === 401) throw new ForgeRefusal(`the ${options.tokenName} was refused (401)`, 401); - throw new ForgeRefusal(`${method} ${new URL(url).pathname} answered ${response.status}: ${said.slice(0, 200) || "no reason given"}`, response.status); + if (response.status === 401) throw new ForgeRefusal(say("OSW-NET-002", { token: options.tokenName }), 401); + throw new ForgeRefusal(say("OSW-NET-003", { + method, + path: new URL(url).pathname, + status: response.status, + reason: said.slice(0, 200) || "no reason given", + }), response.status); } return text.length === 0 ? undefined : JSON.parse(text); } -function why(error: unknown): string { - return error instanceof Error ? error.message : String(error); +/** A refusal's words and identifier where it has them; a bare error's + * message otherwise. */ +function why(error: unknown): { reason: string; code?: MessageCode } { + return error instanceof ForgeRefusal + ? { reason: error.message, code: error.code } + : { reason: error instanceof Error ? error.message : String(error) }; +} + +/** A token the environment never carried, in the register's words. */ +function tokenNotSet(tokenName: string): PullRequestsByBranch { + const said = say("OSW-NET-001", { token: tokenName }); + return { available: false, reason: said.text, code: said.code }; } /** Keeps, per branch, an open or merged one over a closed one, and the @@ -85,7 +106,7 @@ export function createGiteaForge(options: HttpForgeOptions): Forge { return { name: "Gitea", async pullRequestsByBranch(): Promise { - if (!options.token) return { available: false, reason: `${options.tokenName} is not set` }; + if (!options.token) return tokenNotSet(options.tokenName); const byBranch = new Map(); try { for (let page = 1; page <= PAGES; page += 1) { @@ -112,12 +133,12 @@ export function createGiteaForge(options: HttpForgeOptions): Forge { if (items.length < 50) break; } } catch (error) { - return { available: false, reason: why(error) }; + return { available: false, ...why(error) }; } return { available: true, byBranch }; }, async openPullRequest(request): Promise { - if (!options.token) throw new Error(`${options.tokenName} is not set`); + if (!options.token) throw new ForgeRefusal(say("OSW-NET-001", { token: options.tokenName })); const made = await call(options, headers(), "POST", `${api}/pulls`, { head: request.head, base: request.base, title: request.title, body: request.body }) as { number?: unknown; html_url?: unknown }; if (typeof made?.number !== "number") throw new Error("Gitea opened a pull request and did not say its number"); return { number: made.number, url: typeof made.html_url === "string" ? made.html_url : `${options.base}/${options.path}/pulls/${made.number}` }; @@ -125,7 +146,10 @@ export function createGiteaForge(options: HttpForgeOptions): Forge { async checksOf(prNumber): Promise { const pull = await call(options, headers(), "GET", `${api}/pulls/${prNumber}`) as { head?: { sha?: unknown } }; const sha = pull?.head?.sha; - if (typeof sha !== "string") return { state: "none", reason: "Gitea did not say which commit the pull request is at" }; + if (typeof sha !== "string") { + const said = say("OSW-NET-105"); + return { state: "none", reason: said.text, code: said.code }; + } const combined = await call(options, headers(), "GET", `${api}/commits/${sha}/status`) as { statuses?: Array<{ context?: unknown; status?: unknown; state?: unknown }> }; return parseCheckStatus((combined?.statuses ?? []).map((one) => ({ name: typeof one.context === "string" ? one.context : "status", @@ -133,7 +157,7 @@ export function createGiteaForge(options: HttpForgeOptions): Forge { }))); }, async mergeNow(prNumber, method: MergeMethod = "merge") { - if (!options.token) throw new Error(`${options.tokenName} is not set`); + if (!options.token) throw new ForgeRefusal(say("OSW-NET-001", { token: options.tokenName })); await call(options, headers(), "POST", `${api}/pulls/${prNumber}/merge`, { Do: method, delete_branch_after_merge: true }); }, }; @@ -151,7 +175,7 @@ export function createGitLabForge(options: HttpForgeOptions): Forge { return { name: "GitLab", async pullRequestsByBranch(): Promise { - if (!options.token) return { available: false, reason: `${options.tokenName} is not set` }; + if (!options.token) return tokenNotSet(options.tokenName); const byBranch = new Map(); try { for (let page = 1; page <= PAGES; page += 1) { @@ -167,12 +191,12 @@ export function createGitLabForge(options: HttpForgeOptions): Forge { if (items.length < 50) break; } } catch (error) { - return { available: false, reason: why(error) }; + return { available: false, ...why(error) }; } return { available: true, byBranch }; }, async openPullRequest(request): Promise { - if (!options.token) throw new Error(`${options.tokenName} is not set`); + if (!options.token) throw new ForgeRefusal(say("OSW-NET-001", { token: options.tokenName })); const made = await call(options, headers(), "POST", `${api}/merge_requests`, { source_branch: request.head, target_branch: request.base, @@ -191,7 +215,7 @@ export function createGitLabForge(options: HttpForgeOptions): Forge { return parseCheckStatus([{ name: `pipeline ${String(pipeline.id ?? "")}`.trim(), state: gitlabPipelineWord(String(pipeline.status ?? "")) }]); }, async mergeNow(prNumber, method: MergeMethod = "merge") { - if (!options.token) throw new Error(`${options.tokenName} is not set`); + if (!options.token) throw new ForgeRefusal(say("OSW-NET-001", { token: options.tokenName })); const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); // GitLab works out whether a new merge request can merge after it // answers, and refuses a merge meanwhile with 422 "Branch cannot be @@ -256,7 +280,7 @@ export function createGitHubApiForge(options: HttpForgeOptions & { apiBase?: str return { name: "GitHub", async pullRequestsByBranch(): Promise { - if (!options.token) return { available: false, reason: `${options.tokenName} is not set` }; + if (!options.token) return tokenNotSet(options.tokenName); const byBranch = new Map(); try { for (let page = 1; page <= PAGES; page += 1) { @@ -272,12 +296,12 @@ export function createGitHubApiForge(options: HttpForgeOptions & { apiBase?: str if (items.length < 50) break; } } catch (error) { - return { available: false, reason: why(error) }; + return { available: false, ...why(error) }; } return { available: true, byBranch }; }, async openPullRequest(request): Promise { - if (!options.token) throw new Error(`${options.tokenName} is not set`); + if (!options.token) throw new ForgeRefusal(say("OSW-NET-001", { token: options.tokenName })); const made = await call(options, headers(), "POST", `${api}/pulls`, { head: request.head, base: request.base, title: request.title, body: request.body }) as { number?: unknown; html_url?: unknown }; if (typeof made?.number !== "number") throw new Error("GitHub opened a pull request and did not say its number"); return { number: made.number, url: typeof made.html_url === "string" ? made.html_url : `https://github.com/${options.path}/pull/${made.number}` }; @@ -285,7 +309,10 @@ export function createGitHubApiForge(options: HttpForgeOptions & { apiBase?: str async checksOf(prNumber): Promise { const pull = await call(options, headers(), "GET", `${api}/pulls/${prNumber}`) as { head?: { sha?: unknown } }; const sha = pull?.head?.sha; - if (typeof sha !== "string") return { state: "none", reason: "GitHub did not say which commit the pull request is at" }; + if (typeof sha !== "string") { + const said = say("OSW-NET-106"); + return { state: "none", reason: said.text, code: said.code }; + } const runs = await call(options, headers(), "GET", `${api}/commits/${sha}/check-runs?per_page=100`) as { check_runs?: Array<{ name?: unknown; status?: unknown; conclusion?: unknown }> }; const statuses = await call(options, headers(), "GET", `${api}/commits/${sha}/status`) as { statuses?: Array<{ context?: unknown; state?: unknown }> }; return parseCheckStatus([ @@ -300,7 +327,7 @@ export function createGitHubApiForge(options: HttpForgeOptions & { apiBase?: str ]); }, async mergeNow(prNumber, method: MergeMethod = "merge") { - if (!options.token) throw new Error(`${options.tokenName} is not set`); + if (!options.token) throw new ForgeRefusal(say("OSW-NET-001", { token: options.tokenName })); const pull = await call(options, headers(), "GET", `${api}/pulls/${prNumber}`) as { head?: { ref?: unknown } }; await call(options, headers(), "PUT", `${api}/pulls/${prNumber}/merge`, { merge_method: method }); // As `gh pr merge --delete-branch` does. diff --git a/packages/core/src/human-only-inbox-view.ts b/packages/core/src/human-only-inbox-view.ts index f6184203..1c1f5102 100644 --- a/packages/core/src/human-only-inbox-view.ts +++ b/packages/core/src/human-only-inbox-view.ts @@ -14,6 +14,7 @@ // on rather than being assumed to wait on a person. import type { ItemReply } from "./audit-message.js"; +import { say, type MessageCode, type MessageLevel, type SaidMessage } from "./message-register.js"; import type { EnrolmentRequest } from "./signature-facts.js"; /** Who an open item waits on. @@ -119,53 +120,76 @@ export type HumanOnlyInboxState = | { status: "loaded"; inbox: HumanOnlyInbox } | { status: "failed"; reason: string }; +/** A formatter's line: the words to render, and the identifier they are + * said under. `code` is absent where the words are a bare value the + * register holds no entry for — a registered agent's own id, which is + * data rather than a sentence. */ +export type SaidLine = { + code?: MessageCode; + level?: MessageLevel; + text: string; +}; + /** Who one item waits on, in words — for a row's label in either host. * * A disagreement between the change's configuration and the task's own * text is said out loud here rather than settled quietly, which is the * one case this sentence grew past naming an agent. */ -export function describeWaitingOn(waitingOn: WaitingOn): string { - if (waitingOn.kind === "person") return "a person"; - const named = waitingOn.known ? waitingOn.agent : `"${waitingOn.agent}", which is not a registered agent`; - if (waitingOn.alsoNamedInText === undefined) return named; - return `${named} (this change's harness.json names it; the task text names ${waitingOn.alsoNamedInText})`; +export function describeWaitingOn(waitingOn: WaitingOn): SaidLine { + if (waitingOn.kind === "person") return say("OSW-TSK-015"); + const unregistered = waitingOn.known ? undefined : say("OSW-TSK-016", { agent: waitingOn.agent }); + const named = unregistered?.text ?? waitingOn.agent; + if (waitingOn.alsoNamedInText === undefined) return unregistered ?? { text: named }; + return say("OSW-TSK-017", { waiting: named, textagent: waitingOn.alsoNamedInText }); } /** One unmatched `taskAgents` entry, in words. */ -export function describeUnmatchedTaskAgent(unmatched: UnmatchedTaskAgent): string { +export function describeUnmatchedTaskAgent(unmatched: UnmatchedTaskAgent): SaidMessage { const where = unmatched.reason === "task-waits-on-a-person" ? "that task is marked for a person" : "no open task carries that number"; - return `${unmatched.changeName} names ${unmatched.agent} for task ${unmatched.taskNumber}, but ${where}`; + return say("OSW-TSK-018", { + change: unmatched.changeName, + agent: unmatched.agent, + task: unmatched.taskNumber, + where, + }); } function changesRead(count: number): string { return `${count} active change${count === 1 ? "" : "s"}`; } +/** The trailing sentences, each on the same line, in the order they are + * appended: empty where a formatter had nothing to say. */ +function appended(...said: Array): string { + return said.map((line) => (line === undefined ? "" : ` ${line.text}`)).join(""); +} + /** The sentence above the list. * * "Nothing is waiting" and "nothing was read" are different facts, and a * surface that shows an empty list for both says neither. A third fact * joins them here: how much of what is waiting is a question for a * person, and how much is assigned to an agent that has not run yet. */ -export function describeHumanOnlyInbox(inbox: HumanOnlyInbox): string { - const stale = describeUnmatched(inbox.unmatchedTaskAgents) - + describeUnreadable(inbox.unreadableTaskAgents) - + describeEnrolments(inbox.enrolments) - + describeQuestions(inbox.questions); - if (inbox.changesRead === 0) return `No active change to look at.${describeEnrolments(inbox.enrolments)}`; +export function describeHumanOnlyInbox(inbox: HumanOnlyInbox): SaidMessage { + const stale = appended( + describeUnmatched(inbox.unmatchedTaskAgents), + describeUnreadable(inbox.unreadableTaskAgents), + describeEnrolments(inbox.enrolments), + describeQuestions(inbox.questions), + ); + if (inbox.changesRead === 0) { + return say("OSW-TSK-019", { enrolments: appended(describeEnrolments(inbox.enrolments)) }); + } if (inbox.items.length === 0) { - return `Nothing is waiting — ${changesRead(inbox.changesRead)} read.${stale}`; + return say("OSW-TSK-020", { changes: changesRead(inbox.changesRead), stale }); } const changes = new Set(inbox.items.map((item) => item.changeName)).size; - const head = `${inbox.items.length} item${inbox.items.length === 1 ? "" : "s"} waiting,` - + ` across ${changes} of ${changesRead(inbox.changesRead)}`; - const parts: string[] = []; const people = inbox.items.filter((item) => item.waitingOn.kind === "person").length; - if (people > 0) parts.push(`${people} on a person`); + if (people > 0) parts.push(`${people} on ${say("OSW-TSK-015").text}`); // Busiest first, then by name, so the sentence is stable across reads // of the same workspace. @@ -180,10 +204,30 @@ export function describeHumanOnlyInbox(inbox: HumanOnlyInbox): string { (left, right) => right[1].count - left[1].count || left[0].localeCompare(right[0]), ); for (const [agent, group] of ordered) { - parts.push(`${group.count} on ${describeWaitingOn({ kind: "agent", agent, known: group.known })}`); + parts.push(`${group.count} on ${describeWaitingOn({ kind: "agent", agent, known: group.known }).text}`); } - return `${head}: ${parts.join(", ")}.${stale}`; + const waiting = parts.join(", "); + const total = changesRead(inbox.changesRead); + const said = say("OSW-TSK-021", { + items: inbox.items.length, + changes, + total: inbox.changesRead, + waiting, + stale, + }); + // TSK-021's text is plural-only on both counts ("{items} items waiting", + // "{total} active changes"), so a singular item or a single active change + // is said in the carrier's own words rather than the register's; the + // identifier still rides beside them. + if (inbox.items.length === 1 || inbox.changesRead === 1) { + return { + ...said, + text: `${inbox.items.length} item${inbox.items.length === 1 ? "" : "s"} waiting,` + + ` across ${changes} of ${total}: ${waiting}.${stale}`, + }; + } + return said; } /** The trailing sentence naming `taskAgents` entries nothing matched. @@ -196,36 +240,39 @@ export function describeHumanOnlyInbox(inbox: HumanOnlyInbox): string { /** Appended for the same reason as the unmatched entries: a change * whose configuration could not be read still has its items listed, and * a reader who is not told would take the list as complete. */ -function describeUnreadable(unreadable: readonly UnreadableTaskAgentsConfig[] | undefined): string { - if (!unreadable || unreadable.length === 0) return ""; +function describeUnreadable(unreadable: readonly UnreadableTaskAgentsConfig[] | undefined): SaidMessage | undefined { + if (!unreadable || unreadable.length === 0) return undefined; const listed = unreadable.map((entry) => `${entry.changeName} (${entry.reason})`).join("; "); const count = unreadable.length === 1 ? "1 change's" : `${unreadable.length} changes'`; - return ` ${count} harness.json could not be read, so their items are resolved from the task text alone: ${listed}.`; + return say("OSW-TSK-022", { count, listed }); } /** Appended too: a key waiting to be enrolled waits on a person as surely as * an item does, and it is not a task, so it is not in the count. */ -function describeEnrolments(enrolments: readonly EnrolmentRequest[] | undefined): string { - if (!enrolments || enrolments.length === 0) return ""; +function describeEnrolments(enrolments: readonly EnrolmentRequest[] | undefined): SaidMessage | undefined { + if (!enrolments || enrolments.length === 0) return undefined; return enrolments.length === 1 - ? " 1 key that signs a run waits to be enrolled." - : ` ${enrolments.length} keys that sign runs wait to be enrolled.`; + ? say("OSW-TSK-023") + : say("OSW-TSK-024", { count: enrolments.length }); } /** Appended as the keys are: a question holds a run until it is answered, * and it is not a task, so it is not in the count. */ -function describeQuestions(questions: readonly InboxQuestion[] | undefined): string { - if (!questions || questions.length === 0) return ""; +function describeQuestions(questions: readonly InboxQuestion[] | undefined): SaidMessage | undefined { + if (!questions || questions.length === 0) return undefined; return questions.length === 1 - ? " 1 question from an agent waits for your answer." - : ` ${questions.length} questions from agents wait for your answer.`; + ? say("OSW-TSK-025") + : say("OSW-TSK-026", { count: questions.length }); } -function describeUnmatched(unmatched: readonly UnmatchedTaskAgent[] | undefined): string { - if (!unmatched || unmatched.length === 0) return ""; - const listed = unmatched.map(describeUnmatchedTaskAgent).join("; "); +function describeUnmatched(unmatched: readonly UnmatchedTaskAgent[] | undefined): SaidMessage | undefined { + if (!unmatched || unmatched.length === 0) return undefined; + const listed = unmatched.map((entry) => describeUnmatchedTaskAgent(entry).text).join("; "); const count = `${unmatched.length} taskAgents entr${unmatched.length === 1 ? "y" : "ies"}`; - return ` ${count} matched no open task: ${listed}.`; + // TSK-027's text says "taskAgents entry(ies)", where this sentence has + // always said "entry" or "entries"; the carrier's own words are kept + // with the identifier beside them. + return { ...say("OSW-TSK-027", { count: unmatched.length, listed }), text: `${count} matched no open task: ${listed}.` }; } /** The same sentence, over a read that may have failed. @@ -234,8 +281,8 @@ function describeUnmatched(unmatched: readonly UnmatchedTaskAgent[] | undefined) * been: "nothing is waiting" and "nobody could find out" are different * facts about a workspace, and only one of them means the reader can * stop looking. */ -export function describeHumanOnlyInboxState(state: HumanOnlyInboxState): string { +export function describeHumanOnlyInboxState(state: HumanOnlyInboxState): SaidMessage { if (state.status === "loaded") return describeHumanOnlyInbox(state.inbox); const reason = state.reason.trim(); - return `What is waiting could not be read: ${reason.length > 0 ? reason : "no reason given"}.`; + return say("OSW-TSK-028", { reason: reason.length > 0 ? reason : "no reason given" }); } diff --git a/packages/core/src/human-only-inbox.test.ts b/packages/core/src/human-only-inbox.test.ts index b6340eb2..02850167 100644 --- a/packages/core/src/human-only-inbox.test.ts +++ b/packages/core/src/human-only-inbox.test.ts @@ -69,7 +69,10 @@ describe("collectHumanOnlyInbox", () => { expect.objectContaining({ changeName: "change-a", changeDir, questionId: "Q-run1-1", text: "Which database?" }), ]); expect(inbox.questions?.[0]?.lineNumber).toBeGreaterThan(0); - expect(describeHumanOnlyInbox(inbox)).toContain("1 question from an agent waits for your answer."); + expect(describeHumanOnlyInbox(inbox).text).toContain("1 question from an agent waits for your answer."); + // The question's own TSK-025 is folded into the "nothing is waiting" + // sentence, which carries its identifier. + expect(describeHumanOnlyInbox(inbox).code).toBe("OSW-TSK-020"); }); it("reads a question where its run asked it, in the change's own worktree, and lists it once", async () => { @@ -187,26 +190,32 @@ describe("collectHumanOnlyInbox", () => { describe("describeWaitingOn", () => { it("names a person, a registered agent, and an unregistered one differently", () => { - expect(describeWaitingOn({ kind: "person" })).toBe("a person"); - expect(describeWaitingOn({ kind: "agent", agent: "copilot-cli", known: true })).toBe("copilot-cli"); + expect(describeWaitingOn({ kind: "person" })) + .toEqual({ code: "OSW-TSK-015", level: "info", text: "a person" }); + expect(describeWaitingOn({ kind: "agent", agent: "copilot-cli", known: true })) + .toEqual({ text: "copilot-cli" }); expect(describeWaitingOn({ kind: "agent", agent: "nope", known: false })) - .toBe("\"nope\", which is not a registered agent"); + .toEqual({ code: "OSW-TSK-016", level: "warning", text: "\"nope\", which is not a registered agent" }); }); }); describe("describeHumanOnlyInbox", () => { it("tells an empty workspace apart from one where nothing is waiting", () => { // Both show no items, and they are different facts. - expect(describeHumanOnlyInbox({ items: [], changesRead: 0 })).toContain("No active change"); - expect(describeHumanOnlyInbox({ items: [], changesRead: 3 })) - .toContain("Nothing is waiting — 3 active changes read"); + const empty = describeHumanOnlyInbox({ items: [], changesRead: 0 }); + expect(empty.text).toContain("No active change"); + expect(empty.code).toBe("OSW-TSK-019"); + const nothing = describeHumanOnlyInbox({ items: [], changesRead: 3 }); + expect(nothing.text).toContain("Nothing is waiting — 3 active changes read"); + expect(nothing.code).toBe("OSW-TSK-020"); }); it("says how much is waiting and across how many changes", () => { const items = [person("a", 1), person("a", 2), person("b", 1)]; - expect(describeHumanOnlyInbox({ items, changesRead: 6 })) - .toBe("3 items waiting, across 2 of 6 active changes: 3 on a person."); + const said = describeHumanOnlyInbox({ items, changesRead: 6 }); + expect(said.text).toBe("3 items waiting, across 2 of 6 active changes: 3 on a person."); + expect(said.code).toBe("OSW-TSK-021"); }); it("counts what waits on a person apart from what waits on each agent", () => { @@ -217,14 +226,14 @@ describe("describeHumanOnlyInbox", () => { agent("b", 2, "claude-cli"), ]; - expect(describeHumanOnlyInbox({ items, changesRead: 4 })) + expect(describeHumanOnlyInbox({ items, changesRead: 4 }).text) .toBe("4 items waiting, across 2 of 4 active changes: 1 on a person, 2 on copilot-cli, 1 on claude-cli."); }); it("names an unregistered agent as such in the count", () => { const items = [agent("a", 1, "copilto-cli", false)]; - expect(describeHumanOnlyInbox({ items, changesRead: 1 })) + expect(describeHumanOnlyInbox({ items, changesRead: 1 }).text) .toContain("1 on \"copilto-cli\", which is not a registered agent"); }); }); @@ -235,20 +244,21 @@ describe("describeHumanOnlyInboxState", () => { // a block that has not loaded yet, which is the distinction this // surface exists to make. See // a-check-that-passes-checked-something. - expect(describeHumanOnlyInboxState({ status: "failed", reason: "EACCES: permission denied" })) - .toBe("What is waiting could not be read: EACCES: permission denied."); + const said = describeHumanOnlyInboxState({ status: "failed", reason: "EACCES: permission denied" }); + expect(said.text).toBe("What is waiting could not be read: EACCES: permission denied."); + expect(said.code).toBe("OSW-TSK-028"); }); it("says so even when the failure carried no reason", () => { - expect(describeHumanOnlyInboxState({ status: "failed", reason: " " })) + expect(describeHumanOnlyInboxState({ status: "failed", reason: " " }).text) .toBe("What is waiting could not be read: no reason given."); }); it("says what a successful read says", () => { const inbox = { items: [person("a", 1)], changesRead: 2 }; - expect(describeHumanOnlyInboxState({ status: "loaded", inbox })) - .toBe(describeHumanOnlyInbox(inbox)); + expect(describeHumanOnlyInboxState({ status: "loaded", inbox }).text) + .toBe(describeHumanOnlyInbox(inbox).text); }); }); @@ -275,7 +285,7 @@ describe("collectHumanOnlyInbox — a change whose harness.json cannot be read", source: "task-text", }); expect(inbox.unreadableTaskAgents?.map((entry) => entry.changeName)).toEqual(["broken-config"]); - expect(describeHumanOnlyInbox(inbox)).toContain("harness.json could not be read"); - expect(describeHumanOnlyInbox(inbox)).toContain("broken-config"); + expect(describeHumanOnlyInbox(inbox).text).toContain("harness.json could not be read"); + expect(describeHumanOnlyInbox(inbox).text).toContain("broken-config"); }); }); diff --git a/packages/core/src/landed-archive.test.ts b/packages/core/src/landed-archive.test.ts index 365fbadc..9f57009a 100644 --- a/packages/core/src/landed-archive.test.ts +++ b/packages/core/src/landed-archive.test.ts @@ -248,7 +248,7 @@ describe("archiveLandedChanges", () => { const result = await archiveLandedChanges(depsFor(fixture, forge)); - expect(result.followed?.outcome).toEqual({ state: "blocked", reason: "check failed: build (failure)", cause: "check-failed" }); + expect(result.followed?.outcome).toEqual({ state: "blocked", reason: "check failed: build (failure)", cause: "check-failed", code: "OSW-ARC-104" }); expect(forge.mergeNow).not.toHaveBeenCalled(); expect(describeLandedArchive(result)).toEqual(["#650 cannot merge yet: check failed: build (failure)"]); }); @@ -260,7 +260,7 @@ describe("archiveLandedChanges", () => { const result = await archiveLandedChanges(depsFor(fixture, forge)); - expect(result.followed?.outcome).toEqual({ state: "blocked", reason: `TestForge refused the merge: ${refusal}`, cause: "refused" }); + expect(result.followed?.outcome).toEqual({ state: "blocked", reason: `TestForge refused the merge: ${refusal}`, cause: "refused", code: "OSW-ARC-106" }); expect(forge.mergeNow).toHaveBeenCalledTimes(1); }); @@ -316,7 +316,7 @@ describe("archiveLandedChanges", () => { const result = await archiveLandedChanges(depsFor(fixture, forge)); expect(result.due).toEqual([]); - expect(result.owing).toEqual([{ changeName: "landed-owing", pullRequest: 44, owes: ["still open: 1.2 Not yet"] }]); + expect(result.owing).toEqual([{ changeName: "landed-owing", pullRequest: 44, owes: ["still open: 1.2 Not yet"], code: "OSW-ARC-208" }]); expect(describeLandedArchive(result).join(" ")).toContain("landed-owing landed in #44 but still owes an item"); }); @@ -342,7 +342,7 @@ describe("archiveLandedChanges", () => { })); expect(result.opened?.changes).toEqual(["works"]); - expect(result.notArchived).toEqual([{ changeName: "breaks", reason: "openspec archive refused: a spec delta does not apply" }]); + expect(result.notArchived).toEqual([{ changeName: "breaks", reason: "openspec archive refused: a spec delta does not apply", code: "OSW-ARC-207" }]); }); it("says why where the forge cannot be asked", async () => { @@ -352,6 +352,7 @@ describe("archiveLandedChanges", () => { const result = await archiveLandedChanges(depsFor(fixture, forge)); expect(result.failed).toBe("TestForge could not be asked about pull requests: gh is not signed in"); + expect(result.failedCode).toBe("OSW-ARC-102"); }); it("says what it opened, and that the sweep merges it", async () => { @@ -377,6 +378,7 @@ describe("archiveLandedChanges", () => { const result = await archiveLandedChanges(depsFor(fixture, forge, { archive: async () => undefined })); expect(result.failed).toBe("archiving changed nothing, so there was nothing to commit"); + expect(result.failedCode).toBe("OSW-ARC-209"); expect(forge.openPullRequest).not.toHaveBeenCalled(); expect(await git(fixture.remote, ["branch", "--list", `${LANDED_ARCHIVE_BRANCH_PREFIX}*`])).toBe(""); }); @@ -399,6 +401,7 @@ describe("archiveLandedChanges", () => { })); expect(result.failed).toBe("remote rejected: protected branch"); + expect(result.failedCode).toBe("OSW-ARC-209"); expect(forge.openPullRequest).not.toHaveBeenCalled(); expect(await exists(made)).toBe(false); expect(await git(fixture.work, ["branch", "--list", `${LANDED_ARCHIVE_BRANCH_PREFIX}*`])).toBe(""); @@ -423,6 +426,7 @@ describe("archiveLandedChanges", () => { })); expect(result.failed).toBe("! [rejected] archive-landed-x -> archive-landed-x (fetch first); error: failed to push some refs to 'https://github.com/owner/repo.git'"); + expect(result.failedCode).toBe("OSW-ARC-209"); }); }); diff --git a/packages/core/src/landed-archive.ts b/packages/core/src/landed-archive.ts index f61fb76d..0f8fa574 100644 --- a/packages/core/src/landed-archive.ts +++ b/packages/core/src/landed-archive.ts @@ -16,6 +16,7 @@ import type { GitWrapper } from "./git.js"; import { isMergeMethodRefusal, PENDING_REASON, type Forge, type MergeMethod, type PullRequestRef } from "./gh-pr-gateway.js"; +import { say, type MessageCode } from "./message-register.js"; import { describeTaskDebts, owesNothing, parseTaskChecklist } from "./task-checklist.js"; const CHANGES = "openspec/changes"; @@ -65,13 +66,15 @@ export interface LandedArchiveResult { outcome: ArchiveFollowOutcome; }; /** A change that was due and was not archived, and why. */ - notArchived: Array<{ changeName: string; reason: string }>; + notArchived: Array<{ changeName: string; reason: string; code: MessageCode }>; /** A change whose own pull request has merged while it still owes * something: never archived, and said, because the merge gate should * have made it impossible. */ - owing: Array<{ changeName: string; pullRequest: number; owes: string[] }>; + owing: Array<{ changeName: string; pullRequest: number; owes: string[]; code: MessageCode }>; /** Why the pass could do nothing at all, where it had something to do. */ failed?: string; + /** The register's identifier for the words in `failed`. */ + failedCode?: MessageCode; } /** What following an open archive pull request came to. @@ -86,7 +89,7 @@ export interface LandedArchiveResult { export type ArchiveFollowOutcome = | { state: "merged"; method: MergeMethod } | { state: "waiting" } - | { state: "blocked"; reason: string; cause: "check-failed" | "checks-unreadable" | "refused" } + | { state: "blocked"; reason: string; cause: "check-failed" | "checks-unreadable" | "refused"; code: MessageCode } | { state: "rebuilt"; behind: number }; /** How often a host sweeps while an archive pull request is open: its @@ -113,11 +116,23 @@ export async function followArchivePullRequest(forge: Forge, number: number): Pr try { checks = await forge.checksOf(number); } catch (error) { - return { state: "blocked", reason: `its checks could not be read: ${failureReason(error)}`, cause: "checks-unreadable" }; + return { + state: "blocked", + reason: say("OSW-ARC-103", { reason: failureReason(error) }).text, + cause: "checks-unreadable", + code: "OSW-ARC-103", + }; } if (checks.state === "none" && checks.reason === PENDING_REASON) return { state: "waiting" }; - if (checks.state === "fail") return { state: "blocked", reason: checks.reason ?? "a check failed", cause: "check-failed" }; - let reason = "no merge method was tried"; + if (checks.state === "fail") { + return { + state: "blocked", + reason: checks.reason ?? say("OSW-ARC-104").text, + cause: "check-failed", + code: "OSW-ARC-104", + }; + } + let reason = say("OSW-ARC-105").text; for (const method of ARCHIVE_MERGE_METHODS) { try { await forge.mergeNow(number, method); @@ -127,7 +142,12 @@ export async function followArchivePullRequest(forge: Forge, number: number): Pr if (!isMergeMethodRefusal(reason)) break; } } - return { state: "blocked", reason: `${forge.name} refused the merge: ${reason}`, cause: "refused" }; + return { + state: "blocked", + reason: say("OSW-ARC-106", { forge: forge.name, reason }).text, + cause: "refused", + code: "OSW-ARC-106", + }; } function stamp(now: Date): string { @@ -160,7 +180,7 @@ export function failureReason(error: unknown): string { const all = text.split(String.fromCharCode(10)).map((line) => line.trim()).filter((line) => line.length > 0); const telling = all.filter((line) => TELLING_GIT_LINE.test(line)); if (telling.length > 0) return telling.slice(0, 2).join("; "); - return all[0] ?? "no reason given"; + return all[0] ?? say("OSW-ARC-108").text; } @@ -192,7 +212,7 @@ async function makeArchive( await deps.archive(name, directory.path); archived.push(name); } catch (error) { - notArchived.push({ changeName: name, reason: failureReason(error) }); + notArchived.push({ changeName: name, reason: failureReason(error), code: "OSW-ARC-207" }); } } if (archived.length === 0) return archived; @@ -210,7 +230,7 @@ async function makeArchive( // staged. The branch would then be the default branch under another // name, and a pull request of it would archive nothing // (the-sweep-finishes-what-it-starts). - if (!committed.commit) throw new Error("archiving changed nothing, so there was nothing to commit"); + if (!committed.commit) throw new Error(say("OSW-ARC-101").text); await push(git); return archived; } finally { @@ -286,14 +306,17 @@ export async function archiveLandedChanges(deps: LandedArchiveDeps): Promise 0) result.failed = `${deps.forge.name} could not be asked about pull requests: ${pullRequests.reason}`; + if (closed.length > 0) { + result.failed = say("OSW-ARC-102", { forge: deps.forge.name, reason: pullRequests.reason }).text; + result.failedCode = "OSW-ARC-102"; + } return result; } const byBranch = pullRequests.byBranch; for (const [name, owes] of debts) { const own = byBranch.get(name); - if (own?.state === "MERGED") result.owing.push({ changeName: name, pullRequest: own.number, owes }); + if (own?.state === "MERGED") result.owing.push({ changeName: name, pullRequest: own.number, owes, code: "OSW-ARC-208" }); } for (const name of closed) { @@ -324,7 +347,11 @@ export async function archiveLandedChanges(deps: LandedArchiveDeps): Promise git.pushWithLease(remote, branch, leased)); if (archived.length > 0) outcome = { state: "rebuilt", behind }; } catch (error) { - outcome = { ...refused, reason: `${refused.reason}; making it again on ${ref} failed too: ${failureReason(error)}` }; + outcome = { + ...refused, + reason: say("OSW-ARC-107", { reason: refused.reason, ref, failure: failureReason(error) }).text, + code: "OSW-ARC-107", + }; } } } @@ -355,33 +382,45 @@ export async function archiveLandedChanges(deps: LandedArchiveDeps): Promise { if (outcome.kind === "refused") { expect(outcome.holder.author).toBe("ada@example.com"); expect(outcome.reason).toContain("still running"); + expect(outcome.code).toBe("OSW-WSP-003"); // The remedy is stopping it, not taking its lease. expect(outcome.reason).toContain("stop that process"); } @@ -116,6 +117,7 @@ describe("releaseWorkspaceLease", () => { if (outcome.kind === "refused") { expect(outcome.reason).toContain("some-other-box"); expect(outcome.reason).toContain("cannot be checked from here"); + expect(outcome.code).toBe("OSW-WSP-002"); } // A pid on another machine says nothing about a process on this one, // so it is never even asked about. diff --git a/packages/core/src/lease-release.ts b/packages/core/src/lease-release.ts index 7b5c0823..bb36b4af 100644 --- a/packages/core/src/lease-release.ts +++ b/packages/core/src/lease-release.ts @@ -15,6 +15,7 @@ import { access, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { say, type MessageCode } from "./message-register.js"; import { WORKSPACE_LEASE_STALE_AFTER_MS, readWorkspaceLeaseHolder, @@ -25,7 +26,7 @@ export type LeaseReleaseOutcome = /** The lease file is gone, or there was none to begin with. */ | { kind: "cleared"; holder?: WorkspaceLeaseConflict; because: "stale" | "process-gone" | "already-free" } /** The holder could not be shown to be gone, so nothing was touched. */ - | { kind: "refused"; holder: WorkspaceLeaseConflict; reason: string }; + | { kind: "refused"; holder: WorkspaceLeaseConflict; reason: string; code: MessageCode }; /** Whether a process exists, without signalling it. * @@ -70,22 +71,14 @@ export async function releaseWorkspaceLease(options: LeaseReleaseOptions): Promi const hostname = options.hostname ?? os.hostname(); if (holder.hostname !== hostname) { - return { - kind: "refused", - holder, - reason: `the holder is on ${holder.hostname} and this is ${hostname}, so whether its process is still` - + " running cannot be checked from here. Clear it there, or wait for its lease to go stale.", - }; + const said = say("OSW-WSP-002", { holderhostname: holder.hostname, thishostname: hostname }); + return { kind: "refused", holder, reason: said.text, code: said.code }; } const isRunning = options.isRunning ?? isProcessRunning; if (isRunning(holder.pid)) { - return { - kind: "refused", - holder, - reason: `process ${holder.pid} is still running. Taking its lease would let a second mutating run start` - + " against files it still has open, which is what the lease prevents — stop that process instead.", - }; + const said = say("OSW-WSP-003", { pid: holder.pid }); + return { kind: "refused", holder, reason: said.text, code: said.code }; } await removeIfPresent(filePath); diff --git a/packages/core/src/machine-key.test.ts b/packages/core/src/machine-key.test.ts index 6e38416d..6c88a6ca 100644 --- a/packages/core/src/machine-key.test.ts +++ b/packages/core/src/machine-key.test.ts @@ -1,9 +1,10 @@ -import { createPublicKey, verify } from "node:crypto"; +import { createPublicKey, generateKeyPairSync, verify } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { loadOrCreateMachineKey, machineKeyDirectory } from "./machine-key.js"; +import { say } from "./message-register.js"; // every-varying-check-has-a-budget: an Ed25519 pair and a few small files in // a temporary home. No git, no agent. @@ -79,4 +80,16 @@ describe("loadOrCreateMachineKey (a-run-is-signed-by-its-person 1.3)", () => { await expect(loadOrCreateMachineKey({ homeDir })).rejects.toBeInstanceOf(Error); }); + + it("refuses a private file holding another kind of key, in the register's words", async () => { + const homeDir = await temporaryHome(); + await mkdir(machineKeyDirectory(homeDir), { recursive: true }); + const privateFile = path.join(machineKeyDirectory(homeDir), "ed25519.pem"); + const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }) + .privateKey.export({ type: "pkcs8", format: "pem" }) + .toString(); + await writeFile(privateFile, rsa, "utf8"); + + await expect(loadOrCreateMachineKey({ homeDir })).rejects.toThrow(say("OSW-KEY-030", { file: privateFile }).text); + }); }); diff --git a/packages/core/src/machine-key.ts b/packages/core/src/machine-key.ts index 7754c3ac..0a1087bb 100644 --- a/packages/core/src/machine-key.ts +++ b/packages/core/src/machine-key.ts @@ -9,6 +9,7 @@ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, ran import { link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { say } from "./message-register.js"; const PRIVATE_FILE = "ed25519.pem"; const PUBLIC_FILE = "ed25519.pub.pem"; @@ -45,7 +46,7 @@ function errorCode(error: unknown): string | undefined { function toMachineKey(privateKey: KeyObject, file: string): MachineKey { if (privateKey.asymmetricKeyType !== "ed25519") { - throw new Error(`the key at ${file} is not an Ed25519 key`); + throw new Error(say("OSW-KEY-030", { file }).text); } const publicKey = createPublicKey(privateKey); return { @@ -129,7 +130,7 @@ export async function loadOrCreateMachineKey(options: MachineKeyOptions = {}): P await rm(temporary, { force: true }).catch(() => undefined); } privateKey = await readPrivateKey(privateFile); - if (privateKey === undefined) throw new Error(`the key at ${privateFile} was removed as it was made`); + if (privateKey === undefined) throw new Error(say("OSW-KEY-031", { file: privateFile }).text); } const key = toMachineKey(privateKey, privateFile); diff --git a/packages/core/src/mechanical-checks.test.ts b/packages/core/src/mechanical-checks.test.ts index 45731559..ed0fd3d5 100644 --- a/packages/core/src/mechanical-checks.test.ts +++ b/packages/core/src/mechanical-checks.test.ts @@ -94,7 +94,11 @@ describe("validate-change", () => { const result = await runMechanicalCheck("validate-change", undefined, makeCtx("C:\\repo")); - expect(result).toEqual({ pass: true, reason: "openspec change validate --strict sample-change: 1 item(s) passed" }); + expect(result).toEqual({ + pass: true, + reason: "openspec change validate --strict sample-change: 1 item(s) passed", + code: "OSW-VAL-102", + }); }); it("fails and names the failing item when validation reports a failure", async () => { @@ -106,6 +110,7 @@ describe("validate-change", () => { const result = await runMechanicalCheck("validate-change", undefined, makeCtx("C:\\repo")); expect(result.pass).toBe(false); + expect(result.code).toBe("OSW-VAL-101"); expect(result.reason).toContain("spec:agentic-harness"); expect(result.reason).toContain("1 item(s) failed"); }); @@ -118,7 +123,7 @@ describe("typecheck / test / lint", () => { const result = await runMechanicalCheck(name, undefined, makeCtx("C:\\repo")); - expect(result).toEqual({ pass: true, reason: `npm run ${name} exited 0` }); + expect(result).toEqual({ pass: true, reason: `npm run ${name} exited 0`, code: "OSW-VAL-105" }); }); it(`${name} fails and includes the tail of output when "npm run ${name}" exits nonzero`, async () => { @@ -127,6 +132,7 @@ describe("typecheck / test / lint", () => { const result = await runMechanicalCheck(name, undefined, makeCtx("C:\\repo")); expect(result.pass).toBe(false); + expect(result.code).toBe("OSW-VAL-106"); expect(result.reason).toContain(`npm run ${name} exited 1`); expect(result.reason).toContain("boom error"); }); @@ -138,6 +144,7 @@ describe("path-unchanged", () => { const result = await runMechanicalCheck("path-unchanged", undefined, makeCtx("C:\\repo")); expect(result.pass).toBe(false); + expect(result.code).toBe("OSW-VAL-001"); expect(spawnMock).not.toHaveBeenCalled(); }); @@ -152,6 +159,7 @@ describe("path-unchanged", () => { const result = await runMechanicalCheck("path-unchanged", "../outside-the-workspace", makeCtx(root)); expect(result.pass).toBe(false); + expect(result.code).toBe("OSW-VAL-002"); expect(result.reason).toMatch(/outside the workspace/); expect(spawnMock).not.toHaveBeenCalled(); }); @@ -166,6 +174,7 @@ describe("path-unchanged", () => { const result = await runMechanicalCheck("path-unchanged", "/etc/passwd", makeCtx(root)); expect(result.pass).toBe(false); + expect(result.code).toBe("OSW-VAL-002"); expect(result.reason).toMatch(/outside the workspace/); expect(spawnMock).not.toHaveBeenCalled(); }); @@ -177,6 +186,7 @@ describe("path-unchanged", () => { const result = await runMechanicalCheck("path-unchanged", "packages/core/src/agents", makeCtx(root)); expect(result.pass).toBe(true); + expect(result.code).toBe("OSW-VAL-108"); }); it("fails when `git diff --quiet` reports changes (exit 1)", async () => { @@ -186,6 +196,7 @@ describe("path-unchanged", () => { const result = await runMechanicalCheck("path-unchanged", "packages/core/src/agents", makeCtx(root)); expect(result.pass).toBe(false); + expect(result.code).toBe("OSW-VAL-109"); expect(result.reason).toMatch(/packages[/\\]core[/\\]src[/\\]agents/); }); }); @@ -196,7 +207,7 @@ describe("changeset-present", () => { const result = await runMechanicalCheck("changeset-present", undefined, makeCtx("C:\\repo")); - expect(result).toEqual({ pass: true, reason: "2 pending changeset file(s) found" }); + expect(result).toEqual({ pass: true, reason: "2 pending changeset file(s) found", code: "OSW-VAL-113" }); }); it("fails when Changesets is not adopted", async () => { @@ -205,6 +216,7 @@ describe("changeset-present", () => { const result = await runMechanicalCheck("changeset-present", undefined, makeCtx("C:\\repo")); expect(result.pass).toBe(false); + expect(result.code).toBe("OSW-VAL-111"); expect(result.reason).toMatch(/not adopted/); }); @@ -214,6 +226,7 @@ describe("changeset-present", () => { const result = await runMechanicalCheck("changeset-present", undefined, makeCtx("C:\\repo")); expect(result.pass).toBe(false); + expect(result.code).toBe("OSW-VAL-112"); expect(result.reason).toMatch(/no pending/); }); }); diff --git a/packages/core/src/mechanical-checks.ts b/packages/core/src/mechanical-checks.ts index 77bc991c..5ed6cdaa 100644 --- a/packages/core/src/mechanical-checks.ts +++ b/packages/core/src/mechanical-checks.ts @@ -10,6 +10,7 @@ import crossSpawn from "cross-spawn"; import path from "node:path"; import { checkChangesetReminder } from "./changeset-reminder.js"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; import { validateChange } from "./openspec.js"; import { checkCwdSandbox } from "./security.js"; @@ -30,8 +31,11 @@ export type MechanicalCheckName = (typeof MECHANICAL_CHECK_NAMES)[number]; export interface MechanicalCheckResult { pass: boolean; /** Always present, on both pass and fail — task 1.4: "which command, - * which path, what came back", not "check failed". */ + * which path, what came back", not "check failed". The words are the + * register's (ADR 0046); `code` is their identifier. */ reason: string; + /** The identifier of the message in `reason`. */ + code: MessageCode; } export interface MechanicalCheckContext { @@ -51,6 +55,12 @@ export interface MechanicalCheckContext { scripts?: Partial>; } +/** One check's result: the register's words, and the identifier beside + * them. */ +function saidResult(pass: boolean, said: SaidMessage): MechanicalCheckResult { + return { pass, code: said.code, reason: said.text }; +} + function runCommand( cwd: string, binary: string, @@ -78,74 +88,94 @@ function tail(text: string, lines = 20): string { } async function checkValidateChange(ctx: MechanicalCheckContext): Promise { - const label = `openspec change validate --strict ${ctx.changeName}`; try { - const result = await validateChange(ctx.changeName, { cwd: ctx.workspaceRoot }); - if (result.summary.totals.failed > 0) { - const failedIds = result.items.filter((item) => !item.valid).map((item) => item.id).join(", "); - return { pass: false, reason: `${label}: ${result.summary.totals.failed} item(s) failed (${failedIds})` }; + const validation = await validateChange(ctx.changeName, { cwd: ctx.workspaceRoot }); + if (validation.summary.totals.failed > 0) { + const failedIds = validation.items.filter((item) => !item.valid).map((item) => item.id).join(", "); + return saidResult(false, say("OSW-VAL-101", { + change: ctx.changeName, + count: validation.summary.totals.failed, + ids: failedIds, + })); } - return { pass: true, reason: `${label}: ${result.summary.totals.passed} item(s) passed` }; + return saidResult(true, say("OSW-VAL-102", { + change: ctx.changeName, + count: validation.summary.totals.passed, + })); } catch (error) { - return { pass: false, reason: `${label} failed to run: ${error instanceof Error ? error.message : String(error)}` }; + return saidResult(false, say("OSW-VAL-103", { + change: ctx.changeName, + error: error instanceof Error ? error.message : String(error), + })); } } async function runNpmScript(ctx: MechanicalCheckContext, script: string): Promise { - const label = `npm run ${script}`; let outcome: { code: number | null; stdout: string; stderr: string }; try { outcome = await runCommand(ctx.workspaceRoot, "npm", ["run", script]); } catch (error) { - return { pass: false, reason: `${label} failed to run: ${error instanceof Error ? error.message : String(error)}` }; + return saidResult(false, say("OSW-VAL-104", { + script, + error: error instanceof Error ? error.message : String(error), + })); } - if (outcome.code === 0) return { pass: true, reason: `${label} exited 0` }; + if (outcome.code === 0) return saidResult(true, say("OSW-VAL-105", { script })); const output = tail(outcome.stderr || outcome.stdout); - return { pass: false, reason: `${label} exited ${outcome.code ?? "unknown"}:\n${output}` }; + return saidResult(false, say("OSW-VAL-106", { script, code: outcome.code ?? "unknown", output })); } /** Resolves `relativePath` against `workspaceRoot` and confirms it stays * inside the workspace — same check `checkCwdSandbox` performs for a * `cwd`, applied here to a `path-unchanged` parameter (task 1.3). */ -function resolveWithinWorkspace(workspaceRoot: string, relativePath: string): { path: string } | { reason: string } { +function resolveWithinWorkspace( + workspaceRoot: string, + relativePath: string, +): { path: string } | { refusal: SaidMessage } { const resolved = path.resolve(workspaceRoot, relativePath); const decision = checkCwdSandbox(resolved, workspaceRoot); if (!decision.allowed) { - return { reason: `path "${relativePath}" resolves outside the workspace "${workspaceRoot}"` }; + return { refusal: say("OSW-VAL-002", { path: relativePath, root: workspaceRoot }) }; } return { path: resolved }; } async function checkPathUnchanged(ctx: MechanicalCheckContext, param: string | undefined): Promise { if (!param) { - return { pass: false, reason: `"path-unchanged" requires a repository-relative path parameter` }; + return saidResult(false, say("OSW-VAL-001")); } const resolved = resolveWithinWorkspace(ctx.workspaceRoot, param); - if ("reason" in resolved) { - return { pass: false, reason: resolved.reason }; + if ("refusal" in resolved) { + return saidResult(false, resolved.refusal); } const relativeFromRoot = path.relative(ctx.workspaceRoot, resolved.path); - const label = `git diff -- ${relativeFromRoot}`; let outcome: { code: number | null; stdout: string; stderr: string }; try { outcome = await runCommand(ctx.workspaceRoot, "git", ["diff", "--quiet", "--", relativeFromRoot]); } catch (error) { - return { pass: false, reason: `${label} failed to run: ${error instanceof Error ? error.message : String(error)}` }; + return saidResult(false, say("OSW-VAL-107", { + path: relativeFromRoot, + error: error instanceof Error ? error.message : String(error), + })); } - if (outcome.code === 0) return { pass: true, reason: `${label} reported no changes` }; - if (outcome.code === 1) return { pass: false, reason: `${label} reported uncommitted changes to "${relativeFromRoot}"` }; - return { pass: false, reason: `${label} exited ${outcome.code ?? "unknown"}: ${tail(outcome.stderr || outcome.stdout)}` }; + if (outcome.code === 0) return saidResult(true, say("OSW-VAL-108", { path: relativeFromRoot })); + if (outcome.code === 1) return saidResult(false, say("OSW-VAL-109", { path: relativeFromRoot })); + return saidResult(false, say("OSW-VAL-110", { + path: relativeFromRoot, + code: outcome.code ?? "unknown", + output: tail(outcome.stderr || outcome.stdout), + })); } async function checkChangesetPresent(ctx: MechanicalCheckContext): Promise { const status = await checkChangesetReminder(ctx.workspaceRoot); if (!status.changesetsAdopted) { - return { pass: false, reason: `no ".changeset/config.json" found under "${ctx.workspaceRoot}" — Changesets is not adopted` }; + return saidResult(false, say("OSW-VAL-111", { root: ctx.workspaceRoot })); } if (status.pendingChangesetCount === 0) { - return { pass: false, reason: `no pending ".changeset/*.md" file found — run "npx changeset" for "${ctx.changeName}"` }; + return saidResult(false, say("OSW-VAL-112", { change: ctx.changeName })); } - return { pass: true, reason: `${status.pendingChangesetCount} pending changeset file(s) found` }; + return saidResult(true, say("OSW-VAL-113", { count: status.pendingChangesetCount })); } /** The registry itself — the single place mapping a check name to the @@ -181,7 +211,10 @@ export async function runMechanicalCheck( ): Promise { const check = MECHANICAL_CHECKS[name]; if (!check) { - throw new Error(`Unknown mechanical check "${name}" (expected one of: ${MECHANICAL_CHECK_NAMES.join(", ")})`); + throw new Error(say("OSW-VAL-003", { + name, + names: MECHANICAL_CHECK_NAMES.join(", "), + }).text); } return check(ctx, param); } diff --git a/packages/core/src/message-register.test.ts b/packages/core/src/message-register.test.ts index d7f7e404..629124f2 100644 --- a/packages/core/src/message-register.test.ts +++ b/packages/core/src/message-register.test.ts @@ -26,30 +26,414 @@ const REPOSITORY = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", * added here as well as to the register; nothing is ever taken out, so an * identifier once given can be neither dropped nor given again. */ const GIVEN = [ - "OSW-CHG-001", "OSW-CHG-002", "OSW-CHG-003", - "OSW-CLI-001", "OSW-CLI-002", "OSW-CLI-003", "OSW-CLI-004", "OSW-CLI-005", "OSW-CLI-006", "OSW-CLI-007", "OSW-CLI-008", - "OSW-CLI-009", "OSW-CLI-010", "OSW-CLI-011", "OSW-CLI-012", "OSW-CLI-013", "OSW-CLI-014", "OSW-CLI-015", "OSW-CLI-016", - "OSW-CLI-017", "OSW-CLI-018", - "OSW-RUN-001", "OSW-RUN-002", "OSW-RUN-003", "OSW-RUN-004", "OSW-RUN-005", "OSW-RUN-006", - "OSW-RUN-101", "OSW-RUN-102", "OSW-RUN-103", "OSW-RUN-104", - "OSW-RUN-201", "OSW-RUN-202", "OSW-RUN-203", "OSW-RUN-204", "OSW-RUN-205", "OSW-RUN-206", "OSW-RUN-207", - "OSW-RUN-208", "OSW-RUN-209", "OSW-RUN-210", "OSW-RUN-211", "OSW-RUN-212", - "OSW-QST-001", "OSW-QST-002", "OSW-QST-003", "OSW-QST-101", "OSW-QST-102", "OSW-QST-201", - "OSW-PRM-101", "OSW-PRM-102", - "OSW-GIT-001", "OSW-GIT-002", "OSW-GIT-101", "OSW-GIT-102", "OSW-GIT-201", "OSW-GIT-202", "OSW-GIT-203", "OSW-GIT-103", - "OSW-PRC-001", "OSW-PRC-002", "OSW-PRC-003", "OSW-PRC-004", "OSW-PRC-005", - "OSW-PRC-006", "OSW-PRC-007", "OSW-PRC-008", "OSW-PRC-009", "OSW-PRC-010", + "OSW-CHG-001", + "OSW-CHG-002", + "OSW-CHG-003", + "OSW-CLI-001", + "OSW-CLI-002", + "OSW-CLI-003", + "OSW-CLI-004", + "OSW-CLI-005", + "OSW-CLI-006", + "OSW-CLI-007", + "OSW-CLI-008", + "OSW-CLI-009", + "OSW-CLI-010", + "OSW-CLI-011", + "OSW-CLI-012", + "OSW-CLI-013", + "OSW-CLI-014", + "OSW-CLI-015", + "OSW-CLI-016", + "OSW-CLI-017", + "OSW-CLI-018", + "OSW-RUN-001", + "OSW-RUN-002", + "OSW-RUN-003", + "OSW-RUN-004", + "OSW-RUN-005", + "OSW-RUN-006", + "OSW-RUN-101", + "OSW-RUN-102", + "OSW-RUN-103", + "OSW-RUN-104", + "OSW-RUN-201", + "OSW-RUN-202", + "OSW-RUN-203", + "OSW-RUN-204", + "OSW-RUN-205", + "OSW-RUN-206", + "OSW-RUN-207", + "OSW-RUN-208", + "OSW-RUN-209", + "OSW-RUN-210", + "OSW-RUN-211", + "OSW-RUN-212", + "OSW-QST-001", + "OSW-QST-002", + "OSW-QST-003", + "OSW-QST-101", + "OSW-QST-102", + "OSW-QST-201", + "OSW-PRM-101", + "OSW-PRM-102", + "OSW-GIT-001", + "OSW-GIT-002", + "OSW-GIT-101", + "OSW-GIT-102", + "OSW-GIT-103", + "OSW-GIT-201", + "OSW-GIT-202", + "OSW-GIT-203", + "OSW-PRC-001", + "OSW-PRC-002", + "OSW-PRC-003", + "OSW-PRC-004", + "OSW-PRC-005", + "OSW-PRC-006", + "OSW-PRC-007", + "OSW-PRC-008", + "OSW-PRC-009", + "OSW-PRC-010", + "OSW-HRN-001", + "OSW-HRN-002", + "OSW-HRN-003", + "OSW-HRN-004", + "OSW-HRN-005", + "OSW-HRN-006", + "OSW-HRN-007", + "OSW-HRN-008", + "OSW-HRN-009", + "OSW-HRN-010", + "OSW-HRN-011", + "OSW-HRN-012", + "OSW-HRN-013", + "OSW-HRN-014", + "OSW-HRN-015", + "OSW-HRN-016", + "OSW-HRN-017", + "OSW-HRN-018", + "OSW-HRN-019", + "OSW-HRN-020", + "OSW-HRN-021", + "OSW-HRN-022", + "OSW-HRN-023", + "OSW-HRN-024", + "OSW-HRN-025", + "OSW-HRN-026", + "OSW-HRN-027", + "OSW-HRN-028", + "OSW-HRN-029", + "OSW-HRN-030", + "OSW-HRN-031", + "OSW-HRN-032", + "OSW-HRN-033", + "OSW-HRN-034", + "OSW-HRN-035", + "OSW-HRN-036", + "OSW-HRN-037", + "OSW-HRN-038", + "OSW-HRN-039", + "OSW-HRN-040", + "OSW-HRN-041", + "OSW-HRN-042", + "OSW-HRN-043", + "OSW-HRN-044", + "OSW-HRN-045", + "OSW-HRN-046", + "OSW-HRN-047", + "OSW-HRN-048", + "OSW-HRN-049", + "OSW-HRN-050", + "OSW-HRN-051", + "OSW-HRN-052", + "OSW-HRN-053", + "OSW-HRN-054", + "OSW-HRN-055", + "OSW-HRN-056", + "OSW-HRN-057", + "OSW-HRN-058", + "OSW-HRN-059", + "OSW-HRN-060", + "OSW-HRN-061", + "OSW-HRN-062", + "OSW-HRN-063", + "OSW-HRN-064", + "OSW-HRN-065", + "OSW-HRN-066", + "OSW-HRN-067", + "OSW-HRN-068", + "OSW-HRN-069", + "OSW-HRN-070", + "OSW-HRN-071", + "OSW-HRN-072", + "OSW-HRN-073", + "OSW-HRN-074", + "OSW-HRN-075", + "OSW-HRN-076", + "OSW-HRN-077", + "OSW-HRN-078", + "OSW-HRN-079", + "OSW-HRN-080", + "OSW-HRN-081", + "OSW-HRN-082", + "OSW-HRN-083", + "OSW-HRN-084", + "OSW-HRN-085", + "OSW-HRN-086", + "OSW-HRN-087", + "OSW-HRN-088", + "OSW-HRN-089", + "OSW-HRN-090", + "OSW-HRN-091", + "OSW-HRN-092", + "OSW-HRN-093", + "OSW-HRN-094", + "OSW-HRN-095", + "OSW-HRN-096", + "OSW-HRN-097", + "OSW-HRN-098", + "OSW-HRN-099", + "OSW-HRN-100", + "OSW-HRN-101", + "OSW-HRN-102", + "OSW-HRN-103", + "OSW-HRN-104", + "OSW-HRN-105", + "OSW-HRN-106", + "OSW-HRN-107", + "OSW-HRN-108", + "OSW-HRN-109", + "OSW-HRN-110", + "OSW-HRN-111", + "OSW-HRN-112", + "OSW-HRN-113", + "OSW-HRN-114", + "OSW-HRN-115", + "OSW-HRN-116", + "OSW-HRN-117", + "OSW-HRN-118", + "OSW-HRN-119", + "OSW-HRN-120", + "OSW-HRN-121", + "OSW-HRN-122", + "OSW-HRN-123", + "OSW-HRN-124", + "OSW-HRN-125", + "OSW-HRN-126", + "OSW-HRN-127", + "OSW-AGT-001", + "OSW-AGT-002", + "OSW-AGT-003", + "OSW-AGT-004", + "OSW-AGT-005", + "OSW-AGT-006", + "OSW-AGT-007", + "OSW-AGT-008", + "OSW-AGT-009", + "OSW-AGT-010", + "OSW-AGT-101", + "OSW-AGT-102", + "OSW-AGT-103", + "OSW-AGT-104", + "OSW-AGT-201", + "OSW-AGT-202", + "OSW-AGT-203", + "OSW-AGT-204", + "OSW-AGT-205", + "OSW-AGT-206", + "OSW-AGT-207", + "OSW-AGT-208", + "OSW-AGT-209", + "OSW-AGT-210", + "OSW-AGT-211", + "OSW-AGT-212", + "OSW-AGT-213", + "OSW-AGT-214", + "OSW-AGT-215", + "OSW-AGT-216", + "OSW-AGT-217", + "OSW-AGT-218", + "OSW-AGT-219", + "OSW-AGT-220", + "OSW-AGT-221", + "OSW-TSK-001", + "OSW-TSK-002", + "OSW-TSK-003", + "OSW-TSK-004", + "OSW-TSK-005", + "OSW-TSK-006", + "OSW-TSK-007", + "OSW-TSK-008", + "OSW-TSK-009", + "OSW-TSK-010", + "OSW-TSK-011", + "OSW-TSK-012", + "OSW-TSK-013", + "OSW-TSK-014", + "OSW-TSK-015", + "OSW-TSK-016", + "OSW-TSK-017", + "OSW-TSK-018", + "OSW-TSK-019", + "OSW-TSK-020", + "OSW-TSK-021", + "OSW-TSK-022", + "OSW-TSK-023", + "OSW-TSK-024", + "OSW-TSK-025", + "OSW-TSK-026", + "OSW-TSK-027", + "OSW-TSK-028", + "OSW-TSK-029", + "OSW-TSK-030", + "OSW-TSK-031", + "OSW-TSK-032", + "OSW-TSK-033", + "OSW-TSK-034", + "OSW-TSK-035", + "OSW-TSK-036", + "OSW-TSK-101", + "OSW-VAL-001", + "OSW-VAL-002", + "OSW-VAL-003", + "OSW-VAL-004", + "OSW-VAL-005", + "OSW-VAL-006", + "OSW-VAL-007", + "OSW-VAL-008", + "OSW-VAL-009", + "OSW-VAL-010", + "OSW-VAL-101", + "OSW-VAL-102", + "OSW-VAL-103", + "OSW-VAL-104", + "OSW-VAL-105", + "OSW-VAL-106", + "OSW-VAL-107", + "OSW-VAL-108", + "OSW-VAL-109", + "OSW-VAL-110", + "OSW-VAL-111", + "OSW-VAL-112", + "OSW-VAL-113", + "OSW-VAL-114", + "OSW-VAL-115", + "OSW-VAL-116", + "OSW-VAL-117", + "OSW-VAL-118", + "OSW-VAL-119", + "OSW-VAL-120", + "OSW-VAL-121", + "OSW-ARC-001", + "OSW-ARC-002", + "OSW-ARC-003", + "OSW-ARC-004", + "OSW-ARC-005", + "OSW-ARC-006", + "OSW-ARC-007", + "OSW-ARC-101", + "OSW-ARC-102", + "OSW-ARC-103", + "OSW-ARC-104", + "OSW-ARC-105", + "OSW-ARC-106", + "OSW-ARC-107", + "OSW-ARC-108", + "OSW-ARC-201", + "OSW-ARC-202", + "OSW-ARC-203", + "OSW-ARC-204", + "OSW-ARC-205", + "OSW-ARC-206", + "OSW-ARC-207", + "OSW-ARC-208", + "OSW-ARC-209", + "OSW-WSP-001", + "OSW-WSP-002", + "OSW-WSP-003", + "OSW-WSP-004", + "OSW-WSP-005", + "OSW-WSP-006", + "OSW-WSP-007", + "OSW-WSP-008", + "OSW-WSP-009", + "OSW-WSP-101", + "OSW-WSP-102", + "OSW-WSP-103", + "OSW-WSP-104", + "OSW-WSP-105", + "OSW-KEY-001", + "OSW-KEY-002", + "OSW-KEY-003", + "OSW-KEY-004", + "OSW-KEY-005", + "OSW-KEY-006", + "OSW-KEY-007", + "OSW-KEY-008", + "OSW-KEY-009", + "OSW-KEY-010", + "OSW-KEY-011", + "OSW-KEY-012", + "OSW-KEY-013", + "OSW-KEY-014", + "OSW-KEY-015", + "OSW-KEY-016", + "OSW-KEY-017", + "OSW-KEY-018", + "OSW-KEY-019", + "OSW-KEY-020", + "OSW-KEY-021", + "OSW-KEY-022", + "OSW-KEY-023", + "OSW-KEY-024", + "OSW-KEY-025", + "OSW-KEY-026", + "OSW-KEY-027", + "OSW-KEY-028", + "OSW-KEY-029", + "OSW-KEY-030", + "OSW-KEY-031", + "OSW-NET-001", + "OSW-NET-002", + "OSW-NET-003", + "OSW-NET-004", + "OSW-NET-005", + "OSW-NET-006", + "OSW-NET-007", + "OSW-NET-008", + "OSW-NET-009", + "OSW-NET-010", + "OSW-NET-101", + "OSW-NET-102", + "OSW-NET-103", + "OSW-NET-104", + "OSW-NET-105", + "OSW-NET-106", + "OSW-NET-201", + "OSW-NET-202", + "OSW-NET-203", + "OSW-NET-204", + "OSW-NET-205", ]; /** Messages said in place rather than from the register, counted by * `countUnregistered`. The number may only fall: a change that moves * messages into the register lowers it here, and a new message said in * place fails this test. 323 before the register existed; 290 once the - * CLI, RUN, QST and PRM groups moved into it (2026-10-09). */ -const UNREGISTERED_MESSAGES = 290; + * CLI, RUN, QST and PRM groups moved into it (2026-10-09); 472 when the + * counter widened to every carrier (2026-10-12) - the four forms it + * counted held 290, and the `message:`/`problem:` fields, the + * `refused(...)` reasons, the `describe*` sentences and the refusal + * classes' messages added 182 that were said in place all along; 330 once + * the eight remaining groups - HRN, AGT, TSK, VAL, ARC, WSP, KEY, NET - + * moved into the register (2026-10-12). */ +const UNREGISTERED_MESSAGES = 330; /** Where a message is said as a sentence written in place: a CLI line, a - * VS Code notification, a failure or a refusal's reason. A rough measure by + * VS Code notification, a failure or a refusal's reason, a validator's + * thrown message, a `refused(...)` result, a `describe*` formatter's + * sentence, a check's `message:`/`problem:` field. A rough measure by * design - it counts the forms that say most of what a person reads, and a * message said by its identifier has none of them. */ const SAID_IN_PLACE = [ @@ -57,6 +441,13 @@ const SAID_IN_PLACE = [ /\bshow(?:Error|Warning|Information)Message\(\s*[`"']/gu, /\bfailedEvent\(\s*[\w.]+,\s*[`"']/gu, /\breason:\s*[`"']/gu, + /\bmessage:\s*[`"']/gu, + /\bproblem:\s*[`"']/gu, + /\brefused\(\s*[`"']/gu, + /\bsuper\(\s*[`"']/gu, + // A `describe*` formatter's sentence: from its declaration to the first + // sentence it returns. + /\b(?:export\s+)?(?:function\s+)?describe[A-Z][A-Za-z]*\b[\s\S]*?\breturn\s+[`"']/gu, ]; function sourceFiles(): string[] { @@ -96,6 +487,14 @@ describe("the message register", () => { expect(wrong.map(([code]) => code)).toEqual([]); }); + it("has at least one entry in every group ADR 0046 names", () => { + const empty = MESSAGE_GROUPS + .map((group) => group.group) + .filter((group) => !entries.some(([code]) => code.startsWith(`OSW-${group}-`))); + + expect(empty).toEqual([]); + }); + it("keeps every identifier ever given, and gives none twice", () => { expect(GIVEN.filter((code, index) => GIVEN.indexOf(code) !== index)).toEqual([]); expect(GIVEN.filter((code) => !isMessageCode(code))).toEqual([]); diff --git a/packages/core/src/message-register.ts b/packages/core/src/message-register.ts index 268ef99c..f7fe822d 100644 --- a/packages/core/src/message-register.ts +++ b/packages/core/src/message-register.ts @@ -478,6 +478,1942 @@ export const MESSAGES = { why: "A confirmed process tree was killed after its identity re-verified.", todo: "Nothing.", }, + + // HRN: harness settings. + "OSW-HRN-001": { + level: "error", + text: "reviewGate.mode \"agent-sufficient\" is only valid in a per-change harness.json, never in the global openspec/agent-harness.json", + why: "The global file set the review gate so an agent reviews its own work, which only one change may decide.", + todo: "Move the setting into the change's own harness.json, or use \"human-required\" in the global file.", + }, + "OSW-HRN-002": { + level: "error", + text: "autonomyLevel \"autonomous\" is only valid in a per-change harness.json, never in the global openspec/agent-harness.json", + why: "The global file set a level that runs without a person, which is chosen for one change at a time.", + todo: "Set the level in the change's own harness.json, or lower it in the global file.", + }, + "OSW-HRN-003": { + level: "error", + text: "checkpoints.requireConfirmationBetweenSteps: false is only valid in a per-change harness.json, never in the global openspec/agent-harness.json", + why: "The global file turned off the between-stage confirmation that only a change may waive.", + todo: "Move the setting into the change's own harness.json, or remove it from the global file.", + }, + "OSW-HRN-004": { + level: "error", + text: "gitStageAllowlist is only valid in a per-change harness.json, never in the global openspec/agent-harness.json", + why: "A git-stage allowlist is a change's own decision about what it may push, so a global one is refused.", + todo: "Move the allowlist into the change's own harness.json.", + }, + "OSW-HRN-005": { + level: "error", + text: "taskAgents is only valid in a per-change harness.json, never in the global openspec/agent-harness.json — a task number names a task of one change", + why: "A task number belongs to one change's tasks.md, so a workspace-wide mapping cannot mean anything.", + todo: "Move the taskAgents section into the change's own harness.json.", + }, + "OSW-HRN-006": { + level: "error", + text: "steps is only valid in a per-change harness.json, never in the global openspec/agent-harness.json — a declared step belongs to the chain of one change", + why: "A declared step is placed in one change's chain, so a global one would apply to every change forever.", + todo: "Move the steps list into the change's own harness.json.", + }, + "OSW-HRN-007": { + level: "error", + text: "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\"", + why: "Act lets the supervisor start another agent, which a level that asks a person does not allow.", + todo: "Open the change's Harness Settings and save to turn Act off, or set autonomyLevel to \"autonomous\".", + }, + "OSW-HRN-008": { + level: "error", + text: "supervisor.mode \"act\" repeats or moves a failed stage as another attempt of it, and maxStageAttempts is {attempts}: set it above 1 in this change's harness.json, or choose \"advise\"", + why: "Act spends another attempt when it repeats or moves a stage, and a ceiling of one leaves it nothing to spend.", + todo: "Raise maxStageAttempts above 1 in the change's harness.json, or choose \"advise\".", + }, + "OSW-HRN-009": { + level: "warning", + text: "Migrated legacy harness dispatch settings in {file}: {reports}", + why: "An older harness file used the retired dispatch keys, and the migration rewrote them so the settings are read.", + todo: "Nothing, the migration is automatic; save the file to keep the new form.", + }, + "OSW-HRN-010": { + level: "warning", + text: "stepAgents.{stage}.dispatch \"vscode-chat\" was migrated to stepAgents.{stage}.agent \"{agent}\"", + why: "A stepAgents entry named the editor's chat through the retired dispatch key, which is now written as an agent id.", + todo: "Nothing; the entry now names the VS Code chat agent directly.", + }, + "OSW-HRN-011": { + level: "warning", + text: "stepAgents.{stage} was dropped — \"{stage}\" is a mechanical stage and never invokes an agent", + why: "The configuration set an agent for a stage that runs no agent, so the entry never did anything.", + todo: "Nothing; the setting was removed from the resolved configuration.", + }, + "OSW-HRN-012": { + level: "error", + text: "budget.maxCost must be an object of unit to ceiling", + why: "The per-unit budget was written as something other than a map from unit to ceiling.", + todo: "Write it as an object, such as { \"credits\": 500 }.", + }, + "OSW-HRN-013": { + level: "error", + text: "budget.maxCost has a ceiling under an empty unit", + why: "A ceiling was filed under a blank unit name, which no report can be compared against.", + todo: "Name the unit, or remove the entry.", + }, + "OSW-HRN-014": { + level: "error", + text: "budget.maxCost.{unit} must be a positive number", + why: "A per-unit ceiling was not a positive finite number.", + todo: "Give the unit a positive number as its ceiling.", + }, + "OSW-HRN-015": { + level: "error", + text: "{label} has unknown key \"{key}\" (accepted keys: {keys})", + why: "An agent entry carried a key this product does not read, so it would silently do nothing.", + todo: "Remove the key or use one of the accepted keys named.", + }, + "OSW-HRN-016": { + level: "error", + text: "{label} must be a non-empty string", + why: "An agent entry was written as something other than the agent id string it must be.", + todo: "Write the agent id as a non-empty string.", + }, + "OSW-HRN-017": { + level: "error", + text: "{label} references unknown agent id \"{agent}\"", + why: "The entry names an agent id that no registered agent or the editor's chat has.", + todo: "Use a registered agent id, or install the agent that id belongs to.", + }, + "OSW-HRN-018": { + level: "error", + text: "{label} selects agent \"{agent}\", which is only valid under autonomyLevel \"assisted\" — a chain cannot use it", + why: "The editor's chat can only run under assisted autonomy, where a person starts each stage.", + todo: "Use a CLI agent under this level, or set autonomyLevel to \"assisted\".", + }, + "OSW-HRN-019": { + level: "error", + text: "{label}.model cannot reach anything when agent \"{agent}\" dispatches to VS Code chat", + why: "A model id has no effect when the entry dispatches to the editor's chat.", + todo: "Remove the model, or choose an agent that takes a model id.", + }, + "OSW-HRN-020": { + level: "error", + text: "{label}.model \"{model}\" is not a valid model id", + why: "A model id reached a command line as a flag value and did not have the shape one may have.", + todo: "Give a model id of letters, digits, \".\", \"_\", \":\" and \"-\", not beginning with \"-\".", + }, + "OSW-HRN-021": { + level: "error", + text: "{label} sets a model, but agent \"{agent}\" does not accept one", + why: "The named agent has no command-line way to take a model id.", + todo: "Remove the model, or choose an agent that accepts one.", + }, + "OSW-HRN-022": { + level: "error", + text: "{label}.customAgent cannot reach anything when agent \"{agent}\" dispatches to VS Code chat", + why: "A custom agent flag has no effect when the entry dispatches to the editor's chat.", + todo: "Remove the customAgent, or choose an agent that accepts one.", + }, + "OSW-HRN-023": { + level: "error", + text: "{label}.customAgent must be a non-empty string", + why: "The custom agent was written as something other than the flag value it must be.", + todo: "Write the custom agent as a non-empty string.", + }, + "OSW-HRN-024": { + level: "error", + text: "{label}.customAgent \"{customagent}\" must not begin with \"-\" and may contain only letters, digits, \".\", \"_\", \":\" and \"-\"", + why: "A value that reaches argv as a flag value must not look like another flag.", + todo: "Rename the custom agent so it begins with a letter or digit and uses only the allowed characters.", + }, + "OSW-HRN-025": { + level: "error", + text: "{label} sets a custom agent, but agent \"{agent}\" does not accept one", + why: "The named agent's command line has no flag for a custom agent.", + todo: "Remove the customAgent, or choose an agent that accepts one.", + }, + "OSW-HRN-026": { + level: "error", + text: "{label}.effort cannot reach anything when agent \"{agent}\" dispatches to VS Code chat", + why: "A reasoning effort has no effect when the entry dispatches to the editor's chat.", + todo: "Remove the effort, or choose an agent with an effort control.", + }, + "OSW-HRN-027": { + level: "error", + text: "{label}.effort must be one of: {values}", + why: "The effort was not one of the values this product recognises.", + todo: "Use one of the listed effort values.", + }, + "OSW-HRN-028": { + level: "error", + text: "{label} sets effort, but agent \"{agent}\" has no command-line reasoning-effort control", + why: "The named agent has no way to be told how much reasoning to spend.", + todo: "Remove the effort, or choose an agent that takes one.", + }, + "OSW-HRN-029": { + level: "error", + text: "{label}.effort \"{effort}\" is not accepted by agent \"{agent}\" (accepted: {values})", + why: "The named agent takes an effort, but not this one.", + todo: "Use one of the effort values the agent accepts.", + }, + "OSW-HRN-030": { + level: "error", + text: "{label}.budget cannot reach anything when agent \"{agent}\" dispatches to VS Code chat", + why: "A budget has no effect when the entry dispatches to the editor's chat.", + todo: "Remove the budget, or choose an agent that takes a cap.", + }, + "OSW-HRN-031": { + level: "error", + text: "{label}.budget must be an object", + why: "The entry's budget was written as something other than an object of caps.", + todo: "Write the budget as an object with maxCostUsd or maxAiCredits.", + }, + "OSW-HRN-032": { + level: "error", + text: "{label}.budget has unknown key \"{key}\" (accepted keys: {keys})", + why: "The budget carried a cap key this product does not read.", + todo: "Remove the key or use one of the accepted keys named.", + }, + "OSW-HRN-033": { + level: "error", + text: "{label}.budget must set maxCostUsd or maxAiCredits", + why: "The budget was present but named no cap at all.", + todo: "Set maxCostUsd or maxAiCredits, or remove the budget.", + }, + "OSW-HRN-034": { + level: "error", + text: "{label}.budget.maxCostUsd must be a positive number", + why: "The per-stage dollar cap was not a positive finite number.", + todo: "Give it a positive number.", + }, + "OSW-HRN-035": { + level: "error", + text: "{label} sets budget.maxCostUsd, but agent \"{agent}\" does not accept a cost cap in USD", + why: "The named agent's command line has no dollar cost cap.", + todo: "Remove maxCostUsd, or choose an agent billed in a unit it caps.", + }, + "OSW-HRN-036": { + level: "error", + text: "{label}.budget.maxAiCredits must be a positive integer", + why: "The AI-credit cap was not a positive whole number.", + todo: "Give it a positive whole number.", + }, + "OSW-HRN-037": { + level: "error", + text: "{label} sets budget.maxAiCredits, but agent \"{agent}\" does not accept a credit cap", + why: "The named agent's command line has no AI-credit cap.", + todo: "Remove maxAiCredits, or choose an agent that takes one.", + }, + "OSW-HRN-038": { + level: "error", + text: "{label}.budget.maxAiCredits must be at least {minimum} (copilot-cli's own minimum)", + why: "copilot-cli refuses a credit cap below its own minimum.", + todo: "Raise the cap to at least the named minimum.", + }, + "OSW-HRN-039": { + level: "error", + text: "stepAgents must be an object", + why: "The stepAgents section was written as something other than an object of stage entries.", + todo: "Write it as an object keyed by stage.", + }, + "OSW-HRN-040": { + level: "error", + text: "stepAgents.{stage} is not accepted — \"{stage}\" is a mechanical stage and never invokes an agent", + why: "The stage runs no agent, so an entry for it can never do anything.", + todo: "Remove the entry from stepAgents.", + }, + "OSW-HRN-041": { + level: "error", + text: "unknown stepAgents key \"{stage}\" (expected one of: {stages})", + why: "A stepAgents key is not a stage that runs an agent.", + todo: "Use one of the listed stage names.", + }, + "OSW-HRN-042": { + level: "error", + text: "taskAgents must be an object", + why: "The taskAgents section was written as something other than an object of task entries.", + todo: "Write it as an object keyed by task number.", + }, + "OSW-HRN-043": { + level: "error", + text: "taskAgents key \"{task}\" is not a task number (expected the leading number of a tasks.md line, such as \"5.4\")", + why: "An entry filed under something other than a task number can never match a task line.", + todo: "Key the entry by the leading number of a tasks.md line.", + }, + "OSW-HRN-044": { + level: "error", + text: "{label} selects agent \"{agent}\", which cannot run one task — a delegated item's run spawns a CLI agent", + why: "A numbered task is delegated to a spawned CLI agent, which the editor's chat cannot be.", + todo: "Use a CLI agent for the task.", + }, + "OSW-HRN-045": { + level: "error", + text: "autonomyLevel must be one of: {levels}", + why: "The autonomy level was not one this product recognises.", + todo: "Use one of the listed autonomy levels.", + }, + "OSW-HRN-046": { + level: "error", + text: "reviewGate must be an object", + why: "The review gate was written as something other than an object with a mode.", + todo: "Write it as an object with a mode of \"human-required\" or \"agent-sufficient\".", + }, + "OSW-HRN-047": { + level: "error", + text: "reviewGate.mode must be one of: {modes}", + why: "The review gate mode was not one this product recognises.", + todo: "Use \"human-required\" or \"agent-sufficient\".", + }, + "OSW-HRN-048": { + level: "error", + text: "hints must be an object", + why: "The hints section was written as something other than an object with an enabled flag.", + todo: "Write it as an object with an enabled boolean.", + }, + "OSW-HRN-049": { + level: "error", + text: "hints.enabled must be a boolean", + why: "The hints switch was not true or false.", + todo: "Set hints.enabled to true or false.", + }, + "OSW-HRN-050": { + level: "error", + text: "supervisor must be an object", + why: "The supervisor section was written as something other than an object of its keys.", + todo: "Write it as an object of the accepted supervisor keys.", + }, + "OSW-HRN-051": { + level: "error", + text: "supervisor has no key \"{key}\" (accepted keys: {keys})", + why: "The supervisor section carried a key this product does not read.", + todo: "Remove the key or use one of the accepted keys named.", + }, + "OSW-HRN-052": { + level: "error", + text: "supervisor.mode must be one of: {modes}", + why: "The supervisor mode was not one this version knows, and an unknown mode is refused rather than read as advise.", + todo: "Use \"off\", \"advise\" or \"act\".", + }, + "OSW-HRN-053": { + level: "error", + text: "supervisor.mode \"act\" is set in a change's own harness.json, never for the whole workspace: it may start another agent unattended", + why: "Acting may start another agent, which is a change's own decision, not a workspace default.", + todo: "Move supervisor.mode \"act\" into the change's own harness.json.", + }, + "OSW-HRN-054": { + level: "error", + text: "supervisor.{key} must be a positive whole number of seconds", + why: "A supervisor threshold was not a positive whole number of seconds.", + todo: "Give it a positive whole number.", + }, + "OSW-HRN-055": { + level: "error", + text: "supervisor.{key} must be a boolean", + why: "A supervisor switch was not true or false.", + todo: "Set it to true or false.", + }, + "OSW-HRN-056": { + level: "error", + text: "supervisor.{key} is set in a change's own harness.json, never for the whole workspace", + why: "Allowing a cost or provider change is a decision only one change may make.", + todo: "Move the key into the change's own harness.json.", + }, + "OSW-HRN-057": { + level: "error", + text: "supervisor.fallback must be an object of stages", + why: "The fallback section was written as something other than an object keyed by stage.", + todo: "Write it as an object mapping stage names to agent lists.", + }, + "OSW-HRN-058": { + level: "error", + text: "supervisor.fallback.{stage} names no stage that runs an agent", + why: "A fallback was declared for a stage that invokes no agent.", + todo: "Remove the entry, or name a stage that runs an agent.", + }, + "OSW-HRN-059": { + level: "error", + text: "supervisor.fallback.{stage} must be a non-empty list of agent ids", + why: "A fallback for a stage was written as something other than a non-empty list of agent ids.", + todo: "Give the stage a non-empty list of registered agent ids.", + }, + "OSW-HRN-060": { + level: "error", + text: "supervisor.fallback.{stage} may not name {agent}: a chain cannot hand a stage it attempts again to the editor's chat", + why: "A chain retries a stage by spawning a CLI agent, which the editor's chat cannot be.", + todo: "Use a CLI agent in the fallback list.", + }, + "OSW-HRN-061": { + level: "error", + text: "supervisor.fallback.{stage} names \"{agent}\", which is not a registered agent", + why: "A fallback names an agent id no registered agent has.", + todo: "Use a registered agent id, or install that agent.", + }, + "OSW-HRN-062": { + level: "error", + text: "supervisor.fallback.{stage} names \"{agent}\" twice", + why: "A fallback list that repeats an agent would try it twice for one failure.", + todo: "Remove the duplicate entry.", + }, + "OSW-HRN-063": { + level: "error", + text: "checkpoints must be an object", + why: "The checkpoints section was written as something other than an object.", + todo: "Write it as an object with requireConfirmationBetweenSteps.", + }, + "OSW-HRN-064": { + level: "error", + text: "checkpoints.requireConfirmationBetweenSteps must be a boolean", + why: "The between-stage confirmation switch was not true or false.", + todo: "Set requireConfirmationBetweenSteps to true or false.", + }, + "OSW-HRN-065": { + level: "error", + text: "budget must be an object", + why: "The budget section was written as something other than an object of ceilings.", + todo: "Write it as an object of the accepted budget keys.", + }, + "OSW-HRN-066": { + level: "error", + text: "budget.maxCostUsd must be a positive number", + why: "The whole-chain dollar ceiling was not a positive finite number.", + todo: "Give it a positive number.", + }, + "OSW-HRN-067": { + level: "error", + text: "budget.maxTokens must be a positive integer", + why: "The whole-chain token ceiling was not a positive whole number.", + todo: "Give it a positive whole number.", + }, + "OSW-HRN-068": { + level: "error", + text: "budget.maxStageCostUsd must be a positive number", + why: "The per-stage dollar ceiling was not a positive finite number.", + todo: "Give it a positive number.", + }, + "OSW-HRN-069": { + level: "error", + text: "budget.maxStageTokens must be a positive integer", + why: "The per-stage token ceiling was not a positive whole number.", + todo: "Give it a positive whole number.", + }, + "OSW-HRN-070": { + level: "error", + text: "budget.maxContextShare must be a share above 0 and at most 1, such as 0.8 for eighty percent", + why: "The context ceiling is a share, and a value like 80 would never fire.", + todo: "Write the share as a fraction above 0 and at most 1.", + }, + "OSW-HRN-071": { + level: "error", + text: "budget.maxStageCostUsd ({stage}) must not exceed budget.maxCostUsd ({chain}), because the chain ceiling would stop the run first and the stage ceiling could never fire", + why: "A per-stage ceiling above the chain ceiling can never be reached.", + todo: "Lower the stage ceiling, or raise the chain ceiling above it.", + }, + "OSW-HRN-072": { + level: "error", + text: "budget.maxStageTokens ({stage}) must not exceed budget.maxTokens ({chain}), because the chain ceiling would stop the run first and the stage ceiling could never fire", + why: "A per-stage token ceiling above the chain ceiling can never be reached.", + todo: "Lower the stage ceiling, or raise the chain ceiling above it.", + }, + "OSW-HRN-073": { + level: "error", + text: "timeout must be an object", + why: "The timeout section was written as something other than an object of limits.", + todo: "Write it as an object with maxRunSeconds or maxStageSeconds.", + }, + "OSW-HRN-074": { + level: "error", + text: "timeout.{key} must be a positive integer number of seconds", + why: "A time ceiling was not a positive whole number of seconds.", + todo: "Give it a positive whole number of seconds.", + }, + "OSW-HRN-075": { + level: "error", + text: "timeout.maxStageSeconds ({stage}) must not exceed timeout.maxRunSeconds ({run}), because the run ceiling would stop the chain first and the stage ceiling could never fire", + why: "A per-stage time limit above the run limit can never be reached.", + todo: "Lower the stage limit, or raise the run limit above it.", + }, + "OSW-HRN-076": { + level: "error", + text: "skipStages must be a list of stage names", + why: "The skipStages value was written as something other than a list of stage names.", + todo: "Write it as a list, such as [\"review\"].", + }, + "OSW-HRN-077": { + level: "error", + text: "skipStages may leave out only \"review\"; \"{stage}\" is not a stage it can skip", + why: "Only the review stage may be left out of a chain.", + todo: "Remove the stage name, or use \"review\".", + }, + "OSW-HRN-078": { + level: "error", + text: "skipStages names \"{stage}\" twice", + why: "A stage listed twice would be skipped twice for no reason.", + todo: "Remove the duplicate.", + }, + "OSW-HRN-079": { + level: "error", + text: "maxStageAttempts must be a positive integer", + why: "The stage attempt ceiling was not a positive whole number.", + todo: "Give it a positive whole number.", + }, + "OSW-HRN-080": { + level: "error", + text: "gitStageAllowlist must be an object", + why: "The allowlist was written as something other than an object of patterns.", + todo: "Write it as an object with remotes and branches.", + }, + "OSW-HRN-081": { + level: "error", + text: "gitStageAllowlist has unknown key \"{key}\" (accepted keys: {keys})", + why: "The allowlist carried a key this product does not read.", + todo: "Remove the key or use one of the accepted keys named.", + }, + "OSW-HRN-082": { + level: "error", + text: "gitStageAllowlist.remotes must be a non-empty string array", + why: "The remotes pattern list was empty or held something that is not a string.", + todo: "Give it a non-empty list of remote patterns.", + }, + "OSW-HRN-083": { + level: "error", + text: "gitStageAllowlist.branches must be a non-empty string array", + why: "The branches pattern list was empty or held something that is not a string.", + todo: "Give it a non-empty list of branch patterns.", + }, + "OSW-HRN-084": { + level: "error", + text: "unrecognized top-level key \"{key}\" (accepted keys: {keys}).{hint}", + why: "A harness file carried a top-level key the schema does not define, which would otherwise be silently ignored.", + todo: "Remove the key, or use the accepted key it was meant to be.", + }, + "OSW-HRN-085": { + level: "error", + text: "root value must be an object", + why: "The harness file's JSON was not an object at all.", + todo: "Write the file as a JSON object of settings.", + }, + "OSW-HRN-086": { + level: "error", + text: "allowAgentMessages must be a boolean", + why: "The switch that lets a run take another run's messages was not true or false.", + todo: "Set allowAgentMessages to true or false.", + }, + "OSW-HRN-087": { + level: "error", + text: "branches must be an object", + why: "The branches section was written as something other than an object.", + todo: "Write it as an object with rebaseWhenBehind or followMain.", + }, + "OSW-HRN-088": { + level: "error", + text: "branches has no key \"{key}\"; the ones it takes are rebaseWhenBehind and followMain", + why: "The branches section carried a key this product does not read.", + todo: "Remove the key, or use rebaseWhenBehind or followMain.", + }, + "OSW-HRN-089": { + level: "error", + text: "branches.{key} must be a boolean", + why: "A branch switch was not true or false.", + todo: "Set it to true or false.", + }, + "OSW-HRN-090": { + level: "error", + text: "archive must be an object", + why: "The archive section was written as something other than an object.", + todo: "Write it as an object with whenLanded.", + }, + "OSW-HRN-091": { + level: "error", + text: "archive has no key \"{key}\"; the one it takes is whenLanded", + why: "The archive section carried a key this product does not read.", + todo: "Remove the key, or use whenLanded.", + }, + "OSW-HRN-092": { + level: "error", + text: "archive.whenLanded must be a boolean", + why: "The landed-archive switch was not true or false.", + todo: "Set archive.whenLanded to true or false.", + }, + "OSW-HRN-093": { + level: "error", + text: "steps must be an array", + why: "The declared steps were written as something other than a list.", + todo: "Write steps as a JSON array of step declarations.", + }, + "OSW-HRN-094": { + level: "error", + text: "{label} must be an object", + why: "One declared step was written as something other than an object.", + todo: "Write that step as an object with step and exactly one of before or after.", + }, + "OSW-HRN-095": { + level: "error", + text: "unrecognized key \"{key}\" in {label} (accepted keys: {keys})", + why: "A declared step carried a key this product does not read.", + todo: "Remove the key or use one of the accepted keys named.", + }, + "OSW-HRN-096": { + level: "error", + text: "{label}.step is not a declared step (expected one of: {steps})", + why: "The step name is not in the closed registry of steps this product runs.", + todo: "Use one of the listed step names.", + }, + "OSW-HRN-097": { + level: "error", + text: "{label} must state exactly one of \"before\" or \"after\"{suffix}", + why: "A declared step must be placed against exactly one fixed stage.", + todo: "State exactly one position: before or after.", + }, + "OSW-HRN-098": { + level: "error", + text: "{label}.{position} must name a stage (expected one of: {stages}) — a step is placed against the fixed sequence, never against another declared step", + why: "A declared step is placed against a fixed stage, not against another declared step.", + todo: "Use one of the listed fixed stage names.", + }, + "OSW-HRN-099": { + level: "error", + text: "{label}.param must be a string", + why: "The step's argument was written as something other than text.", + todo: "Write the param as a string, or remove it.", + }, + "OSW-HRN-100": { + level: "error", + text: "{label}.param is required for step \"{step}\"", + why: "This step cannot run without its own argument.", + todo: "Add the param the step needs.", + }, + "OSW-HRN-101": { + level: "error", + text: "{label}.maxWaitSeconds must be a positive number of seconds", + why: "The waiting step's time limit was not a positive finite number of seconds.", + todo: "Give it a positive number of seconds.", + }, + "OSW-HRN-102": { + level: "error", + text: "steps[{index}] ({step}) is placed {position} \"{stage}\", which skipStages leaves out; place it against another stage", + why: "A declared step placed against a skipped stage would never run and would say nothing.", + todo: "Place the step against a stage that is not skipped.", + }, + "OSW-HRN-103": { + level: "warning", + text: "\"{agent}\" answers in text and edits no file, so \"{stage}\" on it writes nothing: the proposal, the implementation and the ticked tasks are files. It can review.", + why: "The stage's work must be left in files, and this agent only answers in text.", + todo: "Choose an agent that edits files for this stage, or accept that the stage writes nothing.", + }, + "OSW-HRN-104": { + level: "warning", + text: "\"{agent}\" never says how full its context is, so budget.maxContextShare cannot act on \"{stage}\". That ceiling reads a figure only an ACP agent sends.", + why: "The agent sends no context gauge, so the context ceiling has no figure to compare.", + todo: "Use an agent that sends a context gauge, or drop budget.maxContextShare.", + }, + "OSW-HRN-105": { + level: "warning", + text: "\"{agent}\" has never been observed reporting its usage here, so whether a spending ceiling can act on \"{stage}\" is not known. A time ceiling needs no report and applies regardless.", + why: "The agent's usage reporting has never been watched, so no ceiling's effect can be asserted.", + todo: "Configure a time ceiling, which needs no report, and watch a run to learn what the agent reports.", + }, + "OSW-HRN-106": { + level: "warning", + text: "\"{agent}\" reports no usage at all, so no spending ceiling can act on \"{stage}\" however large the spend.", + why: "The agent reports nothing for a spending ceiling to compare against.", + todo: "Use a time or context ceiling, or choose an agent that reports usage.", + }, + "OSW-HRN-107": { + level: "warning", + text: "\"{stage}\" can run without any bound: \"{agent}\" reports nothing for a spending ceiling to compare, and no timeout is configured. A time ceiling is the only one that applies here.", + why: "Nothing bounds the stage: the agent reports no usage and no time limit is set.", + todo: "Configure timeout.maxStageSeconds or maxRunSeconds for the stage.", + }, + "OSW-HRN-108": { + level: "warning", + text: "\"{agent}\" reports tokens and no cost, so a cost ceiling cannot act on \"{stage}\". A token ceiling is the one that can.", + why: "The agent counts tokens but not money, so a dollar ceiling has nothing to compare.", + todo: "Use budget.maxTokens or budget.maxStageTokens instead of a cost ceiling.", + }, + "OSW-HRN-109": { + level: "warning", + text: "budget.maxCost sets a ceiling in \"{unit}\", and \"{agent}\" is billed in {billing}, so that ceiling cannot act on \"{stage}\". The ceiling in its own unit is the one that can.", + why: "The ceiling's unit does not match the unit the agent is billed in, so nothing is ever converted.", + todo: "Set the ceiling in the unit the agent is billed in.", + }, + "OSW-HRN-110": { + level: "warning", + text: "A token ceiling counts input plus output only, and \"{agent}\" moves most of its tokens through cache — a measured run recorded 1,693,507 cache tokens against 8,322 counted ones, so a token ceiling will rarely act on \"{stage}\". A cost ceiling acts on it directly.", + why: "A cache-heavy agent's counted tokens are a small fraction of what it moves, so a token ceiling rarely fires.", + todo: "Use a cost ceiling, or expect the token ceiling to act late.", + }, + "OSW-HRN-111": { + level: "error", + text: "\"{change}\" is not a valid change name", + why: "The chain was started with a name no change may have.", + todo: "Name the change as its folder under openspec/changes is named.", + }, + "OSW-HRN-112": { + level: "error", + text: "no active change named \"{change}\" in {directory} — an archived change is finished and is not re-run", + why: "No active change of that name exists, and an archived change is not run again.", + todo: "Check the name, or create the change first.", + }, + "OSW-HRN-113": { + level: "error", + text: "this change's harness configuration could not be read: {reason}", + why: "The change's harness.json is malformed or invalid, so the chain cannot know how to run.", + todo: "Fix the configuration the reason names, then start the chain again.", + }, + "OSW-HRN-114": { + level: "error", + text: "this change's autonomyLevel is \"{level}\", which starts one stage at a time through a picker rather than running a chain. There is nothing to open here; start the stage from the standalone shell or the VS Code command, or set a chain-running autonomy level for this change.", + why: "The change's autonomy level runs stages one at a time, so there is no chain to start.", + todo: "Start the stage individually, or set a chain-running autonomy level for the change.", + }, + "OSW-HRN-115": { + level: "error", + text: "this change's configuration pauses between stages for a confirmation, and there is no terminal here to put that choice to. Run it where somebody can answer, or set checkpoints.requireConfirmationBetweenSteps to false in openspec/changes/{change}/harness.json to say that this change may run unattended.", + why: "The change asks for a confirmation between stages, and nothing here can put that question to a person.", + todo: "Run it where somebody can answer, or turn the confirmation off in the change's harness.json.", + }, + "OSW-HRN-116": { + level: "error", + text: "this change declares that it is blocked by {blockers}, which {verb} still active. Land it and archive it, or remove the line from openspec/changes/{change}/.openspec.yaml — there is no option that starts it anyway, because the declaration is a sentence its author wrote in a file under version control.", + why: "The change declares a blocker that is still active, and the declaration itself refuses the run.", + todo: "Land and archive the blocker, or remove the blocking line from the change's .openspec.yaml.", + }, + "OSW-HRN-117": { + level: "error", + text: "steps[{index}] waits for \"{change}\", which is this change itself — a change cannot wait for itself, and this reads as the name of another one", + why: "A declared await-change step names the change it belongs to, which would wait forever.", + todo: "Point the step at the change it actually waits for.", + }, + "OSW-HRN-118": { + level: "error", + text: "the \"{stage}\" stage names agent \"{agent}\", which this build has no runner for", + why: "This build ships no runner for the agent the stage names.", + todo: "Install that agent or choose one this build can run.", + }, + "OSW-HRN-119": { + level: "error", + text: "the \"{stage}\" stage has no agent configured and this build has no default runner", + why: "The stage names no agent and this build has no default runner to fall back to.", + todo: "Set an agent for the stage in the change's harness.json.", + }, + "OSW-HRN-120": { + level: "warning", + text: "{agent} is not a registered agent", + why: "A fallback candidate is not one of the agents this build knows.", + todo: "Add the agent to the fallback list only if this build ships it.", + }, + "OSW-HRN-121": { + level: "warning", + text: "{agent} is another provider ({provider}), and allowProviderChange is false", + why: "Moving the stage to that agent would send the change's files to another company, which this change has not allowed.", + todo: "Set supervisor.allowProviderChange to true, or choose a fallback on the same provider.", + }, + "OSW-HRN-122": { + level: "warning", + text: "{agent} may cost money, and allowCostIncrease is false", + why: "Moving the stage to that agent may cost more, which this change has not allowed.", + todo: "Set supervisor.allowCostIncrease to true, or choose a free fallback.", + }, + "OSW-HRN-123": { + level: "info", + text: "the cause is not known, so the stage is neither repeated nor moved", + why: "The failure's cause could not be diagnosed, so the supervisor cannot tell whether repeating or moving would help.", + todo: "Read the run's reply and diagnose the failure before acting.", + }, + "OSW-HRN-124": { + level: "info", + text: "{cause}, and {stage} names no fallback agent", + why: "The stage has no fallback configured, so the supervisor has nowhere to move it.", + todo: "Add a fallback list for the stage if a move might help.", + }, + "OSW-HRN-125": { + level: "info", + text: "{cause}, and the policy refuses every fallback left: {refusals}", + why: "Every fallback the stage names is refused by the change's policy, so no move is possible.", + todo: "Allow the cost or provider change, or add a fallback the policy permits.", + }, + "OSW-HRN-126": { + level: "info", + text: "{cause}, and every fallback for {stage} has been tried", + why: "The stage has already run on each of its fallbacks, so none is left to try.", + todo: "Fix the underlying cause, or add a further fallback.", + }, + "OSW-HRN-127": { + level: "info", + text: "{cause} (passed over: {refusals})", + why: "The stage moved to a fallback and this names the ones skipped on the way, so a person can see why.", + todo: "Nothing, unless the passed-over agents were the ones you wanted.", + }, + + // AGT: agents and adapters: missing, signed out, failed. + "OSW-AGT-001": { + level: "warning", + text: "not valid JSON", + why: "A status record or roster file on disk could not be read as JSON.", + todo: "Report it; the file is corrupt and the record it was meant to carry is not trusted.", + }, + "OSW-AGT-002": { + level: "warning", + text: "its signed payload is not valid JSON", + why: "A signed status record's payload did not parse after its signature verified.", + todo: "Report it; the record cannot be trusted and is not read.", + }, + "OSW-AGT-003": { + level: "warning", + text: "missing or invalid required fields", + why: "A status record or roster entry lacks a field the reader needs to trust it.", + todo: "Report it; the file is written wrongly and is not trusted.", + }, + "OSW-AGT-004": { + level: "warning", + text: "record identity \"{instanceId}\" does not match file name \"{expectedId}\"", + why: "A status record names an instance other than the file it was found in.", + todo: "Report it; the record is not taken as the run's own.", + }, + "OSW-AGT-005": { + level: "warning", + text: "activityAt or heartbeatAt is not a valid timestamp", + why: "A status record's activity or heartbeat time could not be read as a date.", + todo: "Report it; the record's age cannot be judged.", + }, + "OSW-AGT-006": { + level: "warning", + text: "not an entry", + why: "A roster file did not hold a JSON object.", + todo: "Report it; the file is not taken as an enrolment.", + }, + "OSW-AGT-007": { + level: "warning", + text: "its file name does not match its key id \"{keyId}\"", + why: "A roster file is named for a key id other than the one it holds.", + todo: "Report it; the entry is not trusted.", + }, + "OSW-AGT-008": { + level: "warning", + text: "its key does not match its key id", + why: "A roster entry's public key does not match the key id it claims.", + todo: "Report it; the entry is not trusted.", + }, + "OSW-AGT-009": { + level: "error", + text: "the key offered for {keyId} does not match that key id", + why: "An enrolment was offered a public key that does not match the key id it names.", + todo: "Read the machine's key again, and enrol only a key that matches its id.", + }, + "OSW-AGT-010": { + level: "error", + text: "{keyId} is already enrolled with a different key", + why: "A key id is already enrolled here under another public key.", + todo: "Remove the old enrolment, or enrol the machine under a new key.", + }, + "OSW-AGT-101": { + level: "warning", + text: "not verified", + why: "A stop request or message was not signed by a key the roster knows.", + todo: "Enrol the sender's key, or ask from a machine whose key is enrolled.", + }, + "OSW-AGT-102": { + level: "warning", + text: "stale", + why: "A stop request or message arrived after the window in which a run acts on it.", + todo: "Ask again from the run's card while the run is live.", + }, + "OSW-AGT-103": { + level: "warning", + text: "already read", + why: "The same stop request or message was read and acted on before.", + todo: "Nothing; a request is read once and not acted on twice.", + }, + "OSW-AGT-104": { + level: "warning", + text: "from a run, which this run does not take", + why: "A message was written by another run, and this run does not take instructions from a run.", + todo: "Turn on allowAgentMessages for this run, or send the message as a person.", + }, + "OSW-AGT-201": { + level: "error", + text: "the agent is not installed", + why: "The agent's executable was not found, so the run could not start it.", + todo: "Install the agent, or choose another agent for the stage.", + }, + "OSW-AGT-202": { + level: "error", + text: "the agent is not signed in", + why: "The agent refused the work because no signed-in account was found.", + todo: "Sign the agent in, then start the run again.", + }, + "OSW-AGT-203": { + level: "error", + text: "the machine blocked the agent", + why: "Security software or file permissions stopped the agent from a file or a program.", + todo: "Allow the agent there, then start the run again.", + }, + "OSW-AGT-204": { + level: "error", + text: "the network could not be reached", + why: "The agent could not reach the service it works through.", + todo: "Check the address, the connection and any system proxy, then start the run again.", + }, + "OSW-AGT-205": { + level: "warning", + text: "the service rate-limited the agent", + why: "The service limited how often it may be asked.", + todo: "Wait for the limit to pass, then start the run again.", + }, + "OSW-AGT-206": { + level: "error", + text: "the service failed", + why: "The service failed on its own side while the run was working.", + todo: "Start the run again; the service's own failure often passes.", + }, + "OSW-AGT-207": { + level: "info", + text: "the cause is not known", + why: "The failure's reason and output matched no cause this product knows.", + todo: "Read the run's output, and report it if nothing there explains the failure.", + }, + "OSW-AGT-208": { + level: "warning", + text: "repeating will not help", + why: "The cause is one a repeat of the run meets again unchanged.", + todo: "Do what the remedy says instead of starting the run again.", + }, + "OSW-AGT-209": { + level: "info", + text: "repeating is likely to help", + why: "The cause is one that passes by itself.", + todo: "Start the run again after a moment.", + }, + "OSW-AGT-210": { + level: "info", + text: "whether repeating helps is not known", + why: "No cause was recognised, so whether a repeat helps is unknown.", + todo: "Read the run's output before deciding to start it again.", + }, + "OSW-AGT-211": { + level: "info", + text: "Install `{executable}` and make sure it is on the PATH, or choose another agent for this stage.", + why: "The agent's executable is not installed, and this names it.", + todo: "Install it and put it on the PATH, or choose another agent for the stage.", + }, + "OSW-AGT-212": { + level: "info", + text: "Install the agent and make sure it is on the PATH, or choose another agent for this stage.", + why: "The agent is not installed and its executable is not known by name.", + todo: "Install the agent and put it on the PATH, or choose another agent for the stage.", + }, + "OSW-AGT-213": { + level: "info", + text: "Run `{executable}` in a terminal and sign in, then start the run again.", + why: "The agent is not signed in, and this names the executable that signs it in.", + todo: "Run it in a terminal, sign in, then start the run again.", + }, + "OSW-AGT-214": { + level: "info", + text: "Sign the agent in, then start the run again.", + why: "The agent is not signed in and its executable is not known by name.", + todo: "Sign the agent in, then start the run again.", + }, + "OSW-AGT-215": { + level: "info", + text: "Something on this machine refused the agent a file or a program: security software, or the file's permissions. Allow it there, then start the run again.", + why: "The machine, not the service, blocked the agent from a file or a program.", + todo: "Allow the agent in the security software or the file's permissions, then start the run again.", + }, + "OSW-AGT-216": { + level: "info", + text: "The service limited how often it may be asked. Wait, then start the run again.", + why: "The service rate-limited the run, and waiting is what clears it.", + todo: "Wait for the limit to pass, then start the run again.", + }, + "OSW-AGT-217": { + level: "info", + text: "The service failed on its side. Starting the run again is likely to work.", + why: "The service, not the run, failed, and such a failure often passes.", + todo: "Start the run again.", + }, + "OSW-AGT-218": { + level: "error", + text: "agent refused the prompt", + why: "The ACP agent ended its turn with a stop reason of refusal.", + todo: "Read what the agent said, then start the run again or change the prompt.", + }, + "OSW-AGT-219": { + level: "error", + text: "cancellation could not be carried out: {killFailure}. The agent process may still be running.", + why: "The cancellation could not kill the agent's process tree, for the reason given.", + todo: "Check for the agent's process and terminate it by hand if it is still running.", + }, + "OSW-AGT-220": { + level: "error", + text: "the agent process did not exit within {seconds}s of being terminated, and may still be running.", + why: "The ACP agent's process outlived the confirmation window after being terminated.", + todo: "Check for the agent's process and terminate it by hand if it is still running.", + }, + "OSW-AGT-221": { + level: "error", + text: "no agent available to run the \"{stage}\" stage", + why: "This host resolved no runner for the stage's agent, so the stage can run no work and the chain fails.", + todo: "Configure or install an agent for that stage (or sign in the one it names), then run the change again.", + }, + + // TSK: tasks: ticks, Human-only, delegation. + "OSW-TSK-001": { + level: "error", + text: "change \"{change}\" has no tasks.md", + why: "A delegated item was asked for a change whose tasks.md does not exist.", + todo: "Check the change name, and that its tasks.md exists.", + }, + "OSW-TSK-002": { + level: "error", + text: "no task at line {line} of {change}/tasks.md", + why: "The inbox asked to run the item at a line the change's task list does not have.", + todo: "Refresh the inbox and choose the item again.", + }, + "OSW-TSK-003": { + level: "error", + text: "task at line {line} of {change}/tasks.md is already ticked", + why: "A delegated run was asked for an item that is already closed.", + todo: "Refresh the inbox and run an item that is still open.", + }, + "OSW-TSK-004": { + level: "error", + text: "the item at line {line} of {change}/tasks.md is marked for a person, and no agent is offered for it", + why: "An item marked Human-only is for a person, and delegation is refused rather than quietly offered to an agent.", + todo: "Do the item yourself, or remove the Human-only marking if an agent may do it.", + }, + "OSW-TSK-005": { + level: "error", + text: "the item at line {line} of {change}/tasks.md names no agent", + why: "The item is not delegated to anything, so there is no agent to run.", + todo: "Name an agent in the item's \"Delegated to \" marker or in the change's taskAgents.", + }, + "OSW-TSK-006": { + level: "error", + text: "\"{agent}\" is not an agent this build recognises", + why: "The item names an agent id that is not in this build's registry.", + todo: "Correct the id, or use an agent this build carries.", + }, + "OSW-TSK-007": { + level: "error", + text: "change \"{change}\" was not found in this workspace", + why: "The change is not among the workspace's active changes, so its directory cannot be used.", + todo: "Refresh the Pipeline and check that the change is active.", + }, + "OSW-TSK-008": { + level: "error", + text: "no runner is configured for agent \"{agent}\"", + why: "The agent id is known, but the host has no runner registered for it.", + todo: "Configure the agent in the host, or choose another agent.", + }, + "OSW-TSK-009": { + level: "error", + text: "the line at {line} of {change}/tasks.md is not a checklist item", + why: "The line the inbox reported no longer holds a checklist item.", + todo: "Refresh the inbox and choose the item again.", + }, + "OSW-TSK-010": { + level: "error", + text: "Unknown mechanical check \"{name}\" (expected one of: {checks})", + why: "A task's check(...) declaration names a mechanical check outside the registry.", + todo: "Use one of the checks listed, or correct the name in tasks.md.", + }, + "OSW-TSK-011": { + level: "error", + text: "Invalid parameter for check \"{name}\" (\"{param}\"): {reason}", + why: "A task's mechanical check declaration carries a parameter the check cannot take.", + todo: "Correct the parameter in tasks.md as the reason says.", + }, + "OSW-TSK-012": { + level: "error", + text: "path-unchanged requires a repository-relative path parameter", + why: "A path-unchanged check was declared with no parameter, so it could never run.", + todo: "Give the check a repository-relative path parameter.", + }, + "OSW-TSK-013": { + level: "error", + text: "must be a repository-relative path, not absolute and not containing \"..\"", + why: "A check parameter is absolute or traverses upward, so it cannot name a file inside the repository.", + todo: "Use a path relative to the repository root and without \"..\".", + }, + "OSW-TSK-014": { + level: "error", + text: "Task list for {change} has changed since it was last loaded — refresh and try again.", + why: "tasks.md was rewritten between reading and writing, so a stale line would have been changed.", + todo: "Refresh the task list and make the change again.", + }, + "OSW-TSK-015": { + level: "info", + text: "a person", + why: "An open item waits on a person rather than an agent.", + todo: "Nothing: do the item when you reach it.", + }, + "OSW-TSK-016": { + level: "warning", + text: "\"{agent}\", which is not a registered agent", + why: "An item names an agent id the workspace's registry does not carry, so it only looks assigned.", + todo: "Correct the id in the task text or in the change's taskAgents.", + }, + "OSW-TSK-017": { + level: "warning", + text: "{waiting} (this change's harness.json names it; the task text names {textagent})", + why: "The change's configuration and the task's own text name different agents for one item.", + todo: "Settle which agent owns the item, in harness.json or in the task text.", + }, + "OSW-TSK-018": { + level: "warning", + text: "{change} names {agent} for task {task}, but {where}", + why: "A change's taskAgents key names an agent for a task that is gone or waits on a person.", + todo: "Remove or correct the taskAgents key named.", + }, + "OSW-TSK-019": { + level: "info", + text: "No active change to look at.{enrolments}", + why: "The workspace holds no active change, so the inbox has nothing to read.", + todo: "Open or create a change, or check the workspace root.", + }, + "OSW-TSK-020": { + level: "info", + text: "Nothing is waiting — {changes} read.{stale}", + why: "Active changes were read and none holds an item waiting on a person or an agent.", + todo: "Nothing, unless a change that should be listed is missing from those read.", + }, + "OSW-TSK-021": { + level: "info", + text: "{items} items waiting, across {changes} of {total} active changes: {waiting}.{stale}", + why: "Open items wait across the workspace, some on a person and some on an agent.", + todo: "Work the items, or hand the agents' items to an agent.", + }, + "OSW-TSK-022": { + level: "warning", + text: "{count} harness.json could not be read, so their items are resolved from the task text alone: {listed}.", + why: "A change's taskAgents configuration is unreadable, so its delegation is read from task text alone.", + todo: "Fix the change's harness.json so its delegation is read.", + }, + "OSW-TSK-023": { + level: "warning", + text: "1 key that signs a run waits to be enrolled.", + why: "A key that signs a live run is not enrolled, so a person must confirm it was them.", + todo: "Enrol the key from the change's card or the team surface.", + }, + "OSW-TSK-024": { + level: "warning", + text: "{count} keys that sign runs wait to be enrolled.", + why: "Keys that sign live runs are not enrolled, so a person must confirm each one.", + todo: "Enrol the keys from the changes' cards or the team surface.", + }, + "OSW-TSK-025": { + level: "warning", + text: "1 question from an agent waits for your answer.", + why: "An agent's question holds a run until the operator answers it.", + todo: "Answer the question on the change's card or in decisions.md.", + }, + "OSW-TSK-026": { + level: "warning", + text: "{count} questions from agents wait for your answer.", + why: "Agents' questions hold runs until the operator answers them.", + todo: "Answer the questions on the changes' cards or in decisions.md.", + }, + "OSW-TSK-027": { + level: "warning", + text: "{count} taskAgents entry(ies) matched no open task: {listed}.", + why: "A change's taskAgents names agents for tasks that are not open, so the configuration has gone stale.", + todo: "Remove or correct the taskAgents keys named.", + }, + "OSW-TSK-028": { + level: "error", + text: "What is waiting could not be read: {reason}.", + why: "The inbox read failed, so what is waiting is unknown rather than empty.", + todo: "Read the reason, fix what it names, and reload the inbox.", + }, + "OSW-TSK-029": { + level: "error", + text: "{ending} {item} came back ticked with nothing written, so the tick was reverted and the run is refused.", + why: "A delegated run ticked its item while adding no evidence, so the rubber-stamp gate rolled the tick back.", + todo: "Read the run's reply, write the evidence, then tick the item or run it again.", + }, + "OSW-TSK-030": { + level: "info", + text: "{ending} {item} is ticked and its text carries more than it did. Something was recorded; whether it is true was not checked.", + why: "A delegated run ticked its item and wrote something, which the narrow gate accepts without judging truth.", + todo: "Read what was recorded and judge whether it is the evidence the task asked for.", + }, + "OSW-TSK-031": { + level: "warning", + text: "{ending} {item} could not be found again afterwards, so nothing was compared: {reason}.", + why: "The item moved or vanished after the run, so the gate reports that it could not compare rather than assuming.", + todo: "Find the item and compare its text with what the run wrote.", + }, + "OSW-TSK-032": { + level: "warning", + text: "{ending} {item} is still open.", + why: "The delegated run ended without ticking the item it was asked to close.", + todo: "Read the run's reply and close the item, or run it again.", + }, + "OSW-TSK-033": { + level: "warning", + text: "no open task numbered {task} is left in {change}/tasks.md", + why: "The task the gate compares by number is no longer open in tasks.md after the run.", + todo: "Check whether the task was closed, renumbered or removed during the run.", + }, + "OSW-TSK-034": { + level: "warning", + text: "line {line} of {change}/tasks.md is no longer a checklist item", + why: "The item's line no longer holds a checklist item after the run.", + todo: "Check what the run changed around that line.", + }, + "OSW-TSK-035": { + level: "error", + text: "the item became ticked while saying nothing it did not say before", + why: "The run ticked the item without writing any new evidence, so the gate reverts the tick.", + todo: "Read the run's reply, write the evidence, and tick the item.", + }, + "OSW-TSK-036": { + level: "warning", + text: "{reason}. It last said: {said}", + why: "A failed or cancelled run ended for the given reason, and the end of its stderr is kept for the reader.", + todo: "Read the last words and put right what stopped the run.", + }, + "OSW-TSK-101": { + level: "warning", + text: "a request to stop after {task} named a task this change does not have; the run goes on", + why: "The stop named a task number the change's task list does not contain, and that cannot be read as either \"stop now\" or \"never stop\".", + todo: "Check the task number against tasks.md and ask the run to stop after a task it actually has.", + }, + + // VAL: validation, the merge gate, checks. + "OSW-VAL-001": { + level: "error", + text: "\"path-unchanged\" requires a repository-relative path parameter", + why: "The path-unchanged check was named in a task line without the repository-relative path it must watch.", + todo: "Add the path after the check name, as in `path-unchanged: packages/core/src`.", + }, + "OSW-VAL-002": { + level: "error", + text: "path \"{path}\" resolves outside the workspace \"{root}\"", + why: "A path-unchanged parameter resolved outside the workspace, which a check must never reach.", + todo: "Correct the path in the task line so that it stays inside the workspace.", + }, + "OSW-VAL-003": { + level: "error", + text: "Unknown mechanical check \"{name}\" (expected one of: {names})", + why: "A check name reached the runner that the closed registry does not contain, which is a host's fault rather than a person's.", + todo: "Report it with the task line that named the check, since only the listed names may run.", + }, + "OSW-VAL-004": { + level: "error", + text: "No change named {change}", + why: "A relation edit named a change the workspace does not have.", + todo: "Use the id of a change that exists, refreshing the Pipeline if the name came from a card.", + }, + "OSW-VAL-005": { + level: "error", + text: "{change} is archived, and an archived change's relations are history", + why: "A relation edit targeted an archived change, whose relations are fixed history.", + todo: "Edit an active change's relations instead, or restore the change first.", + }, + "OSW-VAL-006": { + level: "error", + text: "{path} cannot be read as relations: {error}", + why: "The change's metadata holds relation lines that the reader cannot parse.", + todo: "Fix the follows, supersedes or blocked_by lines in the file by hand, then try the edit again.", + }, + "OSW-VAL-007": { + level: "error", + text: "The edit names no change to relate", + why: "A relation edit arrived with neither a change to add nor one to remove.", + todo: "Give the edit exactly one change to add or remove.", + }, + "OSW-VAL-008": { + level: "error", + text: "A change cannot state {key} on itself", + why: "A relation edit would make the change state a relation on itself.", + todo: "Name a different change.", + }, + "OSW-VAL-009": { + level: "error", + text: "No change named {change}, active or archived", + why: "The change being added as a relation exists neither active nor archived.", + todo: "Use the id of an existing change, or create that change first.", + }, + "OSW-VAL-010": { + level: "error", + text: "That would close a cycle: {cycle}", + why: "The added relation would make the change graph circular, which the relation gate refuses.", + todo: "Choose a relation that keeps the graph acyclic, following the named path.", + }, + "OSW-VAL-101": { + level: "error", + text: "openspec change validate --strict {change}: {count} item(s) failed ({ids})", + why: "Strict OpenSpec validation of the change found items that do not hold.", + todo: "Fix the items named, then run the check again.", + }, + "OSW-VAL-102": { + level: "info", + text: "openspec change validate --strict {change}: {count} item(s) passed", + why: "Strict OpenSpec validation of the change passed every item.", + todo: "Nothing.", + }, + "OSW-VAL-103": { + level: "error", + text: "openspec change validate --strict {change} failed to run: {error}", + why: "The strict validation command could not be started or threw.", + todo: "Fix the reported tooling problem, then run the check again.", + }, + "OSW-VAL-104": { + level: "error", + text: "npm run {script} failed to run: {error}", + why: "An npm script that the check runs could not be started.", + todo: "Fix the reported npm or script problem, then run the check again.", + }, + "OSW-VAL-105": { + level: "info", + text: "npm run {script} exited 0", + why: "The npm script that the check runs finished successfully.", + todo: "Nothing.", + }, + "OSW-VAL-106": { + level: "error", + text: "npm run {script} exited {code}:\n{output}", + why: "The npm script that the check runs finished with a non-zero exit code.", + todo: "Read the output shown, fix what failed, then run the check again.", + }, + "OSW-VAL-107": { + level: "error", + text: "git diff -- {path} failed to run: {error}", + why: "The git command behind the path-unchanged check could not be started.", + todo: "Fix the reported git or filesystem problem, then run the check again.", + }, + "OSW-VAL-108": { + level: "info", + text: "git diff -- {path} reported no changes", + why: "The watched path has no uncommitted changes, so the path-unchanged check passed.", + todo: "Nothing.", + }, + "OSW-VAL-109": { + level: "error", + text: "git diff -- {path} reported uncommitted changes to \"{path}\"", + why: "The path that the check must leave unchanged has uncommitted changes.", + todo: "Commit, revert or move the changes at the named path.", + }, + "OSW-VAL-110": { + level: "error", + text: "git diff -- {path} exited {code}: {output}", + why: "The git command behind the path-unchanged check exited with an unexpected code.", + todo: "Read the output shown, fix what git reported, then run the check again.", + }, + "OSW-VAL-111": { + level: "error", + text: "no \".changeset/config.json\" found under \"{root}\" — Changesets is not adopted", + why: "The changeset-present check ran where Changesets is not adopted.", + todo: "Adopt Changesets in the workspace, or remove that check from the task line.", + }, + "OSW-VAL-112": { + level: "error", + text: "no pending \".changeset/*.md\" file found — run \"npx changeset\" for \"{change}\"", + why: "The change has no pending changeset file, which the merge gate requires.", + todo: "Run `npx changeset` and commit the file it writes.", + }, + "OSW-VAL-113": { + level: "info", + text: "{count} pending changeset file(s) found", + why: "The changeset-present check found the pending files it requires.", + todo: "Nothing.", + }, + "OSW-VAL-114": { + level: "error", + text: "requirement header \"{header}\" does not exist in the current specification", + why: "A modified block names a requirement that the current specification no longer carries.", + todo: "Update the change's modified block to the header the current specification carries.", + }, + "OSW-VAL-115": { + level: "error", + text: "scenario \"{scenario}\" under requirement \"{header}\" exists in the current specification but is omitted from this change's modified block", + why: "The current specification has a scenario that the change's modified block omits.", + todo: "Add the scenario to the modified block, or remove it from the specification.", + }, + "OSW-VAL-116": { + level: "error", + text: "{change}'s tasks.md names \"Successor created: `{successor}`\" but no change `{successor}` states `follows: {change}`", + why: "A change's tasks.md promised a named successor that no change of that id follows.", + todo: "Create the successor with the relation stated, or correct the claim in tasks.md.", + }, + "OSW-VAL-117": { + level: "info", + text: "Nothing recorded yet — no runs have been logged in this workspace.", + why: "The audit log has no entries, so there is no verify quality to report.", + todo: "Nothing.", + }, + "OSW-VAL-118": { + level: "info", + text: "Nothing to report yet — {runs} runs recorded, none of which reached a verifying stage that reported what its checks found.", + why: "Runs are recorded, but none reached a verify stage that reported what its checks found.", + todo: "Nothing.", + }, + "OSW-VAL-119": { + level: "info", + text: "{stages} verifying stage(s) recorded, none of which names the agent whose work was checked. They were recorded before that was written down, so none is charged to an agent.", + why: "The verify stages were recorded before the field naming the checked agent existed, so none is charged to an agent.", + todo: "Nothing, newer verifies record the agent whose work was checked.", + }, + "OSW-VAL-120": { + level: "info", + text: "{stages} verifying stage(s) across {agents} checked agent(s){thinClause}{unnamedClause}", + why: "The verifying stages and their failures are reported per checked agent, with thin groups and unattributed entries named.", + todo: "Read the rows, and gather more verifies before trusting a thin group's rate.", + }, + "OSW-VAL-121": { + level: "error", + text: "mechanical checks failed, verifying agent was not invoked: {summary}", + why: "A declared mechanical check failed at verify, and the chain deliberately spends no verifying run to learn what an exit code already said.", + todo: "Put right what the named checks reported (or finish the tasks they uncheck), then run the change again.", + }, + + // ARC: archiving. + "OSW-ARC-001": { + level: "error", + text: "initOpenSpec requires at least one tool", + why: "`initOpenSpec` was called with a tools list that held no usable name.", + todo: "Pass at least one tool name, or skip initialization.", + }, + "OSW-ARC-002": { + level: "error", + text: "OpenSpec CLI returned {kind} JSON for '{command}'. Expected {contract}. Update OpenSpec CLI or OpenSpec Workbench to compatible versions.", + why: "The `openspec` CLI answered a command with JSON this Workbench cannot read.", + todo: "Update the OpenSpec CLI or OpenSpec Workbench so their versions agree.", + }, + "OSW-ARC-003": { + level: "error", + text: "{binary} {args} exited with code {code}: {reason}", + why: "The `openspec` command exited non-zero, and its own diagnosis is quoted after the code.", + todo: "Read the diagnosis and put right what it names, then retry.", + }, + "OSW-ARC-004": { + level: "error", + text: "could not archive \"{change}\": it still owes {debts}", + why: "Archiving is refused unless every task item is closed and anything closed has something written under it (a-change-is-archived-with-nothing-open).", + todo: "Finish the named tasks, writing a note under each closed one, then archive again.", + }, + "OSW-ARC-005": { + level: "error", + text: "the archive did not happen, and no reason was given", + why: "The archive result carried neither an archived change nor any status entries to explain it.", + todo: "Run `openspec archive` by hand to see what it says.", + }, + "OSW-ARC-006": { + level: "error", + text: "the archive did not happen, and the report gave no reason", + why: "The archive's status entries held no error message or code that could be reported.", + todo: "Run `openspec archive` by hand to see what it says.", + }, + "OSW-ARC-007": { + level: "error", + text: "could not archive \"{change}\": {refusal}", + why: "The archive subcommand refused, and its reasons and remedies are reported together.", + todo: "Do what the refusal's reason and fix say, then archive the change again.", + }, + "OSW-ARC-101": { + level: "error", + text: "archiving changed nothing, so there was nothing to commit", + why: "After archiving, git had staged nothing, so the branch would carry no archive.", + todo: "Find why the archive changed no file, then run the sweep again.", + }, + "OSW-ARC-102": { + level: "error", + text: "{forge} could not be asked about pull requests: {reason}", + why: "The forge could not be asked about pull requests, so the pass cannot know what is due or already open.", + todo: "Fix the forge access the reason names, then sweep again.", + }, + "OSW-ARC-103": { + level: "warning", + text: "its checks could not be read: {reason}", + why: "An open archive pull request's checks could not be read, so it cannot be merged yet.", + todo: "Nothing; the next pass reads them again.", + }, + "OSW-ARC-104": { + level: "warning", + text: "a check failed", + why: "A check on the archive pull request failed, so the sweep will not merge it.", + todo: "Read the failed check and fix or re-run it.", + }, + "OSW-ARC-105": { + level: "error", + text: "no merge method was tried", + why: "No merge method was available to try, so the archive pull request was left unmerged.", + todo: "Report it; a merge method should always be tried.", + }, + "OSW-ARC-106": { + level: "error", + text: "{forge} refused the merge: {reason}", + why: "The forge refused every merge method tried for the archive pull request.", + todo: "Read the forge's reason and put right what it names, then wait for the next pass.", + }, + "OSW-ARC-107": { + level: "error", + text: "{reason}; making it again on {ref} failed too: {failure}", + why: "The forge refused the merge and the rebuilt archive could not be pushed to its branch either.", + todo: "Fix what the second failure names, then sweep again.", + }, + "OSW-ARC-108": { + level: "error", + text: "no reason given", + why: "A failure carried no message or telling git line that could be quoted.", + todo: "Run the command by hand to see why it failed.", + }, + "OSW-ARC-201": { + level: "info", + text: "opened #{number} to archive {changes}, which landed with nothing open", + why: "The sweep found changes that landed with every task closed, and opened one pull request to archive them.", + todo: "Nothing; the sweep follows it and merges it once its checks pass.", + }, + "OSW-ARC-202": { + level: "info", + text: "#{number} is merged by the sweep once its checks pass", + why: "The sweep merges the archive pull request itself rather than asking the forge to do it later.", + todo: "Nothing; wait for the checks.", + }, + "OSW-ARC-203": { + level: "info", + text: "merged #{number} by {method}: its checks passed, or none ran", + why: "The archive pull request's checks had decided, or none ran, so the sweep merged it.", + todo: "Nothing.", + }, + "OSW-ARC-204": { + level: "info", + text: "#{number} is waiting for its checks", + why: "The archive pull request's checks are still running.", + todo: "Nothing; the next pass reads them again.", + }, + "OSW-ARC-205": { + level: "warning", + text: "#{number} was refused while {behind} {commits} behind the default branch, so the archive was made again on it and pushed; its checks run again", + why: "A forge that merges only what is up to date refused the branch, so the archive was rebuilt on the default branch.", + todo: "Nothing; wait for the new checks.", + }, + "OSW-ARC-206": { + level: "warning", + text: "#{number} cannot merge yet: {reason}", + why: "The archive pull request is blocked, usually by a failed check or the forge's refusal.", + todo: "Read the reason and put right what it names.", + }, + "OSW-ARC-207": { + level: "warning", + text: "could not archive {change}: {reason}", + why: "One change's archive failed while the pass was making the archive.", + todo: "Read the reason and archive that change again.", + }, + "OSW-ARC-208": { + level: "error", + text: "{change} landed in #{number} but still owes {owed}, so it stays live: {owes}", + why: "A change landed while its task list still owed something, which the merge gate should have prevented.", + todo: "Report it, then close the named tasks or reopen the change.", + }, + "OSW-ARC-209": { + level: "error", + text: "nothing was archived: {failed}", + why: "The pass could not make an archive pull request at all.", + todo: "Read the reason and fix what it names, then sweep again.", + }, + + // WSP: the workspace: setup, lease, locks. + "OSW-WSP-001": { + level: "error", + text: "Another OpenSpec Workbench host ({hostKind} on {hostname}, pid {pid}{author}, last active {seconds}s ago) is currently running a mutating operation on this workspace. Wait for it to finish, or close it, before starting one here.", + why: "A live foreign host holds this workspace's lease, and only one mutating operation may run against a workspace at a time (ADR 0010).", + todo: "Wait for the named host to finish or close it, then start the operation again.", + }, + "OSW-WSP-002": { + level: "error", + text: "the holder is on {holderhostname} and this is {thishostname}, so whether its process is still running cannot be checked from here. Clear it there, or wait for its lease to go stale.", + why: "A lease held on another machine cannot be shown to be dead from this one, so clearing it here could rob a running holder.", + todo: "Clear the lease on the machine named, or wait for its heartbeat to go stale.", + }, + "OSW-WSP-003": { + level: "error", + text: "process {pid} is still running. Taking its lease would let a second mutating run start against files it still has open, which is what the lease prevents — stop that process instead.", + why: "The holder's process is still alive on this machine, so releasing its lease would let two mutating runs touch the same files.", + todo: "Stop the process named, or wait for it to end, then clear the lease.", + }, + "OSW-WSP-004": { + level: "warning", + text: "\"{name}\" is not a valid change name", + why: "A working directory was asked for a change whose name no change may have, so no branch or directory of that name can be made.", + todo: "Name the change as its folder under openspec/changes is named.", + }, + "OSW-WSP-005": { + level: "warning", + text: "\"{name}\" is not in {base}", + why: "The change exists only as uncommitted files, and the working directory is cut from a base that does not contain it.", + todo: "Commit the change's directory to the base first, or name a base that has it.", + }, + "OSW-WSP-006": { + level: "warning", + text: "branch \"{name}\" is already checked out at {path}", + why: "A branch can be checked out in only one working directory, and this change's branch already has one.", + todo: "Run the chain in that directory, or remove it first.", + }, + "OSW-WSP-007": { + level: "warning", + text: "{path} already exists", + why: "The place a working directory would be created is already occupied, so creating it would touch something this product did not make.", + todo: "Remove it, or pass another path.", + }, + "OSW-WSP-008": { + level: "warning", + text: "branch \"{name}\" already exists, with no working directory on it", + why: "Removing a working directory leaves its branch behind, and git would otherwise refuse with a reason that says nothing about what to do.", + todo: "Check the branch out where it is wanted, or delete it if its work has landed.", + }, + "OSW-WSP-009": { + level: "warning", + text: "the working tree is not clean", + why: "A directory whose tree is dirty may still hold uncommitted work, and removing it has no undo.", + todo: "Commit or discard the uncommitted changes, then ask for the removal again.", + }, + "OSW-WSP-101": { + level: "info", + text: "Reclaimed the workspace lease from {hostKind} on {hostname} (pid {pid}), which stopped renewing it {seconds}s ago.", + why: "A previous holder stopped heartbeating, so this run took the workspace from it rather than waiting forever.", + todo: "Nothing, unless that holder was expected to be alive: a reap of a live holder's lease is the one case worth checking by hand.", + }, + "OSW-WSP-102": { + level: "warning", + text: "it is not a directory", + why: "A path the repository lists as a working directory is not a directory, so its changes, task lists and lease cannot be read.", + todo: "Look at the path the worktree list names, and repair or prune it.", + }, + "OSW-WSP-103": { + level: "warning", + text: "it does not exist", + why: "A worktree the repository lists has no directory at its path, so nothing of it can be surveyed.", + todo: "Run `git worktree prune`, or restore the missing directory.", + }, + "OSW-WSP-104": { + level: "error", + text: "{said}. No process names this directory on its command line; one that holds it without naming it is not found this way.", + why: "A removal failed and no process naming the directory was found, so the underlying reason is all that can be said.", + todo: "Look for a program holding the directory without naming it on its command line, then remove it again.", + }, + "OSW-WSP-105": { + level: "error", + text: "{said}. Held by {holders}, by their command lines.", + why: "A removal failed and processes whose command lines name the directory were found, so the message says who may be holding it.", + todo: "Stop the processes named, then remove the directory again.", + }, + + // KEY: signatures, keys, the team. + "OSW-KEY-001": { + level: "error", + text: "Agent \"{agent}\" is not present in the workspace allowlist", + why: "The workspace allowlist names no rule for this agent, so nothing it asks to run is permitted.", + todo: "Add the agent to the workspace allowlist, or choose an agent that is already on it.", + }, + "OSW-KEY-002": { + level: "error", + text: "Agent \"{agent}\" is not permitted to run in process", + why: "The agent's allowlist rules carry no in-process rule matching its own name.", + todo: "Add an in-process rule for the agent, or run it as a spawned executable.", + }, + "OSW-KEY-003": { + level: "error", + text: "HTTP call is not permitted by the allowlist for agent \"{agent}\"", + why: "The agent's rules carry no HTTP rule, so the call it asked for cannot be made.", + todo: "Add an HTTP rule for the agent, or use an agent that reaches the model another way.", + }, + "OSW-KEY-004": { + level: "error", + text: "URL/method \"{method} {url}\" is not permitted by the allowlist", + why: "The agent's HTTP rule allows other URLs or methods than the one this call names.", + todo: "Extend the rule to the URL and method, or change the call.", + }, + "OSW-KEY-005": { + level: "error", + text: "Executable \"{executable}\" is not permitted by the allowlist for agent \"{agent}\"", + why: "The agent asked to spawn an executable that no rule of its allowlist names.", + todo: "Add the executable to the agent's rules, or have it use one already permitted.", + }, + "OSW-KEY-006": { + level: "error", + text: "Arguments [{arguments}] are not permitted by the allowlist", + why: "The executable is permitted, but its rules refuse the arguments this invocation carries.", + todo: "Bring the invocation within the rule's arguments, or widen the rule.", + }, + "OSW-KEY-007": { + level: "error", + text: "cwd \"{cwd}\" is outside the workspace \"{workspace}\"", + why: "The run's working directory resolved outside the workspace root, and the cwd sandbox refuses it.", + todo: "Run from a directory inside the workspace.", + }, + "OSW-KEY-008": { + level: "error", + text: "it is not valid JSON", + why: "A person's file could not be parsed, so nothing in it can be trusted.", + todo: "Fix the file's JSON, then read the team again.", + }, + "OSW-KEY-009": { + level: "error", + text: "it is not a JSON object", + why: "A person's file parses to something other than an object with named fields.", + todo: "Make the file a JSON object with handle, name and keys.", + }, + "OSW-KEY-010": { + level: "error", + text: "its handle is missing or not lower-case letters, digits and single hyphens", + why: "A person is named by a handle a forge would accept, and this file's handle is not that shape.", + todo: "Give the file a handle of lower-case letters, digits and single hyphens.", + }, + "OSW-KEY-011": { + level: "error", + text: "its file is not named after its handle ({handle}.json)", + why: "The file name must be the handle, so that two files cannot claim one handle.", + todo: "Rename the file to the handle it declares.", + }, + "OSW-KEY-012": { + level: "error", + text: "it has no name", + why: "Every person is shown by a name, and this file leaves it empty.", + todo: "Add the person's name to the file.", + }, + "OSW-KEY-013": { + level: "error", + text: "it lists no key", + why: "A person with no key can sign nothing, so their file is unusable.", + todo: "Add at least one public key to the file.", + }, + "OSW-KEY-014": { + level: "error", + text: "{key} is not a JSON object", + why: "One entry in the keys list is not an object with a key id, public key and added date.", + todo: "Make that key entry a JSON object.", + }, + "OSW-KEY-015": { + level: "error", + text: "{key} is not an Ed25519 public key with its key id", + why: "The entry's public key and key id do not match, so signatures made with it cannot be verified.", + todo: "Re-add the key from the machine that holds it.", + }, + "OSW-KEY-016": { + level: "error", + text: "{key} has no date it was added", + why: "Every key records when it was added, and this one has no readable date.", + todo: "Give the key an addedAt date in ISO form.", + }, + "OSW-KEY-017": { + level: "error", + text: "{key} has a retirement date that is not a date", + why: "A retirement date is only read as an ISO date, and this one is not one.", + todo: "Correct the retiredAt date, or remove it.", + }, + "OSW-KEY-018": { + level: "error", + text: "{key} names its machine with something other than text", + why: "The optional machine field must be text a person can read.", + todo: "Make the machine field text, or remove it.", + }, + "OSW-KEY-019": { + level: "error", + text: "its e-mail addresses are not a list of addresses", + why: "The optional emails field must be a list of strings that each look like an address.", + todo: "Correct the e-mail list, or remove the field.", + }, + "OSW-KEY-020": { + level: "error", + text: "key {keyId} is also {handle}'s", + why: "One key id appears in two people's files, so either could sign as the other.", + todo: "Remove the duplicate key from one file and give that person their own.", + }, + "OSW-KEY-021": { + level: "error", + text: "a person is never removed: their signatures would stop verifying", + why: "A pull request that deletes a person's file would leave everything they signed unverifiable.", + todo: "Keep the file and retire the keys that are no longer used.", + }, + "OSW-KEY-022": { + level: "error", + text: "key {keyId} was removed; retire it with retiredAt instead", + why: "A key is never taken out of a person's file, because what it signed must keep verifying.", + todo: "Put the key back and set its retiredAt date.", + }, + "OSW-KEY-023": { + level: "error", + text: "key {keyId} was replaced", + why: "Changing a recorded public key would make everything it signed fail to verify.", + todo: "Restore the original key and add the new one alongside it.", + }, + "OSW-KEY-024": { + level: "error", + text: "key {keyId}'s retirement was changed", + why: "A retirement already recorded is history, and changing it rewrites when a key stopped being used.", + todo: "Restore the recorded retirement date.", + }, + "OSW-KEY-025": { + level: "error", + text: "\"{handle}\" is not a handle: use lower-case letters, digits and single hyphens", + why: "Joining the team takes a handle of the shape a forge accepts, and this one is not that shape.", + todo: "Choose a handle of lower-case letters, digits and single hyphens.", + }, + "OSW-KEY-026": { + level: "error", + text: "a name is needed", + why: "A person joins under a name others can read, and none was given.", + todo: "Pass the name along with the handle.", + }, + "OSW-KEY-027": { + level: "error", + text: "this machine's key is already {handle}'s", + why: "This machine's key can belong to only one person, and it is already in another's file.", + todo: "Join under that handle, or make a new key for this person.", + }, + "OSW-KEY-028": { + level: "error", + text: "{file} cannot be read: {problems}", + why: "The person's existing file has problems, so this machine's key cannot be added to it.", + todo: "Fix the problems listed in that file, then join again.", + }, + "OSW-KEY-029": { + level: "error", + text: "no live run signed by {keyId} is waiting to be enrolled", + why: "Confirmation enrols only a key that signs a live record now, so a confirmation is always about a run somebody can see.", + todo: "Enrol a key id that a live run's record is signed with.", + }, + "OSW-KEY-030": { + level: "error", + text: "the key at {file} is not an Ed25519 key", + why: "The private key file holds a key of another type, which this product does not sign with.", + todo: "Remove or move the file so that a fresh Ed25519 key is made.", + }, + "OSW-KEY-031": { + level: "error", + text: "the key at {file} was removed as it was made", + why: "Two writers made a key at once and the private file vanished before it could be read.", + todo: "Try again; report it if the key file keeps disappearing.", + }, + + // NET: network, proxy, the local LLM. + "OSW-NET-001": { + level: "warning", + text: "{token} is not set", + why: "The forge's token was not in the environment, so the forge cannot be reached.", + todo: "Set the named token in the environment, then read the pull requests again.", + }, + "OSW-NET-002": { + level: "error", + text: "the {token} was refused (401)", + why: "The forge answered the token with 401, so the credentials are wrong or expired.", + todo: "Replace the named token, then read the pull requests again.", + }, + "OSW-NET-003": { + level: "error", + text: "{method} {path} answered {status}: {reason}", + why: "The forge refused the request and said why in its own words.", + todo: "Read the forge's reason and put right what it names, then try again.", + }, + "OSW-NET-004": { + level: "warning", + text: "gh is not installed", + why: "`gh` is not on PATH, so GitHub cannot be read through it.", + todo: "Install `gh`, or set GITHUB_TOKEN to reach GitHub over its API.", + }, + "OSW-NET-005": { + level: "warning", + text: "no remote is on a GitHub host gh knows", + why: "`gh` was asked about a repository whose remote it cannot place on GitHub.", + todo: "Point `origin` at a GitHub host `gh` knows, or set GITHUB_TOKEN.", + }, + "OSW-NET-006": { + level: "warning", + text: "gh is not signed in", + why: "`gh` has no credentials for this host, so it answered with an auth error.", + todo: "Run `gh auth login`, or set GITHUB_TOKEN.", + }, + "OSW-NET-007": { + level: "warning", + text: "{reason}, and GITHUB_TOKEN is not set", + why: "`gh` could not reach GitHub and no API token was set either.", + todo: "Install or sign in to `gh`, or set GITHUB_TOKEN.", + }, + "OSW-NET-008": { + level: "error", + text: "gh failed: {reason}", + why: "`gh` exited non-zero and gave a reason of its own.", + todo: "Read what `gh` said and put it right, then read the pull requests again.", + }, + "OSW-NET-009": { + level: "error", + text: "gh printed something that is not JSON", + why: "`gh` answered the pull-request list with output the product cannot parse.", + todo: "Check the installed `gh` version and report the output if it persists.", + }, + "OSW-NET-010": { + level: "error", + text: "gh printed something that is not a list", + why: "`gh` answered the pull-request list with JSON that is not a list.", + todo: "Check the installed `gh` version and report the output if it persists.", + }, + "OSW-NET-101": { + level: "info", + text: "checks are still pending", + why: "At least one check has not finished, so the answer is not final yet.", + todo: "Wait; the product asks again until the checks decide.", + }, + "OSW-NET-102": { + level: "warning", + text: "no check result was available", + why: "The forge reported no checks, so nothing vouches for the change.", + todo: "Add a check, or merge by hand once you are satisfied.", + }, + "OSW-NET-103": { + level: "error", + text: "check failed: {check} ({state})", + why: "One check finished in a state that is neither a pass nor pending.", + todo: "Read that check's log and put right what it reports.", + }, + "OSW-NET-104": { + level: "warning", + text: "every check was skipped — no check actually ran", + why: "Checks exist but none of them decided anything, which ADR 0014 treats as no evidence.", + todo: "Run a check that actually exercises the change, then try again.", + }, + "OSW-NET-105": { + level: "warning", + text: "Gitea did not say which commit the pull request is at", + why: "Gitea's answer carried no head commit, so its checks cannot be read.", + todo: "Check the pull request in Gitea, then try again.", + }, + "OSW-NET-106": { + level: "warning", + text: "GitHub did not say which commit the pull request is at", + why: "GitHub's answer carried no head commit, so its checks cannot be read.", + todo: "Check the pull request on GitHub, then try again.", + }, + "OSW-NET-201": { + level: "warning", + text: "checks did not reach a terminal state before timeout", + why: "The checks were still pending when the wait ran out.", + todo: "Look at why the checks are slow, or raise maxWaitMs.", + }, + "OSW-NET-202": { + level: "warning", + text: "Stopped at the {calls}-web-tool-call limit.", + why: "The local model asked for more web-tool calls than the product allows.", + todo: "Raise the web-tool-call limit, or narrow the request.", + }, + "OSW-NET-203": { + level: "warning", + text: "Stopped at the {iterations}-iteration web-tool limit.", + why: "The local model used every web-tool iteration the product allows.", + todo: "Raise the web-tool-iteration limit, or narrow the request.", + }, + "OSW-NET-204": { + level: "info", + text: "The local LLM's server refused the key. Set the key the server expects (the local LLM settings, or OPENSPEC_UI_LOCAL_LLM_API_KEY), then start the run again.", + why: "The local LLM's server refused the key the run offered it.", + todo: "Set the key the server expects in the local LLM settings or OPENSPEC_UI_LOCAL_LLM_API_KEY, then start the run again.", + }, + "OSW-NET-205": { + level: "info", + text: "The agent could not reach its service. Check the address and the connection. Where a system proxy is in the way, turn on openspec-ui.agents.ignoreSystemProxy in the editor, or set OPENSPEC_UI_IGNORE_SYSTEM_PROXY=1 for the server and the CLI.", + why: "The agent could not reach its service, and a system proxy is a common cause.", + todo: "Check the address and the connection; where a proxy is in the way, turn on the ignore-system-proxy setting.", + }, } as const satisfies Record; export type MessageCode = keyof typeof MESSAGES; diff --git a/packages/core/src/openspec.test.ts b/packages/core/src/openspec.test.ts index 07a69403..96c95c3b 100644 --- a/packages/core/src/openspec.test.ts +++ b/packages/core/src/openspec.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "node:events"; -import { readFile } from "node:fs/promises"; -import { EOL } from "node:os"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { EOL, tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -267,7 +267,25 @@ describe("openspec CLI wrapper (real CLI fixtures — task 5.3)", () => { // Asserted on content: this class of defect shipped with a // well-formed error string throughout, so asserting one exists would // have passed the whole time. - await expect(archiveChange("demo", { cwd: "/repo" })).rejects.toThrow(/The same reader returns the next day/); + const error = await archiveChange("demo", { cwd: "/repo" }).catch((caught: unknown) => caught); + expect(String(error)).toContain("The same reader returns the next day"); + expect(error).toMatchObject({ code: "OSW-ARC-007" }); + }); + + it("archiveChange refuses a change whose task list still owes something, with the register's identifier", async () => { + const root = await mkdtemp(path.join(tmpdir(), "openspec-archive-gate-")); + try { + const changeDir = path.join(root, "openspec", "changes", "demo"); + await mkdir(changeDir, { recursive: true }); + await writeFile(path.join(changeDir, "tasks.md"), ["## 1. Work", "", "- [ ] 1.1 Not yet", ""].join("\n"), "utf8"); + + const error = await archiveChange("demo", { cwd: root }).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ code: "OSW-ARC-004" }); + expect(String(error)).toContain('could not archive "demo": it still owes still open: 1.1 Not yet'); + } finally { + await rm(root, { recursive: true, force: true }); + } }); it("archiveChange reports every reason, not the first", async () => { @@ -297,6 +315,7 @@ describe("openspec CLI wrapper (real CLI fixtures — task 5.3)", () => { expect(String(error)).toContain("second problem"); // A warning is not a refusal and must not be reported as one. expect(String(error)).not.toContain("not an error"); + expect(error).toMatchObject({ code: "OSW-ARC-007" }); }); it("archiveChange says no reason was given when nothing usable came back", async () => { @@ -312,7 +331,10 @@ describe("openspec CLI wrapper (real CLI fixtures — task 5.3)", () => { return child; }); - await expect(archiveChange("demo", { cwd: "/repo" })).rejects.toThrow(/no diagnosis reported/); + const error = await archiveChange("demo", { cwd: "/repo" }).catch((caught: unknown) => caught); + + expect(String(error)).toContain("no diagnosis reported"); + expect(error).toMatchObject({ code: "OSW-ARC-003" }); }); it("archiveChange calls deterministic non-interactive archive", async () => { @@ -343,9 +365,10 @@ describe("openspec CLI wrapper (real CLI fixtures — task 5.3)", () => { }); it("initOpenSpec rejects when no tools are provided", async () => { - await expect(initOpenSpec({ cwd: "/repo" }, { tools: [] })).rejects.toThrow( - "initOpenSpec requires at least one tool", - ); + const error = await initOpenSpec({ cwd: "/repo" }, { tools: [] }).catch((caught: unknown) => caught); + + expect(String(error)).toContain("initOpenSpec requires at least one tool"); + expect(error).toMatchObject({ code: "OSW-ARC-001" }); }); it("uses a custom binary path when provided", async () => { diff --git a/packages/core/src/openspec.ts b/packages/core/src/openspec.ts index eff31525..4a05176d 100644 --- a/packages/core/src/openspec.ts +++ b/packages/core/src/openspec.ts @@ -7,6 +7,7 @@ // not hand-crafted, see tasks.md 5.3). import crossSpawn from "cross-spawn"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; // `cross-spawn` rather than `node:child_process.execFile`: on Windows // `openspec` (like `copilot`, see agents/shared.ts) is installed as a @@ -73,9 +74,9 @@ function failureMessage( code: number | null, stdout: string, stderr: string, -): string { +): SaidMessage { const reason = diagnosisFrom(stdout, stderr) ?? "no diagnosis reported"; - return `${binary} ${args.join(" ")} exited with code ${code ?? "unknown"}: ${reason}`; + return say("OSW-ARC-003", { binary, args: args.join(" "), code: code ?? "unknown", reason }); } export interface OpenSpecCliOptions { @@ -235,14 +236,29 @@ export class OpenSpecCliCompatibilityError extends Error { options: { cause?: unknown } = {}, ) { super( - `OpenSpec CLI returned ${code === "invalid-json" ? "invalid JSON" : "incompatible JSON"} for '${command}'. ` - + `Expected ${expectedContract}. Update OpenSpec CLI or OpenSpec Workbench to compatible versions.`, + say("OSW-ARC-002", { + kind: code === "invalid-json" ? "invalid" : "incompatible", + command, + contract: expectedContract, + }).text, { cause: options.cause }, ); this.name = "OpenSpecCliCompatibilityError"; } } +/** A failure this wrapper reports, with the register's identifier beside + * its words (ADR 0046). */ +export class OpenSpecCommandError extends Error { + /** The register's identifier for the words. */ + readonly code: MessageCode; + constructor(said: SaidMessage) { + super(said.text); + this.code = said.code; + this.name = "OpenSpecCommandError"; + } +} + type JsonValidator = (value: unknown) => value is T; function isRecord(value: unknown): value is Record { @@ -405,7 +421,7 @@ async function runJson( const command = args.join(" "); const failed = code !== 0; if (failed && !opts.acceptNonZeroExit) { - throw new Error(failureMessage(binary, args, code, stdout, stderr)); + throw new OpenSpecCommandError(failureMessage(binary, args, code, stdout, stderr)); } let parsed: unknown; try { @@ -414,7 +430,7 @@ async function runJson( // A non-zero exit with nothing readable on stdout is the case the // exit code was the only signal for all along — report it as the // failure it is, not as a compatibility problem with the contract. - if (failed) throw new Error(failureMessage(binary, args, code, stdout, stderr)); + if (failed) throw new OpenSpecCommandError(failureMessage(binary, args, code, stdout, stderr)); throw new OpenSpecCliCompatibilityError( "invalid-json", command, @@ -424,7 +440,7 @@ async function runJson( ); } if (!validator(parsed)) { - if (failed) throw new Error(failureMessage(binary, args, code, stdout, stderr)); + if (failed) throw new OpenSpecCommandError(failureMessage(binary, args, code, stdout, stderr)); throw new OpenSpecCliCompatibilityError( "incompatible-output", command, @@ -536,7 +552,7 @@ export async function archiveChange( ...debts.open.map((item) => `still open: ${item}`), ...debts.unrecorded.map((item) => `closed with nothing written under it: ${item}`), ].join("; "); - throw new Error(`could not archive "${changeName}": it still owes ${said}`); + throw new OpenSpecCommandError(say("OSW-ARC-004", { change: changeName, debts: said })); } const args = ["archive", changeName, "--yes", "--json"]; @@ -548,7 +564,7 @@ export async function archiveChange( // worked and why not, rather than for a result to render. const result = await runJson(args, options, "a JSON object", isObjectResult, { acceptNonZeroExit: true }); const refusal = describeArchiveRefusal(result); - if (refusal) throw new Error(`could not archive "${changeName}": ${refusal}`); + if (refusal) throw new OpenSpecCommandError(say("OSW-ARC-007", { change: changeName, refusal })); return result; } @@ -574,7 +590,7 @@ function describeArchiveRefusal(result: OpenSpecArchiveResult): string | undefin const archived = (result as { archive?: unknown }).archive; const status = (result as { status?: unknown }).status; if (!Array.isArray(status)) { - return archived === null ? "the archive did not happen, and no reason was given" : undefined; + return archived === null ? say("OSW-ARC-005").text : undefined; } const messages = (status as ArchiveStatusEntry[]) .filter((entry) => entry.severity === "error") @@ -588,7 +604,7 @@ function describeArchiveRefusal(result: OpenSpecArchiveResult): string | undefin }) .filter((message): message is string => message !== undefined); if (messages.length > 0) return messages.join("; "); - return archived === null ? "the archive did not happen, and the report gave no reason" : undefined; + return archived === null ? say("OSW-ARC-006").text : undefined; } /** Returns the project's own instructions for `artifact` (e.g. `"tasks"`), @@ -622,7 +638,7 @@ export async function initOpenSpec( ): Promise { const tools = initOptions.tools.map((tool) => tool.trim()).filter((tool) => tool.length > 0); if (tools.length === 0) { - throw new Error("initOpenSpec requires at least one tool"); + throw new OpenSpecCommandError(say("OSW-ARC-001")); } const binary = options.binary ?? "openspec"; const args = ["init", "--tools", tools.join(",")]; diff --git a/packages/core/src/own-worktree-tasks.ts b/packages/core/src/own-worktree-tasks.ts index 018bc126..592d5026 100644 --- a/packages/core/src/own-worktree-tasks.ts +++ b/packages/core/src/own-worktree-tasks.ts @@ -28,6 +28,7 @@ import { createGitWrapper, type GitWrapper } from "./git.js"; import { isValidChangeName } from "./change-name.js"; import { parseTaskChecklist, taskNumberOf, TASK_CHECKBOX_LINE_RE, type TaskChecklistItem } from "./task-checklist.js"; import { readWorkspaceLeaseHolder, withWorkspaceLease, WorkspaceLeaseManager, describeWorkspaceLeaseConflict, type WorkspaceLeaseConflict } from "./workspace-lease.js"; +import { withMessageCode } from "./message-register.js"; /** Why an action on a change's own worktree was not taken. A field rather * than a phrase, so a surface can say more where it knows more; `reason` is @@ -418,7 +419,9 @@ export async function runOwnDelegatedItem(options: OwnDelegatedRunOptions): Prom if (!options.holdMutationLease) return execute(); const lease = new WorkspaceLeaseManager(worktree.path, { hostKind: "cli" }); const held = await withWorkspaceLease(lease, execute); - return held.ok ? held.value : refuse("run-working", describeWorkspaceLeaseConflict(held.conflict)); + if (held.ok) return held.value; + const said = describeWorkspaceLeaseConflict(held.conflict); + return refuse("run-working", withMessageCode(said.text, said.code)); } /** The line of task `` in the change's own worktree, with its text, diff --git a/packages/core/src/people.test.ts b/packages/core/src/people.test.ts index 9439c032..b6d1d0ae 100644 --- a/packages/core/src/people.test.ts +++ b/packages/core/src/people.test.ts @@ -55,7 +55,9 @@ describe("a person's file", () => { const text = JSON.stringify(personWith("ada", key)); expect(parsePersonFile("ada.json", text)).toEqual({ person: personWith("ada", key) }); - expect(parsePersonFile("bob.json", text)).toEqual({ problems: ["its file is not named after its handle (ada.json)"] }); + expect(parsePersonFile("bob.json", text)).toEqual({ + problems: [{ problem: "its file is not named after its handle (ada.json)", code: "OSW-KEY-011" }], + }); }); it("says what is wrong with a file that is not a person", () => { @@ -65,9 +67,9 @@ describe("a person's file", () => { const read = parsePersonFile("x.json", JSON.stringify(wrong)); expect("problems" in read && read.problems).toEqual([ - "its handle is missing or not lower-case letters, digits and single hyphens", - "it has no name", - "key 1 is not an Ed25519 public key with its key id", + { problem: "its handle is missing or not lower-case letters, digits and single hyphens", code: "OSW-KEY-010" }, + { problem: "it has no name", code: "OSW-KEY-012" }, + { problem: "key 1 is not an Ed25519 public key with its key id", code: "OSW-KEY-015" }, ]); }); @@ -75,7 +77,7 @@ describe("a person's file", () => { const shared = memoryKey(); expect(crossCheckPeople([personWith("ada", shared), personWith("bob", shared)])).toEqual([ - { file: "openspec/people/bob.json", problem: `key ${shared.keyId} is also ada's` }, + { file: "openspec/people/bob.json", problem: `key ${shared.keyId} is also ada's`, code: "OSW-KEY-020" }, ]); }); }); @@ -169,7 +171,9 @@ describe("reading the people", () => { const read = await readPeople(root); expect(read.people.map((one) => one.handle)).toEqual(["ada"]); - expect(read.problems).toEqual([{ file: "openspec/people/bad.json", problem: "it is not valid JSON" }]); + expect(read.problems).toEqual([ + { file: "openspec/people/bad.json", problem: "it is not valid JSON", code: "OSW-KEY-008" }, + ]); }); it("reads the people at a ref through git", async () => { diff --git a/packages/core/src/people.ts b/packages/core/src/people.ts index cf243eb7..2e48d769 100644 --- a/packages/core/src/people.ts +++ b/packages/core/src/people.ts @@ -15,6 +15,7 @@ import os from "node:os"; import path from "node:path"; import type { GitWrapper } from "./git.js"; import { loadOrCreateMachineKey, type MachineKey } from "./machine-key.js"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; import { keyMatchesKeyId, type Roster, type RosterKey } from "./signed-envelope.js"; /** Where the people of a repository are, relative to its root. */ @@ -47,10 +48,20 @@ export interface Person { emails?: string[]; } +/** One problem with a person's own file: the words, and the register's + * identifier for them. */ +export interface PersonProblem { + problem: string; + code: MessageCode; +} + export interface PeopleProblem { /** The file the problem is in, relative to the repository root. */ file: string; problem: string; + /** The register's identifier for `problem`, where it is said from the + * register. */ + code?: MessageCode; } export interface PeopleReading { @@ -64,51 +75,56 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** A problem's words, and the register's identifier for them. */ +function problemOf(said: SaidMessage): PersonProblem { + return { problem: said.text, code: said.code }; +} + /** One person's file, read and checked. The file name has to be the handle: * two files cannot then claim one handle. */ -export function parsePersonFile(fileName: string, text: string): { person: Person } | { problems: string[] } { +export function parsePersonFile(fileName: string, text: string): { person: Person } | { problems: PersonProblem[] } { let parsed: unknown; try { parsed = JSON.parse(text); } catch { - return { problems: ["it is not valid JSON"] }; + return { problems: [problemOf(say("OSW-KEY-008"))] }; } - if (!isRecord(parsed)) return { problems: ["it is not a JSON object"] }; + if (!isRecord(parsed)) return { problems: [problemOf(say("OSW-KEY-009"))] }; - const problems: string[] = []; + const problems: PersonProblem[] = []; const handle = parsed.handle; if (typeof handle !== "string" || !HANDLE_PATTERN.test(handle)) { - problems.push("its handle is missing or not lower-case letters, digits and single hyphens"); + problems.push(problemOf(say("OSW-KEY-010"))); } else if (fileName !== `${handle}.json`) { - problems.push(`its file is not named after its handle (${handle}.json)`); + problems.push(problemOf(say("OSW-KEY-011", { handle }))); } const name = parsed.name; - if (typeof name !== "string" || name.trim().length === 0) problems.push("it has no name"); + if (typeof name !== "string" || name.trim().length === 0) problems.push(problemOf(say("OSW-KEY-012"))); const keys: PersonKey[] = []; if (!Array.isArray(parsed.keys) || parsed.keys.length === 0) { - problems.push("it lists no key"); + problems.push(problemOf(say("OSW-KEY-013"))); } else { parsed.keys.forEach((entry, index) => { const at = `key ${index + 1}`; if (!isRecord(entry)) { - problems.push(`${at} is not a JSON object`); + problems.push(problemOf(say("OSW-KEY-014", { key: at }))); return; } if (!keyMatchesKeyId(entry.publicKey, entry.keyId)) { - problems.push(`${at} is not an Ed25519 public key with its key id`); + problems.push(problemOf(say("OSW-KEY-015", { key: at }))); return; } if (typeof entry.addedAt !== "string" || !ISO_DATE.test(entry.addedAt)) { - problems.push(`${at} has no date it was added`); + problems.push(problemOf(say("OSW-KEY-016", { key: at }))); return; } if (entry.retiredAt !== undefined && (typeof entry.retiredAt !== "string" || !ISO_DATE.test(entry.retiredAt))) { - problems.push(`${at} has a retirement date that is not a date`); + problems.push(problemOf(say("OSW-KEY-017", { key: at }))); return; } if (entry.machine !== undefined && typeof entry.machine !== "string") { - problems.push(`${at} names its machine with something other than text`); + problems.push(problemOf(say("OSW-KEY-018", { key: at }))); return; } keys.push({ @@ -124,7 +140,7 @@ export function parsePersonFile(fileName: string, text: string): { person: Perso let emails: string[] | undefined; if (parsed.emails !== undefined) { if (!Array.isArray(parsed.emails) || parsed.emails.some((email) => typeof email !== "string" || !email.includes("@"))) { - problems.push("its e-mail addresses are not a list of addresses"); + problems.push(problemOf(say("OSW-KEY-019"))); } else { emails = parsed.emails as string[]; } @@ -143,7 +159,8 @@ export function crossCheckPeople(people: readonly Person[]): PeopleProblem[] { for (const key of person.keys) { const first = owner.get(key.keyId); if (first !== undefined && first !== person.handle) { - problems.push({ file: `${PEOPLE_DIRECTORY}/${person.handle}.json`, problem: `key ${key.keyId} is also ${first}'s` }); + const said = say("OSW-KEY-020", { keyId: key.keyId, handle: first }); + problems.push({ file: `${PEOPLE_DIRECTORY}/${person.handle}.json`, problem: said.text, code: said.code }); } else { owner.set(key.keyId, person.handle); } @@ -159,7 +176,7 @@ function readingOf(files: Array<{ name: string; text: string }>): PeopleReading const read = parsePersonFile(file.name, file.text); const relative = `${PEOPLE_DIRECTORY}/${file.name}`; if ("problems" in read) { - for (const problem of read.problems) problems.push({ file: relative, problem }); + for (const problem of read.problems) problems.push({ file: relative, ...problem }); } else { people.push(read.person); } @@ -217,18 +234,22 @@ export function comparePeople(base: readonly Person[], head: readonly Person[]): const file = `${PEOPLE_DIRECTORY}/${before.handle}.json`; const after = byHandle.get(before.handle); if (after === undefined) { - problems.push({ file, problem: "a person is never removed: their signatures would stop verifying" }); + const said = say("OSW-KEY-021"); + problems.push({ file, problem: said.text, code: said.code }); continue; } const keys = new Map(after.keys.map((key) => [key.keyId, key])); for (const key of before.keys) { const now = keys.get(key.keyId); if (now === undefined) { - problems.push({ file, problem: `key ${key.keyId} was removed; retire it with retiredAt instead` }); + const said = say("OSW-KEY-022", { keyId: key.keyId }); + problems.push({ file, problem: said.text, code: said.code }); } else if (now.publicKey !== key.publicKey) { - problems.push({ file, problem: `key ${key.keyId} was replaced` }); + const said = say("OSW-KEY-023", { keyId: key.keyId }); + problems.push({ file, problem: said.text, code: said.code }); } else if (key.retiredAt !== undefined && now.retiredAt !== key.retiredAt) { - problems.push({ file, problem: `key ${key.keyId}'s retirement was changed` }); + const said = say("OSW-KEY-024", { keyId: key.keyId }); + problems.push({ file, problem: said.text, code: said.code }); } } } @@ -242,7 +263,14 @@ export async function personOfThisMachine(root: string, options: { key?: Pick person.keys.some((one) => one.keyId === key.keyId)); } -export class JoinRefusedError extends Error {} +export class JoinRefusedError extends Error { + /** The register's identifier for the refusal. */ + readonly code: MessageCode; + constructor(said: SaidMessage) { + super(said.text); + this.code = said.code; + } +} export interface JoinOptions { handle: string; @@ -268,14 +296,14 @@ export interface JoinResult { * request that carries the file, reviewed like any other. */ export async function joinTheTeam(root: string, options: JoinOptions): Promise { if (!HANDLE_PATTERN.test(options.handle)) { - throw new JoinRefusedError(`"${options.handle}" is not a handle: use lower-case letters, digits and single hyphens`); + throw new JoinRefusedError(say("OSW-KEY-025", { handle: options.handle })); } - if (options.name.trim().length === 0) throw new JoinRefusedError("a name is needed"); + if (options.name.trim().length === 0) throw new JoinRefusedError(say("OSW-KEY-026")); const key = options.key ?? await loadOrCreateMachineKey(); const reading = await readPeople(root); const holder = reading.people.find((person) => person.keys.some((one) => one.keyId === key.keyId)); if (holder !== undefined && holder.handle !== options.handle) { - throw new JoinRefusedError(`this machine's key is already ${holder.handle}'s`); + throw new JoinRefusedError(say("OSW-KEY-027", { handle: holder.handle })); } const relative = `${PEOPLE_DIRECTORY}/${options.handle}.json`; @@ -285,7 +313,7 @@ export async function joinTheTeam(root: string, options: JoinOptions): Promise one.problem).join("; ") })); person = read.person; if (person.keys.some((one) => one.keyId === key.keyId)) { return { file: relative, person, outcome: "already" }; diff --git a/packages/core/src/process-scheduler.test.ts b/packages/core/src/process-scheduler.test.ts index dad56771..ad4309ab 100644 --- a/packages/core/src/process-scheduler.test.ts +++ b/packages/core/src/process-scheduler.test.ts @@ -397,6 +397,7 @@ describe("WorkbenchProcessScheduler cross-host workspace lease", () => { expect(started).not.toHaveBeenCalled(); expect(process.state).toBe("failed"); expect(process.error).toContain("VS Code extension"); + expect(process.error).toContain("OSW-WSP-001"); expect(process.startedAt).toBeUndefined(); }); @@ -420,6 +421,7 @@ describe("WorkbenchProcessScheduler cross-host workspace lease", () => { expect(process.state).toBe("completed"); expect(process.progress).toContain("Reclaimed"); + expect(process.progress).toContain("OSW-WSP-101"); }); it("releases the lease on completion so a second host can then mutate", async () => { diff --git a/packages/core/src/process-scheduler.ts b/packages/core/src/process-scheduler.ts index ee7f3f5e..fff96b7f 100644 --- a/packages/core/src/process-scheduler.ts +++ b/packages/core/src/process-scheduler.ts @@ -1,4 +1,5 @@ import type { AgentUsage } from "./agent-usage.js"; +import { withMessageCode } from "./message-register.js"; import { WORKSPACE_LEASE_RENEW_INTERVAL_MS, describeWorkspaceLeaseConflict, @@ -336,7 +337,8 @@ export class WorkbenchProcessScheduler { return; } if (result.reclaimedFrom) { - process.progress = describeWorkspaceLeaseReclamation(result.reclaimedFrom); + const said = describeWorkspaceLeaseReclamation(result.reclaimedFrom); + process.progress = withMessageCode(said.text, said.code); } } @@ -363,13 +365,15 @@ export class WorkbenchProcessScheduler { if (!result.ok) { // Never transitions through "running" — another host holds the // workspace, so this run never actually starts. - process.error = describeWorkspaceLeaseConflict(result.conflict); + const said = describeWorkspaceLeaseConflict(result.conflict); + process.error = withMessageCode(said.text, said.code); this.finish(pending, "failed"); this.drain(); return; } if (result.reclaimedFrom) { - process.progress = describeWorkspaceLeaseReclamation(result.reclaimedFrom); + const said = describeWorkspaceLeaseReclamation(result.reclaimedFrom); + process.progress = withMessageCode(said.text, said.code); } } diff --git a/packages/core/src/security.test.ts b/packages/core/src/security.test.ts index d47a6a0a..93fcf9b8 100644 --- a/packages/core/src/security.test.ts +++ b/packages/core/src/security.test.ts @@ -45,16 +45,21 @@ describe("checkCwdSandbox", () => { const decision = checkCwdSandbox("/workspace/repo/../other-repo", workspaceRoot); expect(decision.allowed).toBe(false); expect(decision.reason).toMatch(/is outside the workspace/); + expect(decision.code).toBe("OSW-KEY-007"); }); it("rejects an unrelated absolute path", () => { - expect(checkCwdSandbox("/etc/passwd", workspaceRoot).allowed).toBe(false); + const decision = checkCwdSandbox("/etc/passwd", workspaceRoot); + expect(decision.allowed).toBe(false); + expect(decision.code).toBe("OSW-KEY-007"); }); it("does not treat a prefix-matching sibling as inside the workspace", () => { // "/workspace/repo-evil" starts with the same string as root, but is not // a subdirectory of it — the boundary must be checked by path segments. - expect(checkCwdSandbox("/workspace/repo-evil", workspaceRoot).allowed).toBe(false); + const decision = checkCwdSandbox("/workspace/repo-evil", workspaceRoot); + expect(decision.allowed).toBe(false); + expect(decision.code).toBe("OSW-KEY-007"); }); it("allows any absolute cwd when explicit opt-in is enabled", () => { @@ -84,6 +89,17 @@ describe("checkAllowlist", () => { allowlist, ); expect(decision.allowed).toBe(false); + expect(decision.code).toBe("OSW-KEY-001"); + }); + + it("rejects an agent with rules but no in-process rule", () => { + const decision = checkAllowlist( + "claude-cli", + { kind: "in-process", agent: "claude-cli" }, + allowlist, + ); + expect(decision.allowed).toBe(false); + expect(decision.code).toBe("OSW-KEY-002"); }); it("rejects a disallowed executable for a known agent", () => { @@ -93,6 +109,7 @@ describe("checkAllowlist", () => { allowlist, ); expect(decision.allowed).toBe(false); + expect(decision.code).toBe("OSW-KEY-005"); }); it("rejects disallowed args for an allowed executable", () => { @@ -102,6 +119,7 @@ describe("checkAllowlist", () => { allowlist, ); expect(decision.allowed).toBe(false); + expect(decision.code).toBe("OSW-KEY-006"); }); it("allows a matching http invocation", () => { @@ -113,6 +131,16 @@ describe("checkAllowlist", () => { expect(decision.allowed).toBe(true); }); + it("rejects a URL and method the http rule does not allow", () => { + const decision = checkAllowlist( + "local-llm", + { kind: "http", url: "http://hppii-gpu:30000/v1/chat/completions", method: "GET" }, + allowlist, + ); + expect(decision.allowed).toBe(false); + expect(decision.code).toBe("OSW-KEY-004"); + }); + it("rejects http invocation for an agent without an __http__ rule", () => { const decision = checkAllowlist( "claude-cli", @@ -120,6 +148,7 @@ describe("checkAllowlist", () => { allowlist, ); expect(decision.allowed).toBe(false); + expect(decision.code).toBe("OSW-KEY-003"); }); }); diff --git a/packages/core/src/security.ts b/packages/core/src/security.ts index ece0586b..5dbf7d45 100644 --- a/packages/core/src/security.ts +++ b/packages/core/src/security.ts @@ -15,6 +15,7 @@ import type { AdapterInvocation } from "./agent-runner.js"; import type { AuditMessage } from "./audit-message.js"; import type { AgentUsage } from "./agent-usage.js"; import type { FailureDiagnosis } from "./failure-diagnosis.js"; +import { say, type MessageCode } from "./message-register.js"; import { instructionsForArtifact } from "./openspec.js"; import type { HarnessEffort } from "./harness-step-agent.js"; import type { HarnessStage } from "./harness-stage.js"; @@ -35,6 +36,8 @@ export type AllowlistConfig = Record; export interface AllowlistDecision { allowed: boolean; reason?: string; + /** The register's identifier for `reason`, on a refusal. */ + code?: MessageCode; } /** The allowlist's executable for an agent that runs in process and starts @@ -49,41 +52,44 @@ export function checkAllowlist( ): AllowlistDecision { const rules = allowlist[agentName]; if (!rules || rules.length === 0) { - return { allowed: false, reason: `Agent "${agentName}" is not present in the workspace allowlist` }; + const said = say("OSW-KEY-001", { agent: agentName }); + return { allowed: false, reason: said.text, code: said.code }; } if (invocation.kind === "in-process") { const rule = rules.find((r) => r.executable === IN_PROCESS_SENTINEL); if (!rule || !rule.argsAllowed([invocation.agent])) { - return { allowed: false, reason: `Agent "${agentName}" is not permitted to run in process` }; + const said = say("OSW-KEY-002", { agent: agentName }); + return { allowed: false, reason: said.text, code: said.code }; } return { allowed: true }; } if (invocation.kind === "http") { const rule = rules.find((r) => r.executable === "__http__"); if (!rule) { - return { allowed: false, reason: `HTTP call is not permitted by the allowlist for agent "${agentName}"` }; + const said = say("OSW-KEY-003", { agent: agentName }); + return { allowed: false, reason: said.text, code: said.code }; } const ok = rule.argsAllowed([invocation.url, invocation.method]); - return ok - ? { allowed: true } - : { allowed: false, reason: `URL/method "${invocation.method} ${invocation.url}" is not permitted by the allowlist` }; + if (ok) return { allowed: true }; + const said = say("OSW-KEY-004", { method: invocation.method, url: invocation.url }); + return { allowed: false, reason: said.text, code: said.code }; } const rule = rules.find((r) => r.executable === invocation.executable); if (!rule) { - return { - allowed: false, - reason: `Executable "${invocation.executable}" is not permitted by the allowlist for agent "${agentName}"`, - }; + const said = say("OSW-KEY-005", { executable: invocation.executable, agent: agentName }); + return { allowed: false, reason: said.text, code: said.code }; } const ok = rule.argsAllowed(invocation.args); - return ok - ? { allowed: true } - : { allowed: false, reason: `Arguments [${invocation.args.join(" ")}] are not permitted by the allowlist` }; + if (ok) return { allowed: true }; + const said = say("OSW-KEY-006", { arguments: invocation.args.join(" ") }); + return { allowed: false, reason: said.text, code: said.code }; } export interface CwdDecision { allowed: boolean; reason?: string; + /** The register's identifier for `reason`, on a refusal. */ + code?: MessageCode; } export interface CwdSandboxOptions { @@ -109,9 +115,9 @@ export function checkCwdSandbox(cwd: string, workspaceRoot: string, options: Cwd const normalizedRoot = process.platform === "win32" ? resolvedRoot.toLowerCase() : resolvedRoot; const normalizedRootWithSep = process.platform === "win32" ? rootWithSep.toLowerCase() : rootWithSep; const withinRoot = normalizedCwd === normalizedRoot || normalizedCwd.startsWith(normalizedRootWithSep); - return withinRoot - ? { allowed: true } - : { allowed: false, reason: `cwd "${cwd}" is outside the workspace "${workspaceRoot}"` }; + if (withinRoot) return { allowed: true }; + const said = say("OSW-KEY-007", { cwd, workspace: workspaceRoot }); + return { allowed: false, reason: said.text, code: said.code }; } export interface AgentPromptContext { diff --git a/packages/core/src/spec-delta-check.test.ts b/packages/core/src/spec-delta-check.test.ts index a5768993..b8d4c0f1 100644 --- a/packages/core/src/spec-delta-check.test.ts +++ b/packages/core/src/spec-delta-check.test.ts @@ -101,7 +101,8 @@ describe("checkSpecDeltaAgainstSpec", () => { + "#### Scenario: Configuring an agent for a mechanical stage\n"; const reasons = checkSpecDeltaAgainstSpec(delta, current); expect(reasons).toHaveLength(1); - expect(reasons[0]).toContain("offers no agent setting"); + expect(reasons[0]?.code).toBe("OSW-VAL-114"); + expect(reasons[0]?.text).toContain("offers no agent setting"); }); it("fails when the specification carries a scenario the block omits — agentic-harness-git-stage", () => { @@ -115,9 +116,11 @@ describe("checkSpecDeltaAgainstSpec", () => { + "#### Scenario: git stage runs after archive\n"; const reasons = checkSpecDeltaAgainstSpec(delta, current); expect(reasons).toHaveLength(3); - expect(reasons.join("\n")).toContain("A check fails"); - expect(reasons.join("\n")).toContain("No check result is available"); - expect(reasons.join("\n")).toContain("The pull request is left open for a human"); + expect(reasons.every((reason) => reason.code === "OSW-VAL-115")).toBe(true); + const texts = reasons.map((reason) => reason.text).join("\n"); + expect(texts).toContain("A check fails"); + expect(texts).toContain("No check result is available"); + expect(texts).toContain("The pull request is left open for a human"); }); it("does not fail when the delta adds a scenario the specification doesn't have yet", () => { @@ -140,6 +143,7 @@ describe("checkSpecDeltaDrift", () => { expect(violations).toHaveLength(1); expect(violations[0]?.changeId).toBe("the-change"); expect(violations[0]?.specId).toBe("some-capability"); + expect(violations[0]?.code).toBe("OSW-VAL-115"); expect(violations[0]?.reason).toContain("Added later"); }); diff --git a/packages/core/src/spec-delta-check.ts b/packages/core/src/spec-delta-check.ts index 0b9323eb..bafde979 100644 --- a/packages/core/src/spec-delta-check.ts +++ b/packages/core/src/spec-delta-check.ts @@ -8,6 +8,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; import { discoverOpenSpecWorkspace } from "./workbench.js"; export interface SpecRequirement { @@ -65,7 +66,10 @@ export interface SpecDeltaViolation { changeId: string; specId: string; filePath: string; + /** The register's words (ADR 0046). */ reason: string; + /** The identifier of the message in `reason`. */ + code: MessageCode; } /** Compares one delta's `## MODIFIED Requirements` block against the @@ -78,27 +82,22 @@ export interface SpecDeltaViolation { export function checkSpecDeltaAgainstSpec( deltaModifiedSection: string, currentSpecSource: string, -): string[] { +): SaidMessage[] { const deltaRequirements = parseSpecRequirements(deltaModifiedSection); const currentRequirements = parseSpecRequirements(currentSpecSource); const currentByHeader = new Map(currentRequirements.map((requirement) => [requirement.header, requirement])); - const reasons: string[] = []; + const reasons: SaidMessage[] = []; for (const delta of deltaRequirements) { const current = currentByHeader.get(delta.header); if (!current) { - reasons.push( - `requirement header ${JSON.stringify(delta.header)} does not exist in the current specification`, - ); + reasons.push(say("OSW-VAL-114", { header: delta.header })); continue; } const deltaScenarios = new Set(delta.scenarios); for (const scenario of current.scenarios) { if (!deltaScenarios.has(scenario)) { - reasons.push( - `scenario ${JSON.stringify(scenario)} under requirement ${JSON.stringify(delta.header)}` - + " exists in the current specification but is omitted from this change's modified block", - ); + reasons.push(say("OSW-VAL-115", { scenario, header: delta.header })); } } } @@ -130,8 +129,14 @@ export async function checkSpecDeltaDrift(root: string): Promise { it("records a refused request as a message entry, without the reason nobody verified", () => { const auditLog = new InMemoryAuditLog(); - chainStopRequestHandlers({ requestStop: () => true }, command, auditLog).onStopRequestRefused?.({ messageId: "m-2", why: "unverified", reason: "unverified words" }); + chainStopRequestHandlers({ requestStop: () => true }, command, auditLog).onStopRequestRefused?.({ messageId: "m-2", why: "unverified", reason: "unverified words", code: "OSW-AGT-101" }); expect(auditLog.entries).toEqual([ expect.objectContaining({ @@ -77,7 +77,7 @@ describe("agentStopRequestHandlers", () => { const auditLog = new InMemoryAuditLog(); const single: Command = { ...command, kind: "implement", agentId: "claude-cli" }; - agentStopRequestHandlers({ run: async function* () {} }, single, auditLog).onStopRequestRefused?.({ messageId: "m-4", why: "stale", reason: "x" }); + agentStopRequestHandlers({ run: async function* () {} }, single, auditLog).onStopRequestRefused?.({ messageId: "m-4", why: "stale", reason: "x", code: "OSW-AGT-102" }); expect(auditLog.entries).toEqual([expect.objectContaining({ agent: "claude-cli", outcome: "message", stopRequestRefused: { messageId: "m-4", why: "stale" } })]); }); diff --git a/packages/core/src/successor-check.test.ts b/packages/core/src/successor-check.test.ts index ed283eaf..c5817a43 100644 --- a/packages/core/src/successor-check.test.ts +++ b/packages/core/src/successor-check.test.ts @@ -84,6 +84,7 @@ describe("checkNamedSuccessors", () => { expect(violations).toHaveLength(1); expect(violations[0]?.changeId).toBe("earlier"); expect(violations[0]?.successorId).toBe("later"); + expect(violations[0]?.code).toBe("OSW-VAL-116"); expect(violations[0]?.reason).toContain("earlier"); expect(violations[0]?.reason).toContain("later"); expect(violations[0]?.reason).toContain("follows"); diff --git a/packages/core/src/successor-check.ts b/packages/core/src/successor-check.ts index f70b9092..e745a2a3 100644 --- a/packages/core/src/successor-check.ts +++ b/packages/core/src/successor-check.ts @@ -13,6 +13,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { readChangeGraph, type ChangeGraph } from "./change-graph.js"; +import { say, type MessageCode } from "./message-register.js"; import { discoverOpenSpecWorkspace } from "./workbench.js"; /** The exact wording this check looks for — see @@ -56,7 +57,10 @@ export function findNamedSuccessors(changeId: string, tasksContent: string): Nam } export interface OrphanedSuccessor extends NamedSuccessor { + /** The register's words (ADR 0046). */ reason: string; + /** The identifier of the message in `reason`. */ + code: MessageCode; } /** A successor is real when a change of the named id exists — active or @@ -97,10 +101,11 @@ export async function checkNamedSuccessors(root: string): Promise { it("repeats on the same agent where repeating is likely to help", () => { expect(superviseFailure(facts({ diagnosis: { cause: "rate-limited", repeatHelps: "likely" } }))) - .toEqual({ action: "repeat", agent: "copilot-cli-acp", because: expect.stringContaining("rate") }); + .toEqual({ action: "repeat", agent: "copilot-cli-acp", because: expect.stringContaining("rate"), code: "OSW-AGT-205" }); }); it("moves to the first fallback the policy allows that was not tried", () => { @@ -235,12 +235,12 @@ describe("superviseFailure", () => { it("refuses a provider change, and names the rule", () => { const decision = superviseFailure(facts({ policy: { fallback: { apply: ["claude-cli-acp"] }, allowProviderChange: false } })); - expect(decision).toEqual({ action: "none", why: expect.stringContaining("claude-cli-acp is another provider (anthropic), and allowProviderChange is false") }); + expect(decision).toEqual({ action: "none", why: expect.stringContaining("claude-cli-acp is another provider (anthropic), and allowProviderChange is false"), code: "OSW-HRN-125" }); }); it("refuses a cost increase, and names the rule", () => { const decision = superviseFailure(facts({ policy: { fallback: { apply: ["copilot-cli"] }, allowCostIncrease: false } })); - expect(decision).toEqual({ action: "none", why: expect.stringContaining("copilot-cli may cost money, and allowCostIncrease is false") }); + expect(decision).toEqual({ action: "none", why: expect.stringContaining("copilot-cli may cost money, and allowCostIncrease is false"), code: "OSW-HRN-125" }); }); it("allows the local model without allowCostIncrease, and names the fallback it passed over", () => { @@ -248,19 +248,20 @@ describe("superviseFailure", () => { action: "move", agent: "local-llm-acp", because: expect.stringContaining("passed over: claude-cli-acp may cost money, and allowCostIncrease is false"), + code: "OSW-HRN-127", }); }); it("tries nothing twice", () => { expect(superviseFailure(facts({ tried: ["copilot-cli-acp", "claude-cli-acp", "local-llm-acp"] }))) - .toEqual({ action: "none", why: expect.stringContaining("every fallback for apply has been tried") }); + .toEqual({ action: "none", why: expect.stringContaining("every fallback for apply has been tried"), code: "OSW-HRN-126" }); }); it("does nothing where the cause is unknown, or the stage has no fallback", () => { expect(superviseFailure(facts({ diagnosis: { cause: "unknown", repeatHelps: "unknown" } }))).toMatchObject({ action: "none" }); expect(superviseFailure(facts({ diagnosis: undefined }))).toMatchObject({ action: "none" }); expect(superviseFailure(facts({ policy: { fallback: {} } }))) - .toEqual({ action: "none", why: expect.stringContaining("apply names no fallback agent") }); + .toEqual({ action: "none", why: expect.stringContaining("apply names no fallback agent"), code: "OSW-HRN-124" }); }); }); diff --git a/packages/core/src/supervisor.ts b/packages/core/src/supervisor.ts index 1cecb237..8e094f7a 100644 --- a/packages/core/src/supervisor.ts +++ b/packages/core/src/supervisor.ts @@ -22,10 +22,11 @@ import type { AgentStatusReport } from "./agent-status.js"; import { AGENT_REGISTRY, type AgentProvider } from "./agents/registry.js"; -import { describeDiagnosis, type FailureDiagnosis } from "./failure-diagnosis.js"; +import { describeDiagnosis, failureCauseCode, type FailureDiagnosis } from "./failure-diagnosis.js"; import type { ResolvedSupervisor } from "./harness-config.js"; import type { HarnessStepAgentStage } from "./harness-step-agent.js"; import type { Hint } from "./hints.js"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; import type { LastRunsReport } from "./last-runs-facts.js"; import { describeWaiting } from "./worktree-survey-facts.js"; @@ -161,9 +162,9 @@ function isHarnessStepAgentStageName(stage: string): stage is HarnessStepAgentSt /** What the supervisor does about a failed stage under `act` * (the-supervisor-changes-agents, ADR 0039 decision 4). */ export type FailureDecision = - | { action: "repeat"; agent: string; because: string } - | { action: "move"; agent: string; because: string } - | { action: "none"; why: string }; + | { action: "repeat"; agent: string; because: string; code: MessageCode } + | { action: "move"; agent: string; because: string; code: MessageCode } + | { action: "none"; why: string; code: MessageCode }; export interface FailureFactsForDecision { stage: HarnessStepAgentStage; @@ -187,15 +188,15 @@ export function fallbackRefusal( current: string, candidate: string, supervisor: Pick, -): string | undefined { +): SaidMessage | undefined { const from = providerOf(current); const to = providerOf(candidate); - if (to === undefined) return `${candidate} is not a registered agent`; + if (to === undefined) return say("OSW-HRN-120", { agent: candidate }); if (to !== from && !supervisor.allowProviderChange) { - return `${candidate} is another provider (${to}), and allowProviderChange is false`; + return say("OSW-HRN-121", { agent: candidate, provider: to }); } if (to !== "local" && !supervisor.allowCostIncrease) { - return `${candidate} may cost money, and allowCostIncrease is false`; + return say("OSW-HRN-122", { agent: candidate }); } return undefined; } @@ -206,33 +207,42 @@ export function fallbackRefusal( export function superviseFailure(facts: FailureFactsForDecision): FailureDecision { const { diagnosis } = facts; if (diagnosis === undefined || diagnosis.repeatHelps === "unknown") { - return { action: "none", why: "the cause is not known, so the stage is neither repeated nor moved" }; + const said = say("OSW-HRN-123"); + return { action: "none", why: said.text, code: said.code }; } const cause = describeDiagnosis(diagnosis); + const causeCode = failureCauseCode(diagnosis.cause); if (diagnosis.repeatHelps === "likely") { - return { action: "repeat", agent: facts.current, because: `${cause}` }; + return { action: "repeat", agent: facts.current, because: cause, code: causeCode }; } const fallback = facts.supervisor.fallback[facts.stage] ?? []; if (fallback.length === 0) { - return { action: "none", why: `${cause}, and ${facts.stage} names no fallback agent` }; + const said = say("OSW-HRN-124", { cause, stage: facts.stage }); + return { action: "none", why: said.text, code: said.code }; } - const refusals: string[] = []; + const refusals: SaidMessage[] = []; for (const candidate of fallback) { if (facts.tried.includes(candidate)) continue; const refusal = fallbackRefusal(facts.current, candidate, facts.supervisor); // A fallback passed over on the way is named too: a move that skipped // one says why, not only where it went. if (refusal === undefined) { - return { action: "move", agent: candidate, because: refusals.length > 0 ? `${cause} (passed over: ${refusals.join("; ")})` : cause }; + const passedOver = refusals.length > 0 + ? say("OSW-HRN-127", { cause, refusals: refusals.map((one) => one.text).join("; ") }) + : undefined; + return { + action: "move", + agent: candidate, + because: passedOver?.text ?? cause, + code: passedOver?.code ?? causeCode, + }; } refusals.push(refusal); } - return { - action: "none", - why: refusals.length > 0 - ? `${cause}, and the policy refuses every fallback left: ${refusals.join("; ")}` - : `${cause}, and every fallback for ${facts.stage} has been tried`, - }; + const said = refusals.length > 0 + ? say("OSW-HRN-125", { cause, refusals: refusals.map((one) => one.text).join("; ") }) + : say("OSW-HRN-126", { cause, stage: facts.stage }); + return { action: "none", why: said.text, code: said.code }; } /** What the supervisor points out, in the order a person deals with it: diff --git a/packages/core/src/task-checklist.test.ts b/packages/core/src/task-checklist.test.ts index d1a4f853..3110e56e 100644 --- a/packages/core/src/task-checklist.test.ts +++ b/packages/core/src/task-checklist.test.ts @@ -247,9 +247,9 @@ describe("readTaskChecklist — mechanical check declarations", () => { "- [ ] A misspelled check. `check(typechek)`\n", ); - await expect( - readTaskChecklist(root, "active-change", false), - ).rejects.toThrow(UnknownMechanicalCheckError); + const error = await readTaskChecklist(root, "active-change", false).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(UnknownMechanicalCheckError); + expect((error as UnknownMechanicalCheckError).code).toBe("OSW-TSK-010"); }); it("throws InvalidMechanicalCheckParameterError for a path-unchanged path that escapes the workspace", async () => { @@ -261,9 +261,9 @@ describe("readTaskChecklist — mechanical check declarations", () => { "- [ ] Sneaky task. `check(path-unchanged, ../../outside-the-repo)`\n", ); - await expect( - readTaskChecklist(root, "active-change", false), - ).rejects.toThrow(InvalidMechanicalCheckParameterError); + const error = await readTaskChecklist(root, "active-change", false).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(InvalidMechanicalCheckParameterError); + expect((error as InvalidMechanicalCheckParameterError).code).toBe("OSW-TSK-011"); }); }); @@ -305,15 +305,15 @@ describe("deleteTaskLine", () => { const original = "- [ ] First\n- [ ] Second\n"; await writeFile(tasksPath, original); - await expect( - deleteTaskLine( - root, - "active-change", - false, - 0, - "A task that no longer exists", - ), - ).rejects.toThrow(TaskListChangedError); + const error = await deleteTaskLine( + root, + "active-change", + false, + 0, + "A task that no longer exists", + ).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(TaskListChangedError); + expect((error as TaskListChangedError).code).toBe("OSW-TSK-014"); expect(await readFile(tasksPath, "utf8")).toBe(original); }); diff --git a/packages/core/src/task-checklist.ts b/packages/core/src/task-checklist.ts index 7ffcf74c..5e7580b2 100644 --- a/packages/core/src/task-checklist.ts +++ b/packages/core/src/task-checklist.ts @@ -1,6 +1,7 @@ import { readFile, stat, writeFile } from "node:fs/promises"; import { isMechanicalCheckName, MECHANICAL_CHECK_NAMES, type MechanicalCheckName } from "./mechanical-checks.js"; import { TASK_NUMBER_PATTERN } from "./harness-step-agent.js"; +import { say, type MessageCode } from "./message-register.js"; import { discoverOpenSpecWorkspace, type OpenSpecWorkspace, type WorkbenchChange } from "./workbench.js"; // See openspec/changes/tasks-tree-expand/design.md. Paths always come @@ -144,13 +145,13 @@ export interface TaskDebts { } export function describeTaskDebts(items: readonly TaskChecklistItem[]): TaskDebts { - const say = (item: { text: string }): string => { + const debtLabel = (item: { text: string }): string => { const number = taskNumberOf(item.text); return number === undefined ? item.text : `${number} ${item.text.slice(number.length).trim()}`; }; return { - open: items.filter((item) => !item.done).map(say), - unrecorded: items.filter((item) => isUnrecordedTask(item)).map(say), + open: items.filter((item) => !item.done).map(debtLabel), + unrecorded: items.filter((item) => isUnrecordedTask(item)).map(debtLabel), }; } @@ -235,8 +236,11 @@ export function taskNumberOf(text: string): string | undefined { * quietly becoming an ordinary task is exactly the failure this * capability exists to remove). */ export class UnknownMechanicalCheckError extends Error { + /** The register's identifier for this refusal. */ + readonly code: MessageCode; constructor(name: string) { - super(`Unknown mechanical check "${name}" (expected one of: ${MECHANICAL_CHECK_NAMES.join(", ")})`); + super(say("OSW-TSK-010", { name, checks: MECHANICAL_CHECK_NAMES.join(", ") }).text); + this.code = "OSW-TSK-010"; this.name = "UnknownMechanicalCheckError"; } } @@ -249,8 +253,11 @@ export class UnknownMechanicalCheckError extends Error { * bad shape here means a `tasks.md` typo is caught at parse time, not * only when `verify` eventually runs the check. */ export class InvalidMechanicalCheckParameterError extends Error { + /** The register's identifier for this refusal. */ + readonly code: MessageCode; constructor(name: MechanicalCheckName, param: string, reason: string) { - super(`Invalid parameter for check "${name}" ("${param}"): ${reason}`); + super(say("OSW-TSK-011", { name, param, reason }).text); + this.code = "OSW-TSK-011"; this.name = "InvalidMechanicalCheckParameterError"; } } @@ -289,10 +296,10 @@ export function parseTaskCheckDeclaration(text: string): TaskCheckDeclaration | const param = match[2]?.trim(); if (name === "path-unchanged") { if (!param) { - throw new InvalidMechanicalCheckParameterError(name, "", "path-unchanged requires a repository-relative path parameter"); + throw new InvalidMechanicalCheckParameterError(name, "", say("OSW-TSK-012").text); } if (!isSyntacticallySafeRelativePath(param)) { - throw new InvalidMechanicalCheckParameterError(name, param, "must be a repository-relative path, not absolute and not containing \"..\""); + throw new InvalidMechanicalCheckParameterError(name, param, say("OSW-TSK-013").text); } } @@ -300,8 +307,11 @@ export function parseTaskCheckDeclaration(text: string): TaskCheckDeclaration | } export class TaskListChangedError extends Error { + /** The register's identifier for this refusal. */ + readonly code: MessageCode; constructor(changeName: string) { - super(`Task list for ${changeName} has changed since it was last loaded — refresh and try again.`); + super(say("OSW-TSK-014", { change: changeName }).text); + this.code = "OSW-TSK-014"; this.name = "TaskListChangedError"; } } diff --git a/packages/core/src/verify-quality.test.ts b/packages/core/src/verify-quality.test.ts index 6032d8dd..5d21f961 100644 --- a/packages/core/src/verify-quality.test.ts +++ b/packages/core/src/verify-quality.test.ts @@ -133,18 +133,21 @@ describe("describeVerifyQuality", () => { it("tells an empty log apart from a log whose runs never verified", () => { // Two different facts, and only one of them is fixed by running // something. - expect(describeVerifyQuality(buildVerifyQuality([], known))) - .toContain("no runs have been logged"); + const empty = describeVerifyQuality(buildVerifyQuality([], known)); + expect(empty.code).toBe("OSW-VAL-117"); + expect(empty.text).toContain("no runs have been logged"); const noChecks = describeVerifyQuality(buildVerifyQuality([entry({}), entry({})], known)); - expect(noChecks).toContain("2 runs recorded"); - expect(noChecks).toContain("none of which reached a verifying stage"); + expect(noChecks.code).toBe("OSW-VAL-118"); + expect(noChecks.text).toContain("2 runs recorded"); + expect(noChecks.text).toContain("none of which reached a verifying stage"); }); it("says how many groups are too thin to read as a rate", () => { - const text = describeVerifyQuality(buildVerifyQuality([checksEntry({ checksRan: 1, checksFailed: 0 })], known)); + const said = describeVerifyQuality(buildVerifyQuality([checksEntry({ checksRan: 1, checksFailed: 0 })], known)); - expect(text).toContain(`fewer than ${ENOUGH_VERIFIES}`); + expect(said.code).toBe("OSW-VAL-120"); + expect(said.text).toContain(`fewer than ${ENOUGH_VERIFIES}`); }); it("says when stages were recorded before the checked agent was named", () => { @@ -152,14 +155,16 @@ describe("describeVerifyQuality", () => { checksEntry({ checksRan: 1, checksFailed: 0 }), entry({ agent: VERIFY_CHECKS_AGENT_NAME, stage: "verify", checksRan: 1, checksFailed: 0 }), ], known)); - expect(mixed).toContain("1 of them named no checked agent"); - expect(mixed).toContain("charged to no agent"); + expect(mixed.code).toBe("OSW-VAL-120"); + expect(mixed.text).toContain("1 of them named no checked agent"); + expect(mixed.text).toContain("charged to no agent"); // All of them unattributed is its own state: there is nothing to // show per agent, and "nothing recorded" would be false. const allLegacy = describeVerifyQuality(buildVerifyQuality([ entry({ agent: VERIFY_CHECKS_AGENT_NAME, stage: "verify", checksRan: 1, checksFailed: 0 }), ], known)); - expect(allLegacy).toContain("none of which names the agent whose work was checked"); + expect(allLegacy.code).toBe("OSW-VAL-119"); + expect(allLegacy.text).toContain("none of which names the agent whose work was checked"); }); }); diff --git a/packages/core/src/verify-quality.ts b/packages/core/src/verify-quality.ts index adf6dea4..63e234f7 100644 --- a/packages/core/src/verify-quality.ts +++ b/packages/core/src/verify-quality.ts @@ -25,6 +25,7 @@ // Pure over entries, like its neighbours: the caller supplies both the // entries and which changes still exist. +import { say, type SaidMessage } from "./message-register.js"; import type { AuditEntry } from "./security.js"; import type { KnownChanges } from "./workspace-run-stats.js"; import { belongsToKnownChange } from "./workspace-run-stats.js"; @@ -142,29 +143,37 @@ export function buildVerifyQuality( }; } -/** The sentence a surface shows above the figures. +/** The sentence a surface shows above the figures, by its identifier + * (ADR 0046). The caller renders `code` beside `text`. * * Says which of the empty states this is: a log with nothing in it, a * log whose runs never reached a verifying stage, and a log whose * verifying stages all predate the field naming whose work they checked * are three different facts, and only the first two say anything about * running something. */ -export function describeVerifyQuality(quality: VerifyQuality): string { - if (quality.entriesRead === 0) return "Nothing recorded yet — no runs have been logged in this workspace."; +export function describeVerifyQuality(quality: VerifyQuality): SaidMessage { + if (quality.entriesRead === 0) return say("OSW-VAL-117"); if (quality.entriesWithChecks === 0) { - return `Nothing to report yet — ${quality.entriesRead} runs recorded, none of which reached a verifying stage` - + " that reported what its checks found."; + return say("OSW-VAL-118", { runs: quality.entriesRead }); } if (quality.byAgent.length === 0) { - return `${quality.entriesWithChecks} verifying stage(s) recorded, none of which names the agent whose work was` - + " checked. They were recorded before that was written down, so none is charged to an agent."; + return say("OSW-VAL-119", { stages: quality.entriesWithChecks }); } const thin = quality.byAgent.filter((group) => !group.enough).length; const unnamed = quality.entriesBeforeAgentNamed; - return `${quality.entriesWithChecks} verifying stage(s) across ${quality.byAgent.length} checked agent(s)` - + (thin === 0 ? "." : `; ${thin} rest${thin === 1 ? "s" : ""} on fewer than ${ENOUGH_VERIFIES} and is reported as thin.`) - + (unnamed === 0 - ? "" - : ` ${unnamed} of them named no checked agent, having been recorded before that was written down, and` - + ` ${unnamed === 1 ? "is" : "are"} charged to no agent.`); + // Two clauses the sentence may leave out, each said only when it has + // something to say: the register holds the frame, these the words. + const thinClause = thin === 0 + ? "." + : `; ${thin} rest${thin === 1 ? "" : "s"} on fewer than ${ENOUGH_VERIFIES} and is reported as thin.`; + const unnamedClause = unnamed === 0 + ? "" + : ` ${unnamed} of them named no checked agent, having been recorded before that was written down, and` + + ` ${unnamed === 1 ? "is" : "are"} charged to no agent.`; + return say("OSW-VAL-120", { + stages: quality.entriesWithChecks, + agents: quality.byAgent.length, + thinClause, + unnamedClause, + }); } diff --git a/packages/core/src/workspace-lease.test.ts b/packages/core/src/workspace-lease.test.ts index ba42d01b..09127a3c 100644 --- a/packages/core/src/workspace-lease.test.ts +++ b/packages/core/src/workspace-lease.test.ts @@ -62,7 +62,9 @@ describe("WorkspaceLeaseManager", () => { expect(result.conflict.author).toBe("ada@example.com"); // "git author", never "user": nothing is gated on a self-declared // label, and the message must not imply otherwise. - expect(describeWorkspaceLeaseConflict(result.conflict)).toContain("git author ada@example.com"); + const said = describeWorkspaceLeaseConflict(result.conflict); + expect(said.text).toContain("git author ada@example.com"); + expect(said.code).toBe("OSW-WSP-001"); } }); @@ -80,10 +82,11 @@ describe("WorkspaceLeaseManager", () => { expect(result.ok).toBe(false); if (!result.ok) { const described = describeWorkspaceLeaseConflict(result.conflict); - expect(described).not.toContain("git author"); - expect(described).toContain("standalone server"); + expect(described.text).not.toContain("git author"); + expect(described.text).toContain("standalone server"); + expect(described.code).toBe("OSW-WSP-001"); // No stray punctuation where the name would have been. - expect(described).not.toContain(", ,"); + expect(described.text).not.toContain(", ,"); } }); @@ -113,7 +116,9 @@ describe("WorkspaceLeaseManager", () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.conflict).toMatchObject({ hostKind: "standalone-server", pid: process.pid }); - expect(describeWorkspaceLeaseConflict(result.conflict)).toContain("standalone server"); + const said = describeWorkspaceLeaseConflict(result.conflict); + expect(said.text).toContain("standalone server"); + expect(said.code).toBe("OSW-WSP-001"); } }); @@ -131,7 +136,9 @@ describe("WorkspaceLeaseManager", () => { expect(result.ok).toBe(true); if (result.ok) { expect(result.reclaimedFrom).toMatchObject({ hostKind: "standalone-server" }); - expect(describeWorkspaceLeaseReclamation(result.reclaimedFrom!)).toContain("Reclaimed"); + const said = describeWorkspaceLeaseReclamation(result.reclaimedFrom!); + expect(said.text).toContain("Reclaimed"); + expect(said.code).toBe("OSW-WSP-101"); } const document = await readLease(root); expect(document.hostKind).toBe("vscode-extension"); @@ -175,8 +182,9 @@ describe("a terminal run as a lease holder", () => { heartbeatAgeMs: 3_000, }); - expect(message).toContain("terminal run on build-agent-2"); - expect(message).not.toContain("standalone server"); + expect(message.text).toContain("terminal run on build-agent-2"); + expect(message.text).not.toContain("standalone server"); + expect(message.code).toBe("OSW-WSP-001"); }); it("names every host kind it can be told about", () => { @@ -185,8 +193,9 @@ describe("a terminal run as a lease holder", () => { ["vscode-extension", "VS Code extension"], ["standalone-server", "standalone server"], ] as const) { - expect(describeWorkspaceLeaseReclamation({ hostKind, hostname: "h", pid: 1, heartbeatAgeMs: 0 })) - .toContain(label); + const said = describeWorkspaceLeaseReclamation({ hostKind, hostname: "h", pid: 1, heartbeatAgeMs: 0 }); + expect(said.text).toContain(label); + expect(said.code).toBe("OSW-WSP-101"); } }); diff --git a/packages/core/src/workspace-lease.ts b/packages/core/src/workspace-lease.ts index e257cbca..09b3cfda 100644 --- a/packages/core/src/workspace-lease.ts +++ b/packages/core/src/workspace-lease.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { say, type SaidMessage } from "./message-register.js"; // Cross-host mutation isolation (docs/adr/0010-cross-host-workspace-lease.md). // Extends ADR 0004 decision 4's "one mutating run per workspace" invariant @@ -94,27 +95,31 @@ export function hostKindLabel(hostKind: WorkspaceLeaseHostKind): string { return HOST_KIND_LABELS[hostKind] ?? String(hostKind); } -export function describeWorkspaceLeaseConflict(conflict: WorkspaceLeaseConflict): string { +/** The conflict said by its identifier (ADR 0046). The words are the + * register's; `author` is inserted as `, git author ` and not as + * "user": the value is self-declared, and a message that called it a user + * would read as an identity this system had established. */ +export function describeWorkspaceLeaseConflict(conflict: WorkspaceLeaseConflict): SaidMessage { const heartbeatAgeSeconds = Math.round(conflict.heartbeatAgeMs / 1000); - // "git author" and not "user": the value is self-declared, and a - // message that called it a user would read as an identity this system - // had established. const author = conflict.author ? `, git author ${conflict.author}` : ""; - return ( - `Another OpenSpec Workbench host (${hostKindLabel(conflict.hostKind)} on ` + - `${conflict.hostname}, pid ${conflict.pid}${author}, last active ${heartbeatAgeSeconds}s ago) ` + - `is currently running a mutating operation on this workspace. Wait for it to ` + - `finish, or close it, before starting one here.` - ); + return say("OSW-WSP-001", { + hostKind: hostKindLabel(conflict.hostKind), + hostname: conflict.hostname, + pid: conflict.pid, + author, + seconds: heartbeatAgeSeconds, + }); } -export function describeWorkspaceLeaseReclamation(conflict: WorkspaceLeaseConflict): string { +/** The reclamation said by its identifier (ADR 0046). */ +export function describeWorkspaceLeaseReclamation(conflict: WorkspaceLeaseConflict): SaidMessage { const heartbeatAgeSeconds = Math.round(conflict.heartbeatAgeMs / 1000); - return ( - `Reclaimed the workspace lease from ${hostKindLabel(conflict.hostKind)} on ` + - `${conflict.hostname} (pid ${conflict.pid}), which stopped renewing it ` + - `${heartbeatAgeSeconds}s ago.` - ); + return say("OSW-WSP-101", { + hostKind: hostKindLabel(conflict.hostKind), + hostname: conflict.hostname, + pid: conflict.pid, + seconds: heartbeatAgeSeconds, + }); } /** Who holds a working directory right now, or `undefined` when nobody diff --git a/packages/core/src/workspace-leftovers.test.ts b/packages/core/src/workspace-leftovers.test.ts index a097c16a..cf6c8903 100644 --- a/packages/core/src/workspace-leftovers.test.ts +++ b/packages/core/src/workspace-leftovers.test.ts @@ -141,6 +141,7 @@ describe("removeWorkingDirectory", () => { expect(result.ok).toBe(false); expect(!result.ok && result.reason).toContain("not clean"); + expect(!result.ok && result.code).toBe("OSW-WSP-009"); expect(await namesUnder(directory, ".")).toEqual(["kept.txt"]); }); @@ -358,7 +359,8 @@ describe("withHolders", () => { it("says the original error where nothing names the directory", async () => { const said = await withHolders(path.join(os.tmpdir(), "openspec-unheld"), new Error("EBUSY: resource busy")); - expect(said).toContain("EBUSY: resource busy"); - expect(said).toContain("No process names this directory"); + expect(said.text).toContain("EBUSY: resource busy"); + expect(said.text).toContain("No process names this directory"); + expect(said.code).toBe("OSW-WSP-104"); }); }); diff --git a/packages/core/src/workspace-leftovers.ts b/packages/core/src/workspace-leftovers.ts index 23e8c35b..4087135e 100644 --- a/packages/core/src/workspace-leftovers.ts +++ b/packages/core/src/workspace-leftovers.ts @@ -19,6 +19,7 @@ import { readdir, rm, stat, lstat, unlink, rmdir } from "./plain-fs.js"; import path from "node:path"; import { createGitWrapper, type GitWrapper } from "./git.js"; +import { say, type MessageCode, type SaidMessage } from "./message-register.js"; /** What makes a directory a change rather than a leftover. `specs` is a * directory; the rest are files. */ @@ -140,6 +141,9 @@ export interface LeftoverRemovalFailure { name: string; path: string; reason: string; + /** The register's identifier for `reason`, where it is one of the + * register's sentences rather than a bare operating-system error. */ + code?: MessageCode; } export interface LeftoverSweep { @@ -337,10 +341,12 @@ export async function clearWorktreeShells( await rm(shell.path, { recursive: true, force: true }); sweep.removed.push(shell); } catch (error) { + const said = await withHolders(shell.path, error); sweep.failures.push({ name: shell.name, path: shell.path, - reason: await withHolders(shell.path, error), + reason: said.text, + code: said.code, }); } } @@ -415,20 +421,20 @@ async function defaultProcessList(command: string, args: string[]): Promise { + * anything can be found, said by its identifier (ADR 0046). */ +export async function withHolders(directory: string, error: unknown): Promise { const said = error instanceof Error ? error.message : String(error); const holders = await whoMightHold(directory); if (holders.length === 0) { - return `${said}. No process names this directory on its command line; one that holds it without naming it is not found this way.`; + return say("OSW-WSP-104", { said }); } const named = holders.map((holder) => `${holder.name} (${holder.pid})`).join(", "); - return `${said}. Held by ${named}, by their command lines.`; + return say("OSW-WSP-105", { said, holders: named }); } export type WorkingDirectoryRemoval = | { ok: true; path: string; linksRemoved: number } - | { ok: false; path: string; reason: string }; + | { ok: false; path: string; reason: string; code?: MessageCode }; /** Deletes a link as a link, wherever one is found. * @@ -481,7 +487,8 @@ export async function removeWorkingDirectory( try { const status = await git.status(); if (!status.isClean) { - return { ok: false, path: directory, reason: "the working tree is not clean" }; + const said = say("OSW-WSP-009"); + return { ok: false, path: directory, reason: said.text, code: said.code }; } } catch (error) { return { ok: false, path: directory, reason: error instanceof Error ? error.message : String(error) }; @@ -494,6 +501,7 @@ export async function removeWorkingDirectory( } catch (error) { // Who, not just what: "access denied" is not something a person can // act on (what-is-finished-is-tidied-away). - return { ok: false, path: directory, reason: await withHolders(directory, error) }; + const said = await withHolders(directory, error); + return { ok: false, path: directory, reason: said.text, code: said.code }; } } diff --git a/packages/core/src/worktree-survey-facts.ts b/packages/core/src/worktree-survey-facts.ts index 0831e9ee..6738f9eb 100644 --- a/packages/core/src/worktree-survey-facts.ts +++ b/packages/core/src/worktree-survey-facts.ts @@ -13,6 +13,7 @@ import type { ChangeStanding, StandingCopy, StandingRun } from "./change-standin import { describeSignature, type EnrolledPerson, type RecordSignature } from "./signature-facts.js"; import type { TaskInHand } from "./task-marker.js"; import type { WorkspaceLeaseConflict } from "./workspace-lease.js"; +import type { MessageCode } from "./message-register.js"; /** One item of a change's task list, as an open card lists it * (a-card-opens-to-its-tasks). */ @@ -178,7 +179,14 @@ export type SurveyedDirectory = * directory, becomes visible. */ authorDiffers: boolean; }) - | (SurveyedDirectoryBase & { readable: false; reason: string }); + | (SurveyedDirectoryBase & { + readable: false; + reason: string; + /** The register's identifier for `reason`, where it is one of the + * register's sentences rather than a bare operating-system error + * (ADR 0046). */ + code?: MessageCode; + }); export interface WorktreeSurvey { directories: SurveyedDirectory[]; diff --git a/packages/core/src/worktree-survey.test.ts b/packages/core/src/worktree-survey.test.ts index efca55dd..5106ccef 100644 --- a/packages/core/src/worktree-survey.test.ts +++ b/packages/core/src/worktree-survey.test.ts @@ -155,12 +155,29 @@ describe("surveyWorktrees — directories and their changes", () => { const { main, worktreeRoot, rootSources } = await repository(); await makeChange(main, "change-a"); const gone = path.join(worktreeRoot, "repo", "removed-by-hand"); - const { git } = recordingGit([{ path: main, branch: "main" }, { path: gone, branch: "removed-by-hand" }]); + const notADirectory = path.join(worktreeRoot, "repo", "a-file"); + await mkdir(path.dirname(notADirectory), { recursive: true }); + await writeFile(notADirectory, "not a directory", "utf8"); + const { git } = recordingGit([ + { path: main, branch: "main" }, + { path: gone, branch: "removed-by-hand" }, + { path: notADirectory, branch: "a-file" }, + ]); const survey = await surveyWorktrees({ workspaceRoot: main, git, rootSources }); expect(survey.directories[0]?.readable).toBe(true); - expect(survey.directories[1]).toMatchObject({ readable: false, reason: "it does not exist", label: "removed-by-hand" }); + expect(survey.directories[1]).toMatchObject({ + readable: false, + reason: "it does not exist", + code: "OSW-WSP-103", + label: "removed-by-hand", + }); + expect(survey.directories[2]).toMatchObject({ + readable: false, + reason: "it is not a directory", + code: "OSW-WSP-102", + }); }); // 6.4 diff --git a/packages/core/src/worktree-survey.ts b/packages/core/src/worktree-survey.ts index a0f48981..d8fffbbb 100644 --- a/packages/core/src/worktree-survey.ts +++ b/packages/core/src/worktree-survey.ts @@ -24,6 +24,7 @@ import { discoverOpenSpecWorkspace, type WorkbenchChange } from "./workbench.js" import { taskInHand } from "./task-marker.js"; import { readWorkspaceLeaseHolder } from "./workspace-lease.js"; import { resolveWorktreeRoot, type WorktreeRootSources } from "./worktree-root.js"; +import { say, type MessageCode } from "./message-register.js"; import type { SurveyedChange, SurveyedDirectory, SurveyedRun, SurveyedTask, WorktreeSurvey } from "./worktree-survey-facts.js"; // The shape and the wording live in a leaf the browser can import; this @@ -96,12 +97,15 @@ function taskListKey(directoryPath: string, changeName: string): string { return `${pathKey(directoryPath)}|${changeName}`; } -async function whyUnreadable(directory: string): Promise { +/** Why a directory cannot be read, said by its identifier where the + * register has one (ADR 0046); a bare operating-system error is carried + * as it is. */ +async function whyUnreadable(directory: string): Promise<{ text: string; code?: MessageCode } | undefined> { try { const info = await stat(directory); - return info.isDirectory() ? undefined : "it is not a directory"; + return info.isDirectory() ? undefined : say("OSW-WSP-102"); } catch (error) { - return isMissing(error) ? "it does not exist" : message(error); + return isMissing(error) ? say("OSW-WSP-103") : { text: message(error) }; } } @@ -375,7 +379,12 @@ export async function surveyWorktrees(options: WorktreeSurveyOptions): Promise ({ renderSprintReportPage: (...args: unknown[]) => renderSprintReportPageMock(...args), @@ -2558,14 +2561,17 @@ describe("registerCommands", () => { expect(deleteTaskLineMock).not.toHaveBeenCalled(); }); - it("reports a stale task list as a warning, not an error", async () => { + it("reports a stale task list as a warning, not an error, under its identifier", async () => { vscodeMock.window.showWarningMessage.mockResolvedValueOnce("Delete"); - deleteTaskLineMock.mockRejectedValue(new TaskListChangedError("task list changed")); + deleteTaskLineMock.mockRejectedValue(new TaskListChangedError("Task list for active-change has changed since it was last loaded — refresh and try again.")); registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, makeDeps()); await vscodeMock._registeredCommands.get("openspec-ui.deleteTask")?.(activeItem); expect(vscodeMock.window.showWarningMessage).toHaveBeenCalledTimes(2); + expect(vscodeMock.window.showWarningMessage).toHaveBeenCalledWith( + "OSW-TSK-014: Task list for active-change has changed since it was last loaded — refresh and try again.", + ); expect(vscodeMock.window.showErrorMessage).not.toHaveBeenCalled(); }); @@ -3079,6 +3085,7 @@ describe("the relation commands (a-relation-is-set-where-it-is-read)", () => { reason: "cycle", ids: ["second", "first", "second"], message: "That would close a cycle: second then first then second", + code: "OSW-VAL-010", }); const deps = makeDeps(); registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); @@ -3086,7 +3093,7 @@ describe("the relation commands (a-relation-is-set-where-it-is-read)", () => { await vscodeMock._registeredCommands.get("openspec-ui.addRelation")?.(activeRow("second")); expect(vscodeMock.window.showWarningMessage).toHaveBeenCalledWith( - expect.stringContaining("That would close a cycle: second then first then second"), + "OSW-VAL-010: That would close a cycle: second then first then second", ); expect(deps.refreshTrees).not.toHaveBeenCalled(); }); @@ -3103,7 +3110,9 @@ describe("the relation commands (a-relation-is-set-where-it-is-read)", () => { expect(vscodeMock.window.showQuickPick).not.toHaveBeenCalled(); expect(editChangeRelationMock).not.toHaveBeenCalled(); - expect(vscodeMock.window.showWarningMessage).toHaveBeenCalledWith(expect.stringContaining("archived")); + expect(vscodeMock.window.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining("OSW-VAL-005: old-change is archived, and an archived change's relations are history"), + ); }); it("takes back the relation the reader picked from the ones stated", async () => { @@ -3217,16 +3226,18 @@ describe("the leftover commands (the-workspace-clears-what-it-left-behind)", () expect(vscodeMock.workspace.fs.delete).not.toHaveBeenCalled(); }); - it("shows core's refusal where a working directory is not clean", async () => { + it("shows core's refusal, under its identifier, where a working directory is not clean", async () => { vscodeMock.window.showWarningMessage.mockResolvedValue("Remove"); - removeWorkingDirectoryMock.mockResolvedValue({ ok: false, path: "/wt/change-b", reason: "the working tree is not clean" }); + removeWorkingDirectoryMock.mockResolvedValue({ + ok: false, path: "/wt/change-b", reason: "the working tree is not clean", code: "OSW-WSP-009", + }); const deps = makeDeps(); registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); await vscodeMock._registeredCommands.get("openspec-ui.deleteWorktree")?.("/wt/change-b"); expect(vscodeMock.window.showWarningMessage).toHaveBeenLastCalledWith( - expect.stringContaining("not clean"), + "OSW-WSP-009: the working tree is not clean", ); expect(deps.refreshTrees).not.toHaveBeenCalled(); }); diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts index bd40e5f2..99263974 100644 --- a/packages/extension/src/commands.ts +++ b/packages/extension/src/commands.ts @@ -1294,9 +1294,8 @@ async function refusedAsAnothersChange( } function warnArchivedRelation(change: string): void { - void vscode.window.showWarningMessage( - `OpenSpec Workbench: ${change} is archived, and an archived change's relations are history.`, - ); + const said = say("OSW-VAL-005", { change }); + void vscode.window.showWarningMessage(withMessageCode(said.text, said.code)); } export function registerCommands(context: vscode.ExtensionContext, deps: CommandsDeps): void { @@ -1359,7 +1358,7 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command try { const result = await removeWorkingDirectory(directoryPath); if (!result.ok) { - void vscode.window.showWarningMessage(`OpenSpec Workbench: ${result.reason}.`); + void vscode.window.showWarningMessage(withMessageCode(result.reason, result.code)); return; } deps.refreshTrees(); @@ -1502,7 +1501,7 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command if (!result.ok) { // The refusal core wrote, as it wrote it: a cycle names the // changes in it, and an unknown id names the id. - void vscode.window.showWarningMessage(`OpenSpec Workbench: ${result.message}.`); + void vscode.window.showWarningMessage(withMessageCode(result.message, result.code)); return; } deps.refreshTrees(); @@ -1531,7 +1530,7 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command remove: stated.id, }); if (!result.ok) { - void vscode.window.showWarningMessage(`OpenSpec Workbench: ${result.message}.`); + void vscode.window.showWarningMessage(withMessageCode(result.message, result.code)); return; } deps.refreshTrees(); @@ -2502,7 +2501,7 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command void vscode.window.showInformationMessage(`OpenSpec Workbench: deleted task from ${item.changeName}.`); } catch (error) { if (error instanceof TaskListChangedError) { - void vscode.window.showWarningMessage(`OpenSpec Workbench: ${error.message}`); + void vscode.window.showWarningMessage(withMessageCode(error.message, error.code)); return; } await showCommandError("delete task", error); diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index cc391a94..c0b31ffa 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -48,6 +48,7 @@ import { resolveRunner as resolveAgentRunner, runDelegatedItem, shortDelegatedItemOutcome, + withMessageCode, agentsByRunRoot, resolveWorktreeRoot, } from "@openspec-ui/core"; @@ -717,12 +718,13 @@ export async function activate(context: vscode.ExtensionContext): Promise { + void show(said, "Show output").then((choice) => { if (choice !== "Show output") return; outputChannel.appendLine(`${item.changeName} — ${item.text}: what the agent last said`); outputChannel.appendLine(lastStderr); diff --git a/packages/extension/src/tree/human-only-inbox-tree.test.ts b/packages/extension/src/tree/human-only-inbox-tree.test.ts index 4fac2e25..ce52dd5e 100644 --- a/packages/extension/src/tree/human-only-inbox-tree.test.ts +++ b/packages/extension/src/tree/human-only-inbox-tree.test.ts @@ -13,10 +13,13 @@ vi.mock("@openspec-ui/core", () => ({ collectHumanOnlyInbox: (...args: unknown[]) => collectHumanOnlyInboxMock(...args), describeEnrolmentRequest: (request: { label: string; workingDirectory: string; machine: string | null }) => `${request.label} — ${request.workingDirectory}, on ${request.machine}`, + withMessageCode: (text: string, code?: string) => (code === undefined ? text : `${code}: ${text}`), describeWaitingOn: (waitingOn: { kind: string; agent?: string; known?: boolean }) => (waitingOn.kind === "person" - ? "a person" - : waitingOn.known ? waitingOn.agent : `"${waitingOn.agent}", which is not a registered agent`), + ? { code: "OSW-TSK-015", level: "info", text: "a person" } + : waitingOn.known + ? { text: waitingOn.agent } + : { code: "OSW-TSK-016", level: "warning", text: `"${waitingOn.agent}", which is not a registered agent` }), })); const { @@ -64,7 +67,7 @@ describe("HumanOnlyInboxTreeProvider", () => { expect(items).toHaveLength(2); expect(items.every((item) => item instanceof HumanOnlyInboxItemTreeItem)).toBe(true); expect(items.map((item) => item.description)) - .toEqual(["change-a — waiting on a person", "change-b — waiting on a person"]); + .toEqual(["change-a — waiting on OSW-TSK-015: a person", "change-b — waiting on OSW-TSK-015: a person"]); expect(items.map((item) => item.label)).toEqual([ "1.2 **Human-only**: confirm by hand", "2.1 **Human-only**: another one", @@ -157,7 +160,7 @@ describe("HumanOnlyInboxTreeProvider — who each row waits on", () => { const items = await new HumanOnlyInboxTreeProvider("/repo").getChildren(); expect(items.map((item) => item.description)) - .toEqual(["change-a — waiting on a person", "change-b — waiting on copilot-cli"]); + .toEqual(["change-a — waiting on OSW-TSK-015: a person", "change-b — waiting on copilot-cli"]); }); it("says so when the named agent is not one the registry carries", async () => { @@ -170,7 +173,7 @@ describe("HumanOnlyInboxTreeProvider — who each row waits on", () => { const items = await new HumanOnlyInboxTreeProvider("/repo").getChildren(); - expect(items[0]?.description).toBe('change-a — waiting on "copilto-cli", which is not a registered agent'); + expect(items[0]?.description).toBe('change-a — waiting on OSW-TSK-016: "copilto-cli", which is not a registered agent'); }); }); diff --git a/packages/extension/src/tree/human-only-inbox-tree.ts b/packages/extension/src/tree/human-only-inbox-tree.ts index 7793643f..3367da06 100644 --- a/packages/extension/src/tree/human-only-inbox-tree.ts +++ b/packages/extension/src/tree/human-only-inbox-tree.ts @@ -4,6 +4,7 @@ import { describeEnrolmentRequest, describeMessageOutcome, describeWaitingOn, + withMessageCode, DECISIONS_FILE, type EnrolmentRequest, type InboxQuestion, @@ -106,7 +107,8 @@ export class HumanOnlyInboxItemTreeItem extends vscode.TreeItem { // assigned to an agent that has not run and an item nobody can // close read the same otherwise. See // a-live-check-names-who-performs-it. - const waiting = `${changeName} — waiting on ${describeWaitingOn(waitingOn)}`; + const who = describeWaitingOn(waitingOn); + const waiting = `${changeName} — waiting on ${withMessageCode(who.text, who.code)}`; this.description = outcome === undefined ? waiting : `${waiting} — ${outcome}`; this.contextValue = waitingOn.kind === "agent" && waitingOn.known ? RUNNABLE_INBOX_ITEM_CONTEXT diff --git a/packages/extension/src/webview/pipeline-panel.test.ts b/packages/extension/src/webview/pipeline-panel.test.ts index 445ce27c..b51f5b99 100644 --- a/packages/extension/src/webview/pipeline-panel.test.ts +++ b/packages/extension/src/webview/pipeline-panel.test.ts @@ -726,6 +726,9 @@ describe("PipelinePanel — opening a change", () => { expect(revealChange).not.toHaveBeenCalled(); expect(vscodeMock.workspace.openTextDocument).not.toHaveBeenCalled(); expect(vscodeMock.window.showInformationMessage).toHaveBeenCalledOnce(); + expect(vscodeMock.window.showInformationMessage).toHaveBeenCalledWith( + 'OSW-CHG-001: "../../etc" is not a change name this workspace can have.', + ); }); }); diff --git a/packages/extension/src/webview/pipeline-panel.ts b/packages/extension/src/webview/pipeline-panel.ts index b9809481..3d813cac 100644 --- a/packages/extension/src/webview/pipeline-panel.ts +++ b/packages/extension/src/webview/pipeline-panel.ts @@ -712,7 +712,8 @@ export class PipelinePanel { const workspaceRoot = this.deps.getWorkspaceRoot(); if (!workspaceRoot || this.deps.runChange === undefined) return; if (!isValidChangeName(changeName)) { - void vscode.window.showInformationMessage(`OpenSpec Workbench: "${String(changeName)}" is not a change name this workspace can have.`); + const refused = say("OSW-CHG-001", { name: String(changeName) }); + void vscode.window.showInformationMessage(withMessageCode(refused.text, refused.code)); return; } const change = await this.readers.findActiveChange(workspaceRoot, changeName); @@ -842,7 +843,8 @@ export class PipelinePanel { const workspaceRoot = this.deps.getWorkspaceRoot(); if (!workspaceRoot) return; if (!isValidChangeName(changeName)) { - void vscode.window.showInformationMessage(`OpenSpec Workbench: "${String(changeName)}" is not a change name this workspace can have.`); + const refused = say("OSW-CHG-001", { name: String(changeName) }); + void vscode.window.showInformationMessage(withMessageCode(refused.text, refused.code)); return; } let root: string; diff --git a/packages/server/e2e/waiting-on-inbox.spec.ts b/packages/server/e2e/waiting-on-inbox.spec.ts index fa95b08a..de1f8e4d 100644 --- a/packages/server/e2e/waiting-on-inbox.spec.ts +++ b/packages/server/e2e/waiting-on-inbox.spec.ts @@ -96,9 +96,9 @@ test("says how much is waiting, and on whom, per row", async ({ page }) => { // And each row says it too, so a reader scanning the list does not // have to hold the sentence in their head. - await expect(inbox).toContainText("judge whether it reads well (waiting on a person)"); + await expect(inbox).toContainText("judge whether it reads well (waiting on OSW-TSK-015: a person)"); await expect(inbox).toContainText("quote the audit line (waiting on copilot-cli)"); - await expect(inbox).toContainText("(waiting on \"copilto-cli\", which is not a registered agent)"); + await expect(inbox).toContainText('(waiting on OSW-TSK-016: "copilto-cli", which is not a registered agent)'); // The ordinary open task of the first change is not here: this is what // nobody's implementing agent will close, not what is unfinished. diff --git a/packages/server/src/change-actions-rest.ts b/packages/server/src/change-actions-rest.ts index a9201c59..51ef5ce3 100644 --- a/packages/server/src/change-actions-rest.ts +++ b/packages/server/src/change-actions-rest.ts @@ -48,6 +48,7 @@ import { type ChangeActionRoot, type ChangeGraphNode, type ChangeRelationKey, + type MessageCode, type WorkbenchRecoveryService, } from "@openspec-ui/core"; import { authorizeCwd, readJsonBody, sendBodyError, sendJson, type RestRequestPolicy } from "./rest.js"; @@ -115,7 +116,14 @@ function done(message: string): ChangeActionAnswer { } /** A refusal is an answer, not a transport failure. */ -class Refused extends Error {} +class Refused extends Error { + /** The register's identifier for the refusal, where it has one. */ + readonly code: MessageCode | undefined; + constructor(message: string, code?: MessageCode) { + super(message); + this.code = code; + } +} async function perform(request: ChangeActionRequest, at: ChangeActionRoot, seams: ChangeActionSeams): Promise { const { changeName, cwd } = request; @@ -194,7 +202,7 @@ async function perform(request: ChangeActionRequest, at: ChangeActionRoot, seams // pushed (a-change-is-committed-where-it-is-made). const result = await commitChange({ repositoryRoot: cwd, changeName: name }); const said = sayCommitChange(name, result); - if (!result.ok) throw new Refused(withMessageCode(said.text, said.code)); + if (!result.ok) throw new Refused(withMessageCode(said.text, said.code), said.code); return done(withMessageCode(said.text, said.code)); } case "openWorktree": { @@ -292,6 +300,10 @@ export async function handleChangeActionRequest(req: IncomingMessage, res: Serve } sendJson(res, 200, await perform(parsed, at, seams)); } catch (error) { - sendJson(res, error instanceof Refused ? 409 : 500, { error: error instanceof Error ? error.message : String(error) }); + const refused = error instanceof Refused; + sendJson(res, refused ? 409 : 500, { + error: error instanceof Error ? error.message : String(error), + ...(refused && error.code !== undefined ? { code: error.code } : {}), + }); } } diff --git a/packages/webui/src/components/RunDialog.test.tsx b/packages/webui/src/components/RunDialog.test.tsx index c2c14932..44f923bb 100644 --- a/packages/webui/src/components/RunDialog.test.tsx +++ b/packages/webui/src/components/RunDialog.test.tsx @@ -98,7 +98,7 @@ describe("RunDialog", () => { render( , ); diff --git a/packages/webui/src/components/WorkspaceRunStatsPanel.tsx b/packages/webui/src/components/WorkspaceRunStatsPanel.tsx index af9af6e0..2157864a 100644 --- a/packages/webui/src/components/WorkspaceRunStatsPanel.tsx +++ b/packages/webui/src/components/WorkspaceRunStatsPanel.tsx @@ -1,4 +1,4 @@ -import { describeVerifyQuality, ENOUGH_VERIFIES, recommendFromRunStats } from "@openspec-ui/core/browser"; +import { describeVerifyQuality, ENOUGH_VERIFIES, recommendFromRunStats, withMessageCode } from "@openspec-ui/core/browser"; import type { AgentRunGroup, VerifyQuality, WorkspaceRunStats } from "@openspec-ui/core/browser"; // What runs have cost in this workspace, shown where a person decides @@ -111,12 +111,13 @@ function Recommendations( * See quality-of-what-a-verify-found and * quality-is-charged-to-the-agent-whose-work-was-checked. */ function VerifyQualityBlock({ quality }: { quality: VerifyQuality }) { + const basis = describeVerifyQuality(quality); return (

What the verifying stages found, by the agent whose work was checked

-

{describeVerifyQuality(quality)}

+

{withMessageCode(basis.text, basis.code)}

{quality.byAgent.length > 0 ? (
    {quality.byAgent.map((group) => ( diff --git a/packages/webui/src/standalone-entry.tsx b/packages/webui/src/standalone-entry.tsx index b29a19c5..17ce9871 100644 --- a/packages/webui/src/standalone-entry.tsx +++ b/packages/webui/src/standalone-entry.tsx @@ -131,6 +131,7 @@ import { describeStandingSources, describeWaitingOn, RUN_START_COMMAND, + withMessageCode, withoutArchivePrefix, type ChangeStandings, type ComparisonPeriodId, @@ -147,6 +148,8 @@ import { toChangeState, toChangeSummary } from "./overview-mapping.js"; * sentence and, for a run that stopped, what the agent last said. */ interface DelegatedOutcome { message: string; + /** The register's identifier for `message`, where it has one. */ + code?: string; lastStderr?: string; } @@ -1223,7 +1226,11 @@ function StandaloneApp() { const lastStderr = result.status === "ran" ? result.lastStderr : undefined; setDelegatedOutcomes((current) => ({ ...current, - [key]: { message: result.message, ...(lastStderr !== undefined ? { lastStderr } : {}) }, + [key]: { + message: result.message, + ...(result.code !== undefined ? { code: result.code } : {}), + ...(lastStderr !== undefined ? { lastStderr } : {}), + }, })); try { setHumanOnly({ status: "loaded", inbox: await loadHumanOnlyInbox(apiFetch, cwd) }); @@ -2294,7 +2301,10 @@ function StandaloneApp() { className={humanOnly.status === "failed" ? "openspec-overview-error" : "openspec-shell-note"} data-testid="human-only-inbox-basis" > - {describeHumanOnlyInboxState(humanOnly)} + {(() => { + const said = describeHumanOnlyInboxState(humanOnly); + return withMessageCode(said.text, said.code); + })()}

    {humanOnly.status === "loaded" && humanOnly.inbox.items.length > 0 ? (
      @@ -2306,10 +2316,11 @@ function StandaloneApp() { // carry. See a-delegated-item-runs-its-agent. const runnable = item.waitingOn.kind === "agent" && item.waitingOn.known; const outcome = delegatedOutcomes[key]; + const who = describeWaitingOn(item.waitingOn); return (
    • {item.changeName}{` — ${item.text}`} - {` (waiting on ${describeWaitingOn(item.waitingOn)})`} + {` (waiting on ${withMessageCode(who.text, who.code)})`} {runnable ? ( <> {" "} @@ -2332,7 +2343,7 @@ function StandaloneApp() { {item.reply ? : null} {outcome ? (
      - {outcome.message} + {withMessageCode(outcome.message, outcome.code)} {outcome.lastStderr ? ( // The whole tail, one click away; the message // already quotes its last line. From b2f529707f2c827e4872a6c7baea47ca8e172a2d Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Mon, 12 Oct 2026 15:41:10 +0300 Subject: [PATCH 2/2] =?UTF-8?q?Waive=2012.3=20(Human-only)=20=E2=80=94=20c?= =?UTF-8?q?overed=20by=20tests=20and=20CI=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tasks.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openspec/changes/remaining-groups-move-into-the-register/tasks.md b/openspec/changes/remaining-groups-move-into-the-register/tasks.md index b6ccb84a..69875df0 100644 --- a/openspec/changes/remaining-groups-move-into-the-register/tasks.md +++ b/openspec/changes/remaining-groups-move-into-the-register/tasks.md @@ -165,15 +165,17 @@ section each, under a widened ratchet. 2026-10-12: typecheck clean; lint 0 errors (4 unused-variable warnings, unrelated to identifiers); core 2298 passed, cli 285, extension 538, server 135, webui 768. The register's and the ratchet's tests pass; - `UNREGISTERED_MESSAGES` holds 335. + `UNREGISTERED_MESSAGES` holds 330. - [x] 12.2 `openspec validate remaining-groups-move-into-the-register --strict`, and the merge gate with the worktree's absolute path as `--cwd`. 2026-10-12: "Change 'remaining-groups-move-into-the-register' is valid"; the merge gate (`validate changes --base origin/main`) reports "All changes valid", exit 0. -- [ ] 12.3 **Human-only**: in VS Code, on the extension built from this - branch, a run that fails for one of the newly registered reasons shows - `Failed:` with the identifier as a link, and the link opens its entry in - `docs/messages.md`; the `openspec-ui-cli explain message ` - command answers for one of the new codes. +- [x] 12.3 **Waived** by the owner on 2026-10-12: the live VS Code visual + check is covered by the panel and event tests, which assert `Failed:` + leading with the identifier as a link to its `docs/messages.md` entry, + and by the pull request's `extension-integration` job, which runs the + live extension suite; `openspec-ui-cli explain message OSW-NET-001` was + verified directly and answers with its level, words, why, what-to-do and + the entry link.