sortie-dogs 0.13.19 → 0.13.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,8 +19,8 @@ evidence-backed result, not more agents for their own sake.
19
19
  [![Node.js](https://img.shields.io/node/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
20
20
  [![MIT License](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
21
21
 
22
- **Current release: [v0.13.19](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.19)**
23
- · [Release notes](docs/release-v0.13.19.md)
22
+ **Current release: [v0.13.21](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.21)**
23
+ · [Release notes](docs/release-v0.13.21.md)
24
24
 
25
25
  [Quick start](#quick-start) · [Workflow](#mission-workflow) · [Models](#default-models) ·
26
26
  [Measured results](#measured-results) · [Configuration](#configuration) · [Documentation](#documentation)
@@ -57,7 +57,7 @@ Use an existing Codex CLI signed in with **ChatGPT**. On Windows, PowerShell **7
57
57
  available; POSIX uses bash. Sortie does not install Codex, start login or use a metered API fallback.
58
58
 
59
59
  ```sh
60
- npm install --save-dev sortie-dogs@0.13.19
60
+ npm install --save-dev sortie-dogs@0.13.21
61
61
  npx --no-install sortie-dogs codex init .
62
62
  ```
63
63
 
@@ -86,7 +86,7 @@ bridge. [Codex guide: SDK, permissions, progress, resume and manifest route](doc
86
86
  Use **OpenCode V2**. In the target project:
87
87
 
88
88
  ```sh
89
- npm install --save-dev sortie-dogs@0.13.19
89
+ npm install --save-dev sortie-dogs@0.13.21
90
90
  npx --no-install sortie-dogs init .
91
91
  ```
92
92
 
@@ -193,7 +193,7 @@ known expense. Official local evaluation and leaderboard acceptance are separate
193
193
  **8/23 (34.8%)**, versus v0.12.25's 7/23; all seven prior resolutions retained, no empty patches or
194
194
  official evaluation errors. [Fixed conditions, stopped-run accounting and costs](docs/benchmarks/swebench-lite-history.md#v0131-dev23-2026-09-30).
195
195
 
196
- All scores belong to their fixed candidates, **not v0.13.19**. SWE-bench remains an optional
196
+ All scores belong to their fixed candidates, **not v0.13.21**. SWE-bench remains an optional
197
197
  measurement, not a release gate. Different budgets, models and conditions are not controlled comparisons.
198
198
 
199
199
  ## Configuration
@@ -216,7 +216,7 @@ Install the desired package version and rerun **your host's** initializer:
216
216
 
217
217
  An OpenCode config-local bridge may resolve a separate package; npm-global update alone does not
218
218
  update it. [Global/update instructions](docs/configuration.md#global-availability).
219
- The current Mission asset marker is `0.13.19-workload-progress-v1`; the unchanged Codex skill marker is
219
+ The current Mission asset marker is `0.13.21-operation-recovery-v1`; the unchanged Codex skill marker is
220
220
  `0.13.11-codex-skill-v2`. Installed markers alone do not prove a running host reloaded the new version.
221
221
 
222
222
  There is no supported uninstall command. Remove the dependency and only known Sortie-owned paths;
@@ -232,7 +232,7 @@ follow the [manual removal guide](docs/uninstall.md), never delete the entire `.
232
232
  [SWE-bench history](docs/benchmarks/swebench-lite-history.md) · [Historical local case study](docs/benchmark-reference.md).
233
233
  - **Development:** [Testing](docs/testing.md) · [CLI testing](docs/cli-testing.md) ·
234
234
  [Windows tests](docs/windows-tests.md) · [Release routine](docs/release-batch.md).
235
- - **Releases:** [v0.13.19](docs/release-v0.13.19.md) · [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
235
+ - **Releases:** [v0.13.21](docs/release-v0.13.21.md) · [v0.13.20](docs/release-v0.13.20.md) · [v0.13.19](docs/release-v0.13.19.md) · [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
236
236
  [v0.13.9](docs/release-v0.13.9.md) · [All GitHub releases](https://github.com/zufall-upon/Sortie-dogs/releases).
237
237
 
238
238
  ## Community
@@ -3,6 +3,6 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.13.19-workload-progress-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.13.21-operation-recovery-v1";
7
7
  export declare const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
8
8
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION | typeof CODEX_SKILL_ASSET_VERSION;
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.13.19-workload-progress-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.13.21-operation-recovery-v1";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
@@ -101,16 +101,30 @@ export interface MissionTerminalRescue {
101
101
  export interface MissionExecution {
102
102
  commands: string[];
103
103
  directory: string;
104
+ /** History-service availability is not a process outcome or an execution permission. */
105
+ recovery?: {
106
+ status: "unavailable";
107
+ errors: {
108
+ sessionID: string;
109
+ reason: string;
110
+ }[];
111
+ };
104
112
  observations: {
105
113
  command: string;
106
114
  directory: string;
107
115
  callID: string;
108
116
  sessionID: string;
109
117
  startedAt: string;
118
+ /** Actual native execution start, distinct from a live hook's admission clock. */
119
+ nativeStartedAt?: string;
110
120
  completedAt?: string;
111
121
  exit?: number;
112
122
  status?: "running" | "completed" | "error";
113
123
  shellID?: string;
124
+ /** Contradictory native records retained for this same call, not a process outcome. */
125
+ conflicting?: boolean;
126
+ /** Latest actual start reported for a conflicting call; original startedAt is retained. */
127
+ latestConflictingStartedAt?: string;
114
128
  outcome?: "not-started" | "execution-failed" | "executed";
115
129
  result?: Record<string, unknown>;
116
130
  }[];
@@ -314,10 +328,19 @@ export declare function missionReviewVerdict(text: string): "PASS" | "evidence-g
314
328
  export declare function missionReviewAccepted(review: NonNullable<OperatorMission["review"]>): boolean;
315
329
  /** A short native report, not a tag/hash-based second-review policy or an approval checklist. */
316
330
  export declare function missionSelfRecheckReport(text: string, source: string, hostBound?: boolean): Pick<MissionSelfRecheck, "unresolvedFindings" | "residualMajor"> | undefined;
317
- export declare function missionExecutionStatus(mission: OperatorMission): "not-required" | "not-started" | "running" | "execution-failed" | "executed";
331
+ export declare function missionExecutionStatus(mission: OperatorMission): "not-required" | "not-started" | "recovery-unavailable" | "recovery-blocked" | "running" | "execution-failed" | "executed";
332
+ /** Native Windows cwd case/separator spelling is not a different operation directory. */
333
+ export declare function missionOperationDirectoryMatch(actual: string, declared: string): boolean;
334
+ /** Match only the same declared operation, allowing its native PowerShell envelope and
335
+ * an absolute Node/script/path spelling resolved from the unchanged operation cwd. */
336
+ export declare function missionOperationCommandMatch(command: string, declared: readonly string[], directory: string): string | undefined;
337
+ /** Apply the existing no-decoration operation check to the same accepted native spelling. */
338
+ export declare function missionOperationCommandDecorated(command: string, declared: readonly string[], directory: string): boolean;
318
339
  /** A terminal failure is a collected result, not a successful operation. Required success
319
340
  * remains in the declared formal checks and Operator's original-request comparison. */
320
341
  export declare function missionExecutionComplete(mission: OperatorMission): boolean;
342
+ /** Missing history cannot justify replay; adequate collected results remain usable. */
343
+ export declare function missionExecutionRecoveryAction(mission: OperatorMission): string | undefined;
321
344
  /** Projection of existing observations only; never another check or inferred success. */
322
345
  export declare function missionOperationSummary(mission: OperatorMission): Record<string, unknown>;
323
346
  /** Observe the command's own terminal summary, never Worker/Reviewer prose. Scores are result data,
@@ -1,10 +1,10 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, open, readFile, readdir, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
3
- import { join, resolve } from "node:path";
3
+ import { join, resolve, win32 } from "node:path";
4
4
  import { normalizeExecutionScope } from "./path.js";
5
5
  import { parseOperatorPlan } from "./operator-runtime.js";
6
6
  import { profileAgent } from "./runtime-profile.js";
7
- import { normalizeCommand } from "../plugin/gate.js";
7
+ import { normalizeCommand, shellCommandBase, shellCommandWords } from "../plugin/gate.js";
8
8
  import { missionValidationCommand } from "./mission-validation-command.js";
9
9
  import { retainMissionContext, missionContextView } from "./mission-context.js";
10
10
  import { planWorkQuantity, projectWorkQuantity, syncWorkQuantity } from "./work-quantity.js";
@@ -111,22 +111,129 @@ export function missionExecutionStatus(mission) {
111
111
  if (mission.kind !== "operation")
112
112
  return "not-required";
113
113
  const execution = mission.execution;
114
- if (!execution || execution.commands.length === 0 || execution.observations.length === 0)
114
+ if (!execution || execution.commands.length === 0)
115
115
  return "not-started";
116
- const latest = execution.commands.map(command => [...execution.observations].reverse().find(item => item.command === command && item.directory === execution.directory));
116
+ if (execution.observations.length === 0)
117
+ return execution.recovery ? "recovery-unavailable" : "not-started";
118
+ // Recovery may discover an older call after a newer live/failed observation was saved.
119
+ const orderingTime = (item) => Math.max(Date.parse(item.nativeStartedAt ?? item.startedAt), Date.parse(item.latestConflictingStartedAt ?? item.nativeStartedAt ?? item.startedAt));
120
+ const ordered = [...execution.observations].sort((left, right) => orderingTime(left) - orderingTime(right)).reverse();
121
+ const latest = execution.commands.map(command => ordered.find(item => item.command === command && item.directory === execution.directory));
122
+ if (latest.some(item => item?.conflicting || item && ["completed", "error"].includes(item.status ?? "") &&
123
+ !["executed", "execution-failed", "not-started"].includes(item.outcome ?? "")))
124
+ return "recovery-blocked";
117
125
  if (latest.some(item => item && !item.completedAt))
118
126
  return "running";
127
+ if (latest.some(item => !item) && execution.recovery)
128
+ return "recovery-unavailable";
119
129
  if (latest.some(item => !item || !["executed", "execution-failed"].includes(item.outcome ?? "")))
120
130
  return "not-started";
121
131
  if (latest.some(item => item?.outcome === "execution-failed" || (item && (item.status === "error" || item.exit !== 0))))
122
132
  return "execution-failed";
123
133
  return "executed";
124
134
  }
135
+ function stripMissionPowerShellPrefix(command) {
136
+ return command.trim().replace(/^\$ErrorActionPreference\s*=\s*(['"])Stop\1\s*;\s*/iu, "");
137
+ }
138
+ function missionCommandTokens(command) {
139
+ const tokens = shellCommandWords(stripMissionPowerShellPrefix(command));
140
+ if (tokens[0] === "&")
141
+ tokens.shift();
142
+ return tokens.length ? tokens : undefined;
143
+ }
144
+ function missionWindowsPath(value) {
145
+ return /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("\\\\");
146
+ }
147
+ function missionPathIdentity(value, directory, windows) {
148
+ if (!windows)
149
+ return resolve(directory, value);
150
+ return win32.normalize(win32.resolve(directory, value)).toLowerCase();
151
+ }
152
+ /** Native Windows cwd case/separator spelling is not a different operation directory. */
153
+ export function missionOperationDirectoryMatch(actual, declared) {
154
+ const windows = missionWindowsPath(actual) || missionWindowsPath(declared);
155
+ return missionPathIdentity(actual, declared, windows) === missionPathIdentity(declared, declared, windows);
156
+ }
157
+ function sameNodeOperation(declared, actual, directory) {
158
+ const expected = missionCommandTokens(declared), observed = missionCommandTokens(actual);
159
+ if (!expected || !observed || expected.length !== observed.length || expected.length < 2)
160
+ return false;
161
+ const windows = missionWindowsPath(directory);
162
+ const name = (value) => {
163
+ const basename = value.split(windows ? /[\\/]/u : /\//u).at(-1) ?? "";
164
+ return windows ? basename.toLowerCase() : basename;
165
+ };
166
+ const nodeNames = windows ? ["node", "node.exe"] : ["node"];
167
+ if (!nodeNames.includes(name(expected[0])) || !nodeNames.includes(name(observed[0])))
168
+ return false;
169
+ const executablePath = (value) => windows ? /[\\/]/u.test(value) : value.includes("/");
170
+ if (executablePath(expected[0]) && (!executablePath(observed[0]) ||
171
+ missionPathIdentity(expected[0], directory, windows) !== missionPathIdentity(observed[0], directory, windows)))
172
+ return false;
173
+ const script = expected[1];
174
+ if (!/\.(?:cjs|mjs|js)$/iu.test(script) || !/\.(?:cjs|mjs|js)$/iu.test(observed[1]))
175
+ return false;
176
+ if (missionPathIdentity(script, directory, windows) !== missionPathIdentity(observed[1], directory, windows))
177
+ return false;
178
+ for (let index = 2; index < expected.length; index += 1) {
179
+ const left = expected[index], right = observed[index];
180
+ if (left === right)
181
+ continue;
182
+ // Options and URI values are script inputs, not filesystem paths with Windows case rules.
183
+ const literal = (value) => value.startsWith("-") || !missionWindowsPath(value) && /^[A-Za-z][A-Za-z\d+.-]*:/u.test(value);
184
+ if (literal(left) || literal(right))
185
+ return false;
186
+ // Only the supported absolute-path substitution can change a positional argument.
187
+ // Two different relative opaque values must not collapse via path normalization.
188
+ const absolute = (value) => missionWindowsPath(value) || value.startsWith("/");
189
+ const pathArgument = absolute(left) !== absolute(right) || windows && missionWindowsPath(left) && missionWindowsPath(right);
190
+ if (pathArgument
191
+ ? missionPathIdentity(left, directory, windows) !== missionPathIdentity(right, directory, windows)
192
+ : left !== right)
193
+ return false;
194
+ }
195
+ return true;
196
+ }
197
+ /** Match only the same declared operation, allowing its native PowerShell envelope and
198
+ * an absolute Node/script/path spelling resolved from the unchanged operation cwd. */
199
+ export function missionOperationCommandMatch(command, declared, directory) {
200
+ const normalized = normalizeCommand(stripMissionPowerShellPrefix(command));
201
+ const exact = declared.filter(item => normalizeCommand(stripMissionPowerShellPrefix(item)) === normalized);
202
+ if (exact.length === 1)
203
+ return normalizeCommand(exact[0]);
204
+ if (exact.length > 1)
205
+ return undefined;
206
+ const equivalent = declared.filter(item => sameNodeOperation(item, command, directory));
207
+ return equivalent.length === 1 ? normalizeCommand(equivalent[0]) : undefined;
208
+ }
209
+ /** Apply the existing no-decoration operation check to the same accepted native spelling. */
210
+ export function missionOperationCommandDecorated(command, declared, directory) {
211
+ if (missionOperationCommandMatch(command, declared, directory) !== undefined)
212
+ return false;
213
+ const stripped = stripMissionPowerShellPrefix(command), actual = normalizeCommand(stripped);
214
+ if (declared.some(item => {
215
+ const expected = normalizeCommand(stripMissionPowerShellPrefix(item));
216
+ return actual.startsWith(expected) && /^\s*(?:\||\d?>|&&?|;)/u.test(actual.slice(expected.length));
217
+ }))
218
+ return true;
219
+ const base = shellCommandBase(stripped, stripped !== command.trim() || stripped.startsWith("&") ? "powershell" : "posix");
220
+ return base !== undefined && missionOperationCommandMatch(base, declared, directory) !== undefined;
221
+ }
125
222
  /** A terminal failure is a collected result, not a successful operation. Required success
126
223
  * remains in the declared formal checks and Operator's original-request comparison. */
127
224
  export function missionExecutionComplete(mission) {
128
225
  return ["not-required", "executed", "execution-failed"].includes(missionExecutionStatus(mission));
129
226
  }
227
+ /** Missing history cannot justify replay; adequate collected results remain usable. */
228
+ export function missionExecutionRecoveryAction(mission) {
229
+ if (missionExecutionStatus(mission) === "recovery-blocked")
230
+ return "An admitted operation has missing or contradictory terminal evidence. Preserve existing observations; inspect the same native call's shell/progress and recover its actual result. " +
231
+ "Do not replay or rerun the operation. Report the actual recovery blocker if its result remains unknown.";
232
+ if (!mission.execution?.recovery || missionExecutionComplete(mission))
233
+ return undefined;
234
+ return "Native operation history could not be read. Preserve existing observations; inspect the admitted session's existing shell/progress or restore the history reader. " +
235
+ "Do not replay or rerun the operation. Report the actual recovery blocker if history remains unavailable.";
236
+ }
130
237
  /** Projection of existing observations only; never another check or inferred success. */
131
238
  export function missionOperationSummary(mission) {
132
239
  const status = missionExecutionStatus(mission);
@@ -836,21 +943,22 @@ export function missionPacket(mission, run, includeWorkQuantity = true) {
836
943
  : "Correction ready; reuse the native author for explicit self-recheck or select an independent Reviewer with review_mission when useful. Remaining authorized implementation may proceed with plan_units without acceptance. Known Major/Medium defects still require correction. Do not restart unchanged checks or investigation; CORRECTION_READY alone is not acceptance."
837
944
  : mission.kind === "operation" && operationStatus === "running"
838
945
  ? "The declared operation is already running. Inspect its native shell/progress; do not start another Worker or run. Wait for a terminal result, or report the existing run as blocked if its completion cannot be observed."
839
- : run?.phase === "awaiting-decision" && mission.corrections?.some(item => item.runID === run.runID && item.status === "failed")
840
- ? "Correction validation or native execution failed. Reuse its context with repair_review, select another available Reviewer, or plan_units for Worker/direct correction. Retain findings, affected checks, requested delivery and cumulative spend. Failure is never acceptance."
841
- : run?.phase === "awaiting-decision" ? (run.units.some(unit => unit.dispatchDenial)
842
- ? "Coordinator: inspect units[].dispatch_denial before changing the plan. Correct only its diagnosed cause; do not repeat an unchanged refused Task or replan for a host-state mismatch. Report an unresolved runtime mismatch with the loaded runtime identity; preserve requirements and cumulative spend."
843
- : run.units.some(unit => unit.status === "failed" && unit.resultClass === "acceptance" && unit.failure?.outcome === "fail" && !unit.normalRemediationUsed)
844
- ? "Coordinator: one exact declared validation failed. Reconcile the returned Worker and cumulative budget, then call retry_mission_unit once for that unit before replanning. This preserves the same scope and acceptance."
845
- : run.units.some(unit => unit.status === "failed" && unit.resultClass === "acceptance" && unit.failure?.outcome === "fail" && unit.normalRemediationUsed && !unit.terminalRescue)
846
- ? "Coordinator: the one ordinary remediation failed the declared validation again. If its native terminal, writer release, exact candidate and cumulative budget permit, call rescue_mission_unit once; inspect and report a non_rescue reason, then continue ordinary correction within budget. Rescue does not bypass validation, review or root acceptance."
847
- : "Coordinator: correct the cause and call plan_units with the remaining work and all requirements; budget is cumulative.")
848
- : run?.phase === "awaiting-acceptance" && !operationComplete
849
- ? `Requested operation is ${operationStatus}. Continue the actual operation or report its blocker; auxiliary checks and review disposition cannot complete it.`
850
- : run?.phase === "awaiting-acceptance" ? (reviewAccepted
851
- ? "Coordinator: review permits submission, not completion of remaining implementation. Continue authorized remaining work with plan_units, or submit when the original requirements are met. Retain review notes; do not repeat passed validation or review for evidence formatting. Operator final acceptance remains separate."
852
- : "Coordinator: correct known Major/Medium findings, then obtain native author self-recheck or useful independent review (required for concrete residual Major risk). Remaining authorized implementation can proceed without submission/acceptance. Operator compares all requirements with source/evidence before complete_mission.")
853
- : "Coordinator: continue the next useful unit within original requirements. Return only a completion candidate, user-only decision, or scope/budget extension." };
946
+ : missionExecutionRecoveryAction(mission) !== undefined ? missionExecutionRecoveryAction(mission)
947
+ : run?.phase === "awaiting-decision" && mission.corrections?.some(item => item.runID === run.runID && item.status === "failed")
948
+ ? "Correction validation or native execution failed. Reuse its context with repair_review, select another available Reviewer, or plan_units for Worker/direct correction. Retain findings, affected checks, requested delivery and cumulative spend. Failure is never acceptance."
949
+ : run?.phase === "awaiting-decision" ? (run.units.some(unit => unit.dispatchDenial)
950
+ ? "Coordinator: inspect units[].dispatch_denial before changing the plan. Correct only its diagnosed cause; do not repeat an unchanged refused Task or replan for a host-state mismatch. Report an unresolved runtime mismatch with the loaded runtime identity; preserve requirements and cumulative spend."
951
+ : run.units.some(unit => unit.status === "failed" && unit.resultClass === "acceptance" && unit.failure?.outcome === "fail" && !unit.normalRemediationUsed)
952
+ ? "Coordinator: one exact declared validation failed. Reconcile the returned Worker and cumulative budget, then call retry_mission_unit once for that unit before replanning. This preserves the same scope and acceptance."
953
+ : run.units.some(unit => unit.status === "failed" && unit.resultClass === "acceptance" && unit.failure?.outcome === "fail" && unit.normalRemediationUsed && !unit.terminalRescue)
954
+ ? "Coordinator: the one ordinary remediation failed the declared validation again. If its native terminal, writer release, exact candidate and cumulative budget permit, call rescue_mission_unit once; inspect and report a non_rescue reason, then continue ordinary correction within budget. Rescue does not bypass validation, review or root acceptance."
955
+ : "Coordinator: correct the cause and call plan_units with the remaining work and all requirements; budget is cumulative.")
956
+ : run?.phase === "awaiting-acceptance" && !operationComplete
957
+ ? `Requested operation is ${operationStatus}. Continue the actual operation or report its blocker; auxiliary checks and review disposition cannot complete it.`
958
+ : run?.phase === "awaiting-acceptance" ? (reviewAccepted
959
+ ? "Coordinator: review permits submission, not completion of remaining implementation. Continue authorized remaining work with plan_units, or submit when the original requirements are met. Retain review notes; do not repeat passed validation or review for evidence formatting. Operator final acceptance remains separate."
960
+ : "Coordinator: correct known Major/Medium findings, then obtain native author self-recheck or useful independent review (required for concrete residual Major risk). Remaining authorized implementation can proceed without submission/acceptance. Operator compares all requirements with source/evidence before complete_mission.")
961
+ : "Coordinator: continue the next useful unit within original requirements. Return only a completion candidate, user-only decision, or scope/budget extension." };
854
962
  }
855
963
  /** Models forward a short capability, never recopy the host's evidence hashes and source packet. */
856
964
  export function missionReviewTask(mission) {
@@ -2,6 +2,10 @@
2
2
  import type { OperatorMission } from "./operator-mission.js";
3
3
  import type { OperatorState } from "./operator-runtime.js";
4
4
  import { type WorkQuantityEvent, type WorkQuantityState, type WorkQuantityProgress } from "./work-quantity.js";
5
+ export declare function forgetWorkSession(directory: string, actor: string): void;
6
+ export declare function forgetWorkDirectory(directory: string): void;
7
+ /** Module-global observations remain live until the last overlapping Location setup unloads. */
8
+ export declare function retainWorkDirectory(directory: string): () => void;
5
9
  export declare function rememberWorkMission(directory: string, mission: OperatorMission): void;
6
10
  export declare function rememberWorkRun(directory: string, run: OperatorState): void;
7
11
  export declare function rememberedWorkRun(directory: string, root: string): OperatorState | undefined;
@@ -1,12 +1,74 @@
1
1
  import { observeWorkQuantity, planWorkQuantity, projectWorkQuantity, syncWorkQuantity } from "./work-quantity.js";
2
2
  const views = new Map();
3
3
  const owners = new Map();
4
+ const instances = new Map();
5
+ // Terminal UI snapshots contain no Mission/run/events and cannot grow with history.
6
+ const terminals = new Map();
4
7
  const identity = (directory, root) => `${directory.replaceAll("\\", "/").replace(/\/$/u, "")}\0${root}`;
8
+ function forgetRoot(directory, root) {
9
+ const id = identity(directory, root), prefix = identity(directory, "");
10
+ views.delete(id);
11
+ terminals.delete(id);
12
+ for (const [actor, owner] of owners)
13
+ if (actor.startsWith(prefix) && owner === root)
14
+ owners.delete(actor);
15
+ }
16
+ export function forgetWorkSession(directory, actor) {
17
+ const id = identity(directory, actor);
18
+ if (views.has(id) || terminals.has(id) || owners.get(id) === actor)
19
+ forgetRoot(directory, actor);
20
+ else {
21
+ const root = owners.get(id);
22
+ if (root)
23
+ views.get(identity(directory, root))?.deleted.add(actor);
24
+ owners.delete(id);
25
+ }
26
+ }
27
+ export function forgetWorkDirectory(directory) {
28
+ const prefix = identity(directory, "");
29
+ for (const cache of [views, owners, terminals])
30
+ for (const id of cache.keys())
31
+ if (id.startsWith(prefix))
32
+ cache.delete(id);
33
+ }
34
+ /** Module-global observations remain live until the last overlapping Location setup unloads. */
35
+ export function retainWorkDirectory(directory) {
36
+ const id = identity(directory, "");
37
+ instances.set(id, (instances.get(id) ?? 0) + 1);
38
+ let released = false;
39
+ return () => {
40
+ if (released)
41
+ return;
42
+ released = true;
43
+ const remaining = (instances.get(id) ?? 1) - 1;
44
+ if (remaining)
45
+ instances.set(id, remaining);
46
+ else {
47
+ instances.delete(id);
48
+ forgetWorkDirectory(directory);
49
+ }
50
+ };
51
+ }
52
+ function own(directory, root, actor, value) {
53
+ if (!value.deleted.has(actor))
54
+ owners.set(identity(directory, actor), root);
55
+ }
56
+ function finishView(directory, root, value) {
57
+ const mission = value.mission, run = value.run;
58
+ const progress = mission ? projectWorkQuantity(missionWorkState(directory, mission, run), mission.phase === "completed", mission.phase === "cancelled")
59
+ : run?.workQuantity ? projectWorkQuantity(syncWorkQuantity(applyPendingWork(directory, root, structuredClone(run.workQuantity)), {}, run), run.phase === "completed", run.phase === "cancelled") : undefined;
60
+ forgetRoot(directory, root);
61
+ if (progress)
62
+ terminals.set(identity(directory, root), { progress, runID: mission?.runID ?? run?.runID });
63
+ if (terminals.size > 128)
64
+ terminals.delete(terminals.keys().next().value);
65
+ }
5
66
  function view(directory, root) {
6
67
  const id = identity(directory, root);
68
+ terminals.delete(id);
7
69
  let value = views.get(id);
8
70
  if (!value) {
9
- value = { sequence: 0, events: new Map() };
71
+ value = { sequence: 0, events: new Map(), deleted: new Set() };
10
72
  views.set(id, value);
11
73
  }
12
74
  owners.set(id, root);
@@ -20,23 +82,30 @@ export function rememberWorkMission(directory, mission) {
20
82
  value.sequence = Math.max(value.sequence, mission.workQuantity?.observationSequence ?? 0);
21
83
  delete value.progress;
22
84
  if (mission.coordinator)
23
- owners.set(identity(directory, mission.coordinator), mission.root);
85
+ own(directory, mission.root, mission.coordinator, value);
24
86
  if (mission.review?.child)
25
- owners.set(identity(directory, mission.review.child), mission.root);
87
+ own(directory, mission.root, mission.review.child, value);
26
88
  for (const attempt of mission.attempts ?? [])
27
89
  if (attempt.childSessionID)
28
- owners.set(identity(directory, attempt.childSessionID), mission.root);
90
+ own(directory, mission.root, attempt.childSessionID, value);
91
+ if (["completed", "cancelled"].includes(mission.phase))
92
+ finishView(directory, mission.root, value);
29
93
  }
30
94
  export function rememberWorkRun(directory, run) {
95
+ const terminal = terminals.get(identity(directory, run.rootSessionID));
96
+ if (terminal && terminal.runID === run.runID)
97
+ return;
31
98
  const value = view(directory, run.rootSessionID);
32
99
  value.run = run;
33
100
  value.sequence = Math.max(value.sequence, run.workQuantity?.observationSequence ?? 0);
34
101
  delete value.progress;
35
102
  if (run.operatorSessionID)
36
- owners.set(identity(directory, run.operatorSessionID), run.rootSessionID);
103
+ own(directory, run.rootSessionID, run.operatorSessionID, value);
37
104
  for (const unit of run.units)
38
105
  if (unit.childSessionID)
39
- owners.set(identity(directory, unit.childSessionID), run.rootSessionID);
106
+ own(directory, run.rootSessionID, unit.childSessionID, value);
107
+ if (!value.mission && ["completed", "cancelled"].includes(run.phase))
108
+ finishView(directory, run.rootSessionID, value);
40
109
  }
41
110
  export function rememberedWorkRun(directory, root) {
42
111
  return views.get(identity(directory, root))?.run;
@@ -77,6 +146,9 @@ export function missionWorkState(directory, mission, run) {
77
146
  return syncWorkQuantity(applyPendingWork(directory, mission.root, structuredClone(state)), mission, run);
78
147
  }
79
148
  export function cachedWorkProgress(directory, actor) {
149
+ const terminal = terminals.get(identity(directory, actor));
150
+ if (terminal)
151
+ return terminal.progress;
80
152
  const root = owners.get(identity(directory, actor));
81
153
  const value = root && views.get(identity(directory, root));
82
154
  if (!value)
@@ -27,6 +27,7 @@ function pathMatches(actual, binding) {
27
27
  const a = key(actual), b = key(binding).replace(/\/\*\*$/u, "");
28
28
  return a === b || broad(binding) && ([".", "**"].includes(b) || a.startsWith(`${b}/`));
29
29
  }
30
+ const pathsOverlap = (a, b) => pathMatches(a, b) || pathMatches(b, a);
30
31
  function fraction(item) {
31
32
  const o = item.observation;
32
33
  if (item.removed)
@@ -71,6 +72,10 @@ function item(id, kind, label, requirements, paths = [], commands = [], units =
71
72
  return { id, kind, label, requirementIDs: unique(requirements), weight: weight[kind], basis: "derived",
72
73
  paths: unique(paths.map(key)), commands: unique(commands.map(commandKey)), units: unique(units), observation: {}, fraction: 0 };
73
74
  }
75
+ /** Keep alias observations for later subdivisions without counting their quantity twice. */
76
+ function retainedAliases(state) {
77
+ return new Set(state.items.filter(entry => !entry.removed && entry.basis === "declared").flatMap(entry => entry.aliases ?? []));
78
+ }
74
79
  /** Concrete work before scheduling/coalescence; broad scopes remain unexpanded estimates. */
75
80
  export function planWorkQuantity(mission, rawUnits = [], run, previous, declared, directory) {
76
81
  const pathKey = (path) => {
@@ -198,7 +203,7 @@ export function planWorkQuantity(mission, rawUnits = [], run, previous, declared
198
203
  if (units.length)
199
204
  declared.units = units;
200
205
  for (const child of state.items.filter(entry => entry.parentID === declared.id && !entry.removed)) {
201
- const matched = aliases.filter(entry => !child.paths.length && !child.commands.length || entry.paths.some(path => child.paths.includes(path)) ||
206
+ const matched = aliases.filter(entry => !child.paths.length && !child.commands.length || entry.paths.some(path => child.paths.some(binding => pathsOverlap(path, binding))) ||
202
207
  entry.commands.some(command => child.commands.includes(command)));
203
208
  const units = unique(matched.flatMap(entry => entry.units));
204
209
  if (units.length)
@@ -282,8 +287,37 @@ function applyDeclaredWork(state, raw, generated, pathKey) {
282
287
  child.units = parent.units;
283
288
  child.paths = strings(point.paths).map(pathKey);
284
289
  child.commands = strings(point.commands).map(commandKey);
285
- if (!state.items.includes(child))
286
- child.observation = { ...parent.observation };
290
+ if (!state.items.includes(child)) {
291
+ const aliases = matches.filter(alias => child.paths.some(path => alias.paths.some(binding => pathsOverlap(path, binding))) ||
292
+ child.commands.some(command => alias.commands.includes(command)));
293
+ const observations = aliases.map(alias => {
294
+ const saved = state.items.find(entry => entry.id === alias.id).observation;
295
+ if (!alias.targetEstimate || child.paths.some(path => alias.paths.includes(path)))
296
+ return saved;
297
+ const projected = { ...saved };
298
+ // A broad alias's changed-target list identifies concrete edits;
299
+ // its scalar read flag does not identify which child was read.
300
+ delete projected.read;
301
+ if (saved.changed) {
302
+ projected.changedTargets = (saved.changedTargets ?? []).filter(path => child.paths.some(binding => pathMatches(path, binding)));
303
+ projected.changed = projected.changedTargets.length > 0;
304
+ }
305
+ return projected;
306
+ });
307
+ // Bound checkpoints inherit only matching aliases. Multiple aliases retain
308
+ // common observations conservatively; an unbound checkpoint covers its parent.
309
+ child.observation = !child.paths.length && !child.commands.length ? { ...parent.observation }
310
+ : Object.fromEntries(Object.entries(observations[0] ?? {}).filter(([field, value]) => observations.every(observation => JSON.stringify(observation[field]) === JSON.stringify(value))));
311
+ if (child.paths.some(broad) && aliases.length && aliases.every(alias => !alias.targetEstimate && alias.paths.length)) {
312
+ const targets = unique(aliases.flatMap(alias => alias.paths).filter(path => child.paths.some(binding => pathMatches(path, binding))));
313
+ child.targetEstimate = targets.length;
314
+ child.observation.changedTargets = unique(aliases.flatMap((alias, i) => observations[i].changed ? alias.paths : [])
315
+ .filter(path => targets.includes(path)));
316
+ child.observation.changed = child.observation.changedTargets.length > 0;
317
+ if (observations.some(observation => observation.read))
318
+ child.observation.read = true;
319
+ }
320
+ }
287
321
  child.parentID = parent.id;
288
322
  child.weight = amount * quantities[index] / sum;
289
323
  child.basis = "declared";
@@ -309,8 +343,9 @@ export function observeWorkQuantity(state, event) {
309
343
  state.revisionReason = "native work revealed an additional target";
310
344
  }
311
345
  }
346
+ const aliases = retainedAliases(state);
312
347
  for (const entry of state.items) {
313
- if (entry.removed || entry.children?.length)
348
+ if (entry.removed && !aliases.has(entry.id) || entry.children?.length)
314
349
  continue;
315
350
  const pathMatch = paths.some(path => entry.paths.some(binding => pathMatches(path, binding)));
316
351
  const unitMatch = event.unit !== undefined && entry.units.includes(event.unit);
@@ -373,8 +408,9 @@ export function syncWorkQuantity(state, mission, run) {
373
408
  state.lastReview = reviewRef;
374
409
  else
375
410
  delete state.lastReview;
411
+ const aliases = retainedAliases(state);
376
412
  for (const entry of state.items) {
377
- if (entry.removed || entry.children?.length)
413
+ if (entry.removed && !aliases.has(entry.id) || entry.children?.length)
378
414
  continue;
379
415
  const units = current?.units.filter(unit => entry.units.includes(`${current.runID}/${unit.unit.id}`)) ?? [];
380
416
  if (["artifact", "implementation", "validation", "requirement"].includes(entry.kind) && units.length && units.every(unit => unit.status === "succeeded")) {
@@ -51,7 +51,14 @@ export declare function resolveProjectRoot(input: {
51
51
  directory: string;
52
52
  worktree?: string;
53
53
  }): string;
54
+ /** Reuse the write-gate's quoted shell tokenization for non-authorizing command identity checks. */
55
+ export declare function shellCommandWords(command: string): string[];
56
+ type ShellDialect = "posix" | "powershell";
57
+ /** The command before an unquoted shell suffix, reusing the gate's existing syntax scanner. */
58
+ export declare function shellCommandBase(command: string, dialect: ShellDialect): string | undefined;
54
59
  export declare function describeUnclassifiedCommand(tool: string, args: unknown): string | undefined;
60
+ /** Shell tool names already supported by the write gate, including native suffixes. */
61
+ export declare function isShellTool(tool: string): boolean;
55
62
  /** Extract known write destinations; unknown shell executables fail closed as ambiguous. */
56
63
  export declare function extractWritePaths(tool: string, args: unknown, toolDirectory?: string): Extraction;
57
64
  /**
@@ -101,6 +101,10 @@ function unquote(value) {
101
101
  function words(command) {
102
102
  return command.match(/"(?:\\.|[^"])*"|'[^']*'|[^\s]+/gu)?.map(unquote) ?? [];
103
103
  }
104
+ /** Reuse the write-gate's quoted shell tokenization for non-authorizing command identity checks. */
105
+ export function shellCommandWords(command) {
106
+ return words(command);
107
+ }
104
108
  function operands(tokens) {
105
109
  const result = [];
106
110
  for (let index = 1; index < tokens.length; index += 1) {
@@ -411,6 +415,18 @@ function shellSegments(command, dialect) {
411
415
  segments.push(command.slice(start));
412
416
  return segments;
413
417
  }
418
+ /** The command before an unquoted shell suffix, reusing the gate's existing syntax scanner. */
419
+ export function shellCommandBase(command, dialect) {
420
+ const masked = scanShellSyntax(command, dialect).masked;
421
+ for (let index = 0; index < masked.length; index += 1) {
422
+ const character = masked[index];
423
+ if (!";|><\r\n".includes(character) && !(character === "&" && masked.slice(0, index).trim()))
424
+ continue;
425
+ const base = command.slice(0, index);
426
+ return (character === ">" || character === "<" ? base.replace(/(?:^|\s)\d+$/u, "") : base).trim();
427
+ }
428
+ return undefined;
429
+ }
414
430
  function depthOnePowerShellLiteral(source) {
415
431
  const match = /^\s*(?:"[^"]*(?:pwsh|powershell)(?:\.exe)?"|[^\s"']*(?:pwsh|powershell)(?:\.exe)?)\s+-noprofile\s+-command\s+'((?:''|[^'])*)'\s*$/iu.exec(source);
416
432
  return match?.[1].replaceAll("''", "'");
@@ -761,6 +777,10 @@ export function describeUnclassifiedCommand(tool, args) {
761
777
  const extracted = extractWritePaths(tool, args);
762
778
  return extracted.ambiguous && extracted.issue !== undefined ? issuePath(extracted.issue) : undefined;
763
779
  }
780
+ /** Shell tool names already supported by the write gate, including native suffixes. */
781
+ export function isShellTool(tool) {
782
+ return /^(?:bash|shell|powershell|pwsh)(?:$|[_-])/u.test(tool.toLowerCase());
783
+ }
764
784
  /** Extract known write destinations; unknown shell executables fail closed as ambiguous. */
765
785
  export function extractWritePaths(tool, args, toolDirectory) {
766
786
  const name = tool.toLowerCase();
@@ -774,7 +794,7 @@ export function extractWritePaths(tool, args, toolDirectory) {
774
794
  const extracted = patch === undefined ? [] : patchPaths(patch);
775
795
  return { applies: true, ambiguous: extracted.length === 0, paths: [...paths, ...extracted] };
776
796
  }
777
- if (/^(?:bash|shell|powershell|pwsh)(?:$|[_-])/u.test(name)) {
797
+ if (isShellTool(tool)) {
778
798
  const command = isRecord(args) && typeof args.command === "string" ? args.command : undefined;
779
799
  if (command === undefined) {
780
800
  const ambiguous = paths.length === 0;
@@ -1036,7 +1056,7 @@ export function isKnownReadOnlyTool(tool, args, additionalReadOnlyTools = new Se
1036
1056
  const name = tool.toLowerCase();
1037
1057
  if (READ_ONLY_TOOLS.has(name) || additionalReadOnlyTools.has(name))
1038
1058
  return true;
1039
- if (!/^(?:bash|shell|powershell|pwsh)(?:$|[_-])/u.test(name))
1059
+ if (!isShellTool(tool))
1040
1060
  return false;
1041
1061
  const extraction = extractWritePaths(tool, args);
1042
1062
  return !extraction.applies && !extraction.ambiguous && extraction.remoteMutation !== true;