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..69875df0 --- /dev/null +++ b/openspec/changes/remaining-groups-move-into-the-register/tasks.md @@ -0,0 +1,181 @@ +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 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. +- [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. 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.