sortie-dogs 0.13.17 → 0.13.19

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.17](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.17)**
23
- · [Release notes](docs/release-v0.13.17.md)
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)
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.17
60
+ npm install --save-dev sortie-dogs@0.13.19
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.17
89
+ npm install --save-dev sortie-dogs@0.13.19
90
90
  npx --no-install sortie-dogs init .
91
91
  ```
92
92
 
@@ -125,6 +125,11 @@ Discovery: User → Operator → Coordinator → Worker → Validation → Revi
125
125
  The default Mission profile is serial. Background responsiveness and status observations do not
126
126
  add parallel writers, replay unknown commands or imply completion. More agents are not the goal.
127
127
 
128
+ Whole-request workload appears as `全体作業量 n/100(概算)`, using ordinary plans and native
129
+ work observations, not budget consumption or fixed stage shares. Calculation adds no model
130
+ request or verification; formal acceptance still owns `100/100`.
131
+ [Workload estimates, optional weights and runtime cost](docs/work-quantity-progress.md).
132
+
128
133
  For **run-once / result-collection** requests, a terminal failure can be the requested result:
129
134
  validate and report it without an unauthorized repair/rerun loop. Acceptance preserves the real
130
135
  operation exit and `execution-failed` outcome; it never turns a failed benchmark into a successful
@@ -188,7 +193,7 @@ known expense. Official local evaluation and leaderboard acceptance are separate
188
193
  **8/23 (34.8%)**, versus v0.12.25's 7/23; all seven prior resolutions retained, no empty patches or
189
194
  official evaluation errors. [Fixed conditions, stopped-run accounting and costs](docs/benchmarks/swebench-lite-history.md#v0131-dev23-2026-09-30).
190
195
 
191
- All scores belong to their fixed candidates, **not v0.13.17**. SWE-bench remains an optional
196
+ All scores belong to their fixed candidates, **not v0.13.19**. SWE-bench remains an optional
192
197
  measurement, not a release gate. Different budgets, models and conditions are not controlled comparisons.
193
198
 
194
199
  ## Configuration
@@ -211,7 +216,7 @@ Install the desired package version and rerun **your host's** initializer:
211
216
 
212
217
  An OpenCode config-local bridge may resolve a separate package; npm-global update alone does not
213
218
  update it. [Global/update instructions](docs/configuration.md#global-availability).
214
- The current Mission asset marker is `0.13.17-handoff-continuity-v1`; the unchanged Codex skill marker is
219
+ The current Mission asset marker is `0.13.19-workload-progress-v1`; the unchanged Codex skill marker is
215
220
  `0.13.11-codex-skill-v2`. Installed markers alone do not prove a running host reloaded the new version.
216
221
 
217
222
  There is no supported uninstall command. Remove the dependency and only known Sortie-owned paths;
@@ -227,7 +232,7 @@ follow the [manual removal guide](docs/uninstall.md), never delete the entire `.
227
232
  [SWE-bench history](docs/benchmarks/swebench-lite-history.md) · [Historical local case study](docs/benchmark-reference.md).
228
233
  - **Development:** [Testing](docs/testing.md) · [CLI testing](docs/cli-testing.md) ·
229
234
  [Windows tests](docs/windows-tests.md) · [Release routine](docs/release-batch.md).
230
- - **Releases:** [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.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) ·
231
236
  [v0.13.9](docs/release-v0.13.9.md) · [All GitHub releases](https://github.com/zufall-upon/Sortie-dogs/releases).
232
237
 
233
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.17-handoff-continuity-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.13.19-workload-progress-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.17-handoff-continuity-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.13.19-workload-progress-v1";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
@@ -118,6 +118,8 @@ export function codexMissionProgress(event) {
118
118
  command: bounded(args.command), workdir: bounded(args.workdir), cwd: bounded(metadata.cwd), reason: bounded(args.reason),
119
119
  agent: bounded(args.subagent_type), description: bounded(args.description), child_session_id: bounded(metadata.sessionId),
120
120
  outcome: bounded(receipt.status), exit: metadata.exit, executor: metadata.executor,
121
+ ...(record(receipt.work_quantity ?? metadata.work_quantity) ? { work_quantity: receipt.work_quantity ?? metadata.work_quantity,
122
+ work_quantity_display: bounded(receipt.work_quantity_display ?? metadata.work_quantity_display) } : {}),
121
123
  next_action: bounded(receipt.next_action),
122
124
  summary: bounded(args.summary ?? receipt.summary),
123
125
  detail: bounded(receipt.reason ?? receipt.error ?? (item.success === false ? text : undefined)) };
@@ -111,6 +111,15 @@ export interface GoalTerminalReceipt {
111
111
  interface GoalEventBase {
112
112
  readonly at: string;
113
113
  }
114
+ /** Host verification of an existing direct finish receipt, not a new execution. */
115
+ export interface GoalDirectFinishRecovery {
116
+ readonly call_id: string;
117
+ readonly actor_session_id: string;
118
+ readonly finish_call_id: string;
119
+ readonly started_at: string;
120
+ readonly finished_at: string;
121
+ readonly validation_call_ids: readonly string[];
122
+ }
114
123
  export interface GoalValidationRetryAuthorization {
115
124
  readonly authority: "operator-repair-validation-retry";
116
125
  readonly operator_run_id: string;
@@ -218,6 +227,7 @@ export type GoalFlightEvent = (GoalEventBase & {
218
227
  readonly unit_id: string;
219
228
  readonly previous_receipt_id: string;
220
229
  readonly evidence: readonly GoalEvidence[];
230
+ readonly direct_finish?: GoalDirectFinishRecovery;
221
231
  }) | (GoalEventBase & {
222
232
  readonly kind: "validation.admission";
223
233
  readonly goal_id: string;
@@ -133,7 +133,7 @@ export function reduceGoalFlight(records) {
133
133
  let replanRevision = null;
134
134
  let legacyResetRevision = false;
135
135
  let previous = null;
136
- const processDefects = new Map();
136
+ const recoverableSettlements = new Map();
137
137
  const unitCosts = new Map();
138
138
  const validationAdmissions = new Map();
139
139
  const validationSettlements = new Map();
@@ -160,7 +160,7 @@ export function reduceGoalFlight(records) {
160
160
  session_ids: [event.origin_session_id] };
161
161
  replanRevision = null;
162
162
  legacyResetRevision = false;
163
- processDefects.clear();
163
+ recoverableSettlements.clear();
164
164
  validationAdmissions.clear();
165
165
  validationSettlements.clear();
166
166
  reopenedValidationEvidence.clear();
@@ -255,8 +255,8 @@ export function reduceGoalFlight(records) {
255
255
  unit_ids: addUnique(state.unit_ids, event.unit_id),
256
256
  evidence_refs: event.evidence.reduce((refs, evidence) => addUnique(refs, evidence.evidence_id), state.evidence_refs),
257
257
  satisfied_criteria: event.evidence.reduce((ids, evidence) => evidence.measurement.criterion_ids.reduce(addUnique, ids), state.satisfied_criteria) };
258
- if (event.result_class === "process-defect" && event.evidence.length === 0 && event.disposition !== "cancelled") {
259
- processDefects.set(event.receipt_id, { unitID: event.unit_id, revision: state.revision, epoch: state.scope_epoch });
258
+ if (event.evidence.length === 0 && event.disposition !== "succeeded") {
259
+ recoverableSettlements.set(event.receipt_id, { unitID: event.unit_id, revision: state.revision, epoch: state.scope_epoch, settlement: event });
260
260
  }
261
261
  }
262
262
  else if (event.kind === "unit.usage-reconciled") {
@@ -271,11 +271,24 @@ export function reduceGoalFlight(records) {
271
271
  : costs.reduce((sum, item) => sum + item.cost, 0) };
272
272
  }
273
273
  else if (event.kind === "unit.evidence-reconciled") {
274
- const old = processDefects.get(event.previous_receipt_id);
274
+ const old = recoverableSettlements.get(event.previous_receipt_id);
275
+ const direct = event.direct_finish, settled = old?.settlement;
276
+ const directFinish = direct !== undefined && text(direct.call_id) && direct.call_id.startsWith("direct-") &&
277
+ text(direct.actor_session_id) && text(direct.finish_call_id) && instant(direct.started_at) && instant(direct.finished_at) &&
278
+ Date.parse(direct.finished_at) >= Date.parse(direct.started_at) && Array.isArray(direct.validation_call_ids) && direct.validation_call_ids.length > 0 &&
279
+ direct.validation_call_ids.every(text) && settled?.native_session_id === direct.actor_session_id &&
280
+ settled.native_started_at !== undefined && Math.abs(Date.parse(settled.native_started_at) - Date.parse(direct.started_at)) <= 1000 &&
281
+ Math.abs(Date.parse(settled.at) - Date.parse(direct.finished_at)) <= 1000 &&
282
+ settled.reservation_id === goalFingerprint({ goal_id: event.goal_id, unit_id: event.unit_id, call_id: direct.call_id }) &&
283
+ settled.receipt_id === goalFingerprint({ call_id: direct.call_id,
284
+ output: "Host-observed direct unit finished; controller session remains active." }) &&
285
+ (settled.disposition === "cancelled" && settled.result_class === "interrupted" ||
286
+ settled.disposition === "failed" && ["process-defect", "acceptance"].includes(settled.result_class ?? ""));
287
+ const processDefect = direct === undefined && settled?.disposition !== "cancelled" && settled?.result_class === "process-defect";
275
288
  requireState(state.phase === "active" && state.receipt === null && old?.unitID === event.unit_id &&
276
- old.revision === state.revision && old.epoch === state.scope_epoch && state.outstanding_reservations.length === 0, "transition", "Evidence reconciliation requires a current settled process defect.");
289
+ old.revision === state.revision && old.epoch === state.scope_epoch && state.outstanding_reservations.length === 0 && (processDefect || directFinish), "transition", "Evidence reconciliation requires a current settled process defect or verified direct finish defect.");
277
290
  requireState(event.evidence.length > 0 && event.evidence.every(entry => validGoalEvidence(entry, state) && entry.execution.units.includes(event.unit_id)), "evidence", "Reconciled evidence must retain the current goal and unit identity.");
278
- processDefects.delete(event.previous_receipt_id);
291
+ recoverableSettlements.delete(event.previous_receipt_id);
279
292
  state = { ...state, evidence_refs: event.evidence.reduce((refs, evidence) => addUnique(refs, evidence.evidence_id), state.evidence_refs),
280
293
  satisfied_criteria: event.evidence.reduce((ids, evidence) => evidence.measurement.criterion_ids.reduce(addUnique, ids), state.satisfied_criteria) };
281
294
  }
@@ -391,7 +391,10 @@ async function v010Config(root, global) {
391
391
  const update = (path, value) => { content = applyEdits(content, modify(content, path, value, options)); };
392
392
  const wrapper = `${prefix}plugins/sortie-dogs/index.js`;
393
393
  const wrapperSource = await readFile(resolve(root, wrapper), "utf8").catch(() => "");
394
- const localV2Wrapper = /^\s*export\s*\{\s*default\s*\}\s*from\s*["']sortie-dogs\/server["'];?\s*$/u.test(wrapperSource);
394
+ // Both the package re-export and the release fixture's factory wrapper are
395
+ // automatically discovered by V2. Adding the package would load the same ID twice.
396
+ const localV2Wrapper = /^\s*export\s*\{\s*default\s*\}\s*from\s*["']sortie-dogs\/server["'];?\s*$/u.test(wrapperSource) ||
397
+ /^\s*import\s*\{\s*createSortieDogsV2Plugin\s*\}\s*from\s*["']sortie-dogs\/server["'];?\s*import\s*\{\s*SortieDogsPlugin\s*\}\s*from\s*["']sortie-dogs\/plugin["'];?\s*export\s+default\s+createSortieDogsV2Plugin\s*\(\s*SortieDogsPlugin\s*\);?\s*$/u.test(wrapperSource);
395
398
  const registered = plugins?.some(item => {
396
399
  const name = typeof item === "string" ? item : item !== null && typeof item === "object" ? item.package : undefined;
397
400
  return typeof name === "string" && /^sortie-dogs(?:@[^/]+)?$/u.test(name);
@@ -1,6 +1,7 @@
1
1
  import { type OperatorPlan, type OperatorState, type OperatorTask, type OperatorRuntime } from "./operator-runtime.js";
2
2
  import { type RuntimeProfile } from "./runtime-profile.js";
3
3
  import { type MissionContinuity } from "./mission-context.js";
4
+ import { type WorkQuantityState } from "./work-quantity.js";
4
5
  export { missionValidationCommand } from "./mission-validation-command.js";
5
6
  export declare const MISSION_REFERENCE = "SORTIE_MISSION_REF ";
6
7
  export declare const MISSION_REVIEW_REFERENCE = "SORTIE_MISSION_REVIEW_REF ";
@@ -76,6 +77,13 @@ export interface MissionAttempt {
76
77
  code: string;
77
78
  };
78
79
  resultClass?: string;
80
+ /** Retained erroneous finish projection; the original goal receipt remains append-only. */
81
+ registrationRecovery?: {
82
+ at: string;
83
+ previousStatus: MissionAttempt["status"];
84
+ previousResultClass?: string;
85
+ previousFailure?: MissionAttempt["failure"];
86
+ };
79
87
  selectedModel?: string;
80
88
  }
81
89
  export interface MissionTerminalRescue {
@@ -249,6 +257,8 @@ export interface OperatorMission {
249
257
  status: string;
250
258
  at: string;
251
259
  }[];
260
+ /** Display-only request workload; excluded from controls, prompts and acceptance. */
261
+ workQuantity?: WorkQuantityState;
252
262
  submission: {
253
263
  status: "ready" | "needs-decision" | "blocked";
254
264
  summary: string;
@@ -380,7 +390,7 @@ export declare function missionPlan(mission: OperatorMission, raw: unknown, proj
380
390
  /** Compact provenance, not a new acceptance verdict or a substitute for original-request comparison. */
381
391
  export declare function missionAcceptanceSummary(mission: OperatorMission, run: OperatorState | undefined, operators: OperatorRuntime, observe?: (validation: readonly string[], child: string | null, notBefore?: number) => Promise<unknown>): Promise<Record<string, unknown>>;
382
392
  export declare function missionReviewIndependent(mission: OperatorMission, child: string | undefined): boolean;
383
- export declare function missionPacket(mission: OperatorMission, run?: OperatorState): Record<string, unknown>;
393
+ export declare function missionPacket(mission: OperatorMission, run?: OperatorState, includeWorkQuantity?: boolean): Record<string, unknown>;
384
394
  /** Models forward a short capability, never recopy the host's evidence hashes and source packet. */
385
395
  export declare function missionReviewTask(mission: OperatorMission): OperatorTask;
386
396
  /** Optional implementation notes supplement host-observed source/checks; prose is not a coverage gate. */
@@ -7,6 +7,8 @@ import { profileAgent } from "./runtime-profile.js";
7
7
  import { normalizeCommand } from "../plugin/gate.js";
8
8
  import { missionValidationCommand } from "./mission-validation-command.js";
9
9
  import { retainMissionContext, missionContextView } from "./mission-context.js";
10
+ import { planWorkQuantity, projectWorkQuantity, syncWorkQuantity } from "./work-quantity.js";
11
+ import { applyPendingWork, rememberWorkMission, rememberedWorkRun } from "./work-quantity-observations.js";
10
12
  export { missionValidationCommand } from "./mission-validation-command.js";
11
13
  const digest = (value) => createHash("sha256").update(value).digest("hex");
12
14
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
@@ -299,11 +301,22 @@ export class OperatorMissionRuntime {
299
301
  return matches.length === 1 ? matches[0] : undefined;
300
302
  }
301
303
  async save(file, value) {
304
+ if (record(value) && value.version === "0.12" && typeof value.root === "string") {
305
+ const mission = value;
306
+ const run = rememberedWorkRun(this.projectRoot, mission.root);
307
+ const current = run?.runID === mission.runID ? run : undefined;
308
+ if (!mission.workQuantity || current && mission.workQuantity.planRunID !== current.runID) {
309
+ mission.workQuantity = planWorkQuantity(mission, current?.units.map(unit => unit.unit), current, mission.workQuantity, undefined, this.projectRoot);
310
+ }
311
+ mission.workQuantity = syncWorkQuantity(applyPendingWork(this.projectRoot, mission.root, mission.workQuantity), mission, current);
312
+ }
302
313
  const temporary = `${file}.${randomUUID()}.tmp`;
303
314
  await mkdir(join(this.projectRoot, this.profile.stateDirectory, "missions"), { recursive: true });
304
315
  try {
305
316
  await writeFile(temporary, JSON.stringify(value), { flag: "wx", mode: 0o600 });
306
317
  await rename(temporary, file);
318
+ if (record(value) && value.version === "0.12" && file === this.file(String(value.root)))
319
+ rememberWorkMission(this.projectRoot, value);
307
320
  }
308
321
  finally {
309
322
  await rm(temporary, { force: true });
@@ -326,6 +339,8 @@ export class OperatorMissionRuntime {
326
339
  const state = await this.loadMission(root);
327
340
  if (state && (state.version !== "0.12" || state.root !== root))
328
341
  throw new Error("mission-state-invalid");
342
+ if (state)
343
+ rememberWorkMission(this.projectRoot, state);
329
344
  return state;
330
345
  }
331
346
  /** Serialize host recovery claims across processes; a stale lock never authorizes takeover. */
@@ -441,6 +456,7 @@ export class OperatorMissionRuntime {
441
456
  throw new Error("mission-requirements-preserved: keep the existing ordered requirements and append user additions");
442
457
  }
443
458
  previous.requirements = requirements.map((text, index) => ({ id: `R${index + 1}`, text }));
459
+ previous.workQuantity = planWorkQuantity(previous, [], undefined, previous.workQuantity);
444
460
  // Only an explicit Mission action adopts the latest real turn; chat capture alone never does.
445
461
  if (!previous.requests.some(item => item.id === request.id))
446
462
  previous.requests.push(request);
@@ -468,6 +484,10 @@ export class OperatorMissionRuntime {
468
484
  ...((previous === undefined || previous.phase === "cancelled") &&
469
485
  (replaceRequirements || previous?.runID === null || previous === undefined || request.id !== previous.requests[0]?.id) && predecessor
470
486
  ? { supersededRunID: predecessor } : {}) };
487
+ if (previous?.phase === "cancelled" && !replaceRequirements &&
488
+ previous.requirements.every((requirement, index) => state.requirements[index]?.text === requirement.text)) {
489
+ state.workQuantity = planWorkQuantity(state, [], undefined, previous.workQuantity);
490
+ }
471
491
  await this.save(this.file(root), state);
472
492
  return state;
473
493
  });
@@ -762,7 +782,7 @@ export function missionReviewIndependent(mission, child) {
762
782
  return child !== undefined && !mission.sourceAuthors?.includes(child) &&
763
783
  !(mission.corrections ?? []).some(correction => correction.author === child);
764
784
  }
765
- export function missionPacket(mission, run) {
785
+ export function missionPacket(mission, run, includeWorkQuantity = true) {
766
786
  const predecessor = run && mission.runID !== run.runID && (mission.supersededRunID === run.runID || run.phase === "cancelled") ? run : undefined;
767
787
  if (predecessor)
768
788
  run = undefined;
@@ -770,7 +790,9 @@ export function missionPacket(mission, run) {
770
790
  const reviewAccepted = currentReview && missionReviewAccepted(mission.review);
771
791
  const operationStatus = missionExecutionStatus(mission);
772
792
  const operationComplete = missionExecutionComplete(mission);
793
+ const workload = syncWorkQuantity(structuredClone(mission.workQuantity ?? planWorkQuantity(mission, run?.runID === mission.runID ? run.units.map(unit => unit.unit) : [], run)), mission, run);
773
794
  return { mission_id: mission.id, phase: mission.phase, coordinator_session_id: mission.coordinator,
795
+ ...(includeWorkQuantity ? { work_quantity: projectWorkQuantity(workload, mission.phase === "completed", mission.phase === "cancelled") } : {}),
774
796
  ...(predecessor ? { predecessor: { run_id: predecessor.runID, status: predecessor.phase,
775
797
  completed_units: predecessor.units.filter(unit => unit.status === "succeeded").length,
776
798
  note: "Historical results and spend are retained; they do not complete the current requirements." } } : {}),
@@ -2,6 +2,7 @@ import { type RuntimeProfile } from "./runtime-profile.js";
2
2
  import type { GoalEvidence, GoalTerminalReceipt } from "./goal-bound.js";
3
3
  import type { SerialDispatchSettlement } from "../plugin/runtime-bridge.js";
4
4
  import { type OperatorContractRepairFileIdentity } from "./operator-contract-repair.js";
5
+ import { type WorkQuantityState, type WorkQuantityProgress } from "./work-quantity.js";
5
6
  export declare const OPERATOR_LIMITS: Readonly<{
6
7
  units: 32;
7
8
  planBytes: number;
@@ -131,6 +132,11 @@ interface UnitState {
131
132
  actor: string;
132
133
  startedAt: string;
133
134
  finishedAt?: string;
135
+ registrationRecovery?: {
136
+ at: string;
137
+ previousStatus: "failed" | "cancelled";
138
+ previousResultClass: string | null;
139
+ };
134
140
  checks: import("../plugin/runtime-bridge.js").ReviewerCorrectionCheck[];
135
141
  validationCwd?: Readonly<Record<string, string>>;
136
142
  validationRegisteredAt?: Readonly<Record<string, string>>;
@@ -200,6 +206,8 @@ export interface OperatorAcceptanceAnchor {
200
206
  readonly handoffHash: string;
201
207
  }
202
208
  export interface OperatorState {
209
+ /** Display metadata only, never part of the immutable execution plan. */
210
+ workQuantity?: WorkQuantityState;
203
211
  readonly schema_version: "0.1";
204
212
  readonly profile: string;
205
213
  readonly rootSessionID: string;
@@ -249,6 +257,7 @@ export interface OperatorState {
249
257
  receipt: GoalTerminalReceipt | null;
250
258
  }
251
259
  export interface OperatorProgress {
260
+ readonly work_quantity?: WorkQuantityProgress;
252
261
  readonly profile: string;
253
262
  readonly view: "progress";
254
263
  readonly run_id: string;
@@ -430,7 +439,7 @@ export declare class OperatorRuntime {
430
439
  private repairValidationRetryBinding;
431
440
  canRetryRepairValidation(state: OperatorState): boolean;
432
441
  resumeRepairValidation(root: string, runID: string, authorize: (source: OperatorRepairValidationRetrySource) => Promise<void>): Promise<OperatorState>;
433
- resume(root: string, runID: string, recovered: ReadonlyMap<string, readonly GoalEvidence[]>, unstarted?: ReadonlySet<string>): Promise<OperatorState>;
442
+ resume(root: string, runID: string, recovered: ReadonlyMap<string, readonly GoalEvidence[]>, unstarted?: ReadonlySet<string>, recoveredDirect?: ReadonlySet<string>): Promise<OperatorState>;
434
443
  requireAcceptanceRemediation(root: string, runID: string): Promise<OperatorState>;
435
444
  draftStatus(root: string): Promise<unknown | undefined>;
436
445
  completionGoalFingerprint(state: OperatorState): Promise<string>;
@@ -15,6 +15,8 @@ import { ensureGitManagedStateExcluded } from "./git-managed-state.js";
15
15
  import { operatorContractRepairFingerprint } from "./operator-contract-repair.js";
16
16
  import { classifyUnitResult } from "./unit-result-classification.js";
17
17
  import { SOURCE_REVIEW_PHASES, SOURCE_REVIEW_RISK_TAGS } from "./consultation.js";
18
+ import { planWorkQuantity, projectWorkQuantity, syncWorkQuantity } from "./work-quantity.js";
19
+ import { applyPendingWork, rememberWorkRun } from "./work-quantity-observations.js";
18
20
  export const OPERATOR_LIMITS = Object.freeze({ units: 32, planBytes: 256 * 1024, packetBytes: 24 * 1024,
19
21
  remediationReserve: 32 });
20
22
  export class OperatorContractError extends Error {
@@ -319,8 +321,10 @@ export class OperatorRuntime {
319
321
  async read(root) {
320
322
  await this.writes.get(root);
321
323
  const cached = this.states.get(root);
322
- if (cached)
324
+ if (cached) {
325
+ rememberWorkRun(this.projectRoot, cached);
323
326
  return structuredClone(cached);
327
+ }
324
328
  let source;
325
329
  try {
326
330
  source = await readFile(this.file(root), "utf8");
@@ -330,9 +334,13 @@ export class OperatorRuntime {
330
334
  return undefined;
331
335
  throw error;
332
336
  }
333
- if (Buffer.byteLength(source) > OPERATOR_LIMITS.planBytes * 4)
334
- throw new Error("operator-state-too-large");
335
337
  const parsed = JSON.parse(source);
338
+ // Display metadata must not turn an otherwise valid execution state into a refusal.
339
+ if (Buffer.byteLength(source) > OPERATOR_LIMITS.planBytes * 4) {
340
+ const { workQuantity: _display, ...execution } = parsed;
341
+ if (Buffer.byteLength(JSON.stringify(execution)) > OPERATOR_LIMITS.planBytes * 4)
342
+ throw new Error("operator-state-too-large");
343
+ }
336
344
  const repairGeneration = parsed.repairGeneration === undefined ? 0 : parsed.repairGeneration;
337
345
  const state = { ...parsed,
338
346
  repairResidualPaths: parsed.repairResidualPaths === undefined ? [] : parsed.repairResidualPaths,
@@ -382,9 +390,15 @@ export class OperatorRuntime {
382
390
  throw new Error("operator-state-invalid");
383
391
  }
384
392
  this.states.set(root, state);
393
+ rememberWorkRun(this.projectRoot, state);
385
394
  return structuredClone(state);
386
395
  }
387
396
  async save(state) {
397
+ if (!state.workQuantity || state.workQuantity.planRunID !== state.runID)
398
+ state.workQuantity = planWorkQuantity({
399
+ id: state.workQuantity?.scopeID ?? `operator:${state.runID}`, requirements: state.acceptance.map((text, index) => ({ id: `R${index + 1}`, text })), phase: "running", submission: null
400
+ }, state.units.map(unit => unit.unit), state, state.workQuantity, undefined, this.projectRoot);
401
+ syncWorkQuantity(applyPendingWork(this.projectRoot, state.rootSessionID, state.workQuantity), {}, state);
388
402
  state.sequence++;
389
403
  const serialized = JSON.stringify(state);
390
404
  const previous = this.writes.get(state.rootSessionID) ?? Promise.resolve();
@@ -405,6 +419,7 @@ export class OperatorRuntime {
405
419
  try {
406
420
  await next;
407
421
  this.states.set(state.rootSessionID, structuredClone(state));
422
+ rememberWorkRun(this.projectRoot, state);
408
423
  }
409
424
  catch (error) {
410
425
  state.sequence--;
@@ -1139,6 +1154,7 @@ export class OperatorRuntime {
1139
1154
  ? { ...structuredClone(previous.gitLifecycle), committedHead: null, commitProvenance: null }
1140
1155
  : await this.createGitLifecycle(plan, remediationHead);
1141
1156
  const state = { schema_version: "0.1", profile: this.profile.id, rootSessionID: root, runID, planHash: validatedPlanHash,
1157
+ ...(parent?.workQuantity ? { workQuantity: structuredClone(parent.workQuantity) } : {}),
1142
1158
  acceptance: plan.acceptance, acceptanceProof: plan.acceptance_proof, acceptanceFingerprint, sourceRefs: plan.source_refs, createdAt: new Date().toISOString(),
1143
1159
  parentRunID: parent?.runID ?? null, ...(superseding ? { supersededRunID: previous.runID } : {}), priorAcceptedUnits,
1144
1160
  remediationParent: remediationTaskID && remediationHead && parent?.gitLifecycle ? {
@@ -2091,16 +2107,17 @@ export class OperatorRuntime {
2091
2107
  return state;
2092
2108
  });
2093
2109
  }
2094
- resume(root, runID, recovered, unstarted = new Set()) {
2110
+ resume(root, runID, recovered, unstarted = new Set(), recoveredDirect = new Set()) {
2095
2111
  return this.serial(root, async () => {
2096
2112
  const state = await this.required(root);
2097
2113
  if (state.contractRepair !== null)
2098
2114
  throw new Error("operator-resume-contract-repair-required");
2099
- if (state.runID !== runID || state.phase !== "awaiting-decision" || state.units.some(unit => unit.status === "running" || unit.status === "cancelled")) {
2115
+ if (state.runID !== runID || state.phase !== "awaiting-decision" || state.units.some(unit => unit.status === "running" ||
2116
+ unit.status === "cancelled" && !recoveredDirect.has(unit.unit.id))) {
2100
2117
  throw new Error("operator-resume-not-ready");
2101
2118
  }
2102
2119
  for (const unit of state.units) {
2103
- if (unit.status !== "failed")
2120
+ if (unit.status !== "failed" && unit.status !== "cancelled")
2104
2121
  continue;
2105
2122
  if (unstarted.has(unit.unit.id)) {
2106
2123
  if (unit.resultClass !== "process-defect" || unit.childSessionID !== null || state.decision !== "dispatch-admission-rejected")
@@ -2111,10 +2128,18 @@ export class OperatorRuntime {
2111
2128
  continue;
2112
2129
  }
2113
2130
  const evidence = recovered.get(unit.unit.id);
2131
+ const direct = recoveredDirect.has(unit.unit.id) && unit.directExecution?.actor === unit.childSessionID &&
2132
+ !!unit.directExecution.finishedAt && !!unit.callID && !!evidence?.length;
2133
+ await this.verifyControls(unit);
2114
2134
  const ids = new Set(evidence?.flatMap(item => item.measurement.criterion_ids) ?? []);
2115
- if (unit.resultClass !== "process-defect" || !evidence?.length || !unit.unit.acceptance_indices.every(index => state.acceptanceProof[index].some(id => ids.has(id)))) {
2135
+ if (unit.resultClass !== "process-defect" && !direct || !evidence?.length || !unit.unit.acceptance_indices.every(index => state.acceptanceProof[index].some(id => ids.has(id)))) {
2116
2136
  throw new Error("operator-resume-proof-incomplete");
2117
2137
  }
2138
+ if (unit.status === "cancelled" && !direct)
2139
+ throw new Error("operator-resume-proof-incomplete");
2140
+ if (direct)
2141
+ unit.directExecution.registrationRecovery = { at: new Date().toISOString(),
2142
+ previousStatus: unit.status, previousResultClass: unit.resultClass };
2118
2143
  unit.status = "succeeded";
2119
2144
  unit.resultClass = "acceptance";
2120
2145
  unit.evidence = evidence;
@@ -2210,6 +2235,7 @@ export class OperatorRuntime {
2210
2235
  const authoritativeAction = nextAction !== undefined ? nextAction
2211
2236
  : this.continuationNextAction(state);
2212
2237
  return { profile: state.profile, view: "progress", run_id: state.runID, stage: state.phase, decision: state.decision,
2238
+ ...(state.workQuantity ? { work_quantity: projectWorkQuantity(state.workQuantity, state.phase === "completed", state.phase === "cancelled") } : {}),
2213
2239
  current_unit: current === undefined ? null : { id: current.unit.id, title: current.unit.title, status: current.status },
2214
2240
  completed_units: state.units.filter(unit => unit.status === "succeeded").length, total_units: state.units.length,
2215
2241
  budget_remaining_units: budgetRemainingUnits, next_action: typeof authoritativeAction === "string" ? authoritativeAction : null };
@@ -2233,6 +2259,7 @@ export class OperatorRuntime {
2233
2259
  : `call ${this.profile.toolPrefix}cancel_operator, then call ${this.profile.toolPrefix}prepare_operator with an approved replacement plan for the same goal and exact acceptance; start from git_lifecycle.committed_head, keep writes within git_lifecycle.approved_write_union, retain consumed budget, and do not call resume_operator or edit the committed candidate${carriedAction}`;
2234
2260
  const awaitingAcceptanceNextAction = `assess the independent-review requirement and obtain review when required, copying review_dispatch_contract.required_prompt_lines into the reviewer Task prompt with real values; if review PASSes (or policy records an allowed skip), call ${this.profile.toolPrefix}complete_operator with this run_id and acceptance_fingerprint; if review has blocking findings wholly inside the existing exact acceptance, the union of git_lifecycle.approved_write_union and git_lifecycle.remediation_reserve, and remaining cumulative budget, do not complete or ask for user approval: call ${this.profile.toolPrefix}cancel_operator with reason review-blocking, then call ${this.profile.toolPrefix}prepare_operator for one same-goal replacement from git_lifecycle.committed_head targeting only those findings; copy this packet's acceptance array verbatim into the replacement plan without paraphrase, deletion, addition, or reordering, preserve accepted-criteria lineage, and run final canonical validation/review; if the replacement is refused for write scope, report replacement_constraints.blocked_write_paths to the user as the exact paths needing approval and stop, then after the user approves resend the same replacement with git_lifecycle.remediation_scope_expansion set to exactly those paths; if acceptance or budget must increase, stop for the user decision`;
2235
2261
  const packet = { profile: state.profile, run_id: state.runID, root_session_id: state.rootSessionID, generation: state.generation,
2262
+ ...(state.workQuantity ? { work_quantity: projectWorkQuantity(state.workQuantity, state.phase === "completed", state.phase === "cancelled") } : {}),
2236
2263
  sequence: state.sequence, status: state.phase, repair_generation: state.repairGeneration,
2237
2264
  acceptance: state.acceptance, acceptance_fingerprint: state.acceptanceFingerprint,
2238
2265
  next_task_ref: current?.status === "pending" ? (state.units.length > 1
@@ -925,6 +925,14 @@ export class RunFlightLedger {
925
925
  stored.reservation_id === event.reservation_id && stored.native_session_id === event.native_session_id &&
926
926
  stored.cost_usd === event.cost_usd))
927
927
  return reduceGoalFlight(records);
928
+ if (event.kind === "unit.evidence-reconciled" && event.direct_finish !== undefined) {
929
+ const existing = records.find(record => record.event.kind === "unit.evidence-reconciled" &&
930
+ record.event.previous_receipt_id === event.previous_receipt_id)?.event;
931
+ if (existing?.kind === "unit.evidence-reconciled" && existing.goal_id === event.goal_id && existing.unit_id === event.unit_id &&
932
+ goalFingerprint(existing.evidence) === goalFingerprint(event.evidence) &&
933
+ goalFingerprint(existing.direct_finish) === goalFingerprint(event.direct_finish))
934
+ return reduceGoalFlight(records);
935
+ }
928
936
  if (event.kind === "goal.accepted") {
929
937
  const prior = records.find(({ event: stored }) => stored.kind === "goal.accepted" &&
930
938
  stored.origin_user_message_id === event.origin_user_message_id);
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { CONTRACT_TEXT_LIMITS } from "./contract-limits.js";
2
3
  export const VALIDATION_PROFILES = ["fast", "balanced", "assurance"];
3
4
  /** A real failed process can be rerun after setup repair without changing source or command identity. */
4
5
  export function retryableValidationFailure(outcome, exitCode) {
@@ -38,6 +39,7 @@ export function validationEvidenceState(events) {
38
39
  return { reusable: [...reusable], blocked: [...blocked] };
39
40
  }
40
41
  const id = (value) => typeof value === "string" && value.length > 0 && value.length <= 4096;
42
+ const commandText = (value) => typeof value === "string" && value.length > 0 && value.length <= CONTRACT_TEXT_LIMITS.command;
41
43
  const scopes = new Set(["static", "targeted", "related", "canonical", "full-suite", "full"]);
42
44
  const normalizedScope = (scope) => scope === "full" ? "canonical" : scope;
43
45
  export function validationOwner(scope) {
@@ -94,7 +96,7 @@ export function decideValidationBudget(request, state) {
94
96
  const environment = value.environment;
95
97
  const marginal = value.marginal_value;
96
98
  const valid = id(value.run_id) && id(value.operation_id) && id(value.source_snapshot) && id(value.candidate) &&
97
- value.command.length > 0 && value.command.every(id) && scopes.has(value.scope) &&
99
+ value.command.length > 0 && value.command.every(commandText) && scopes.has(value.scope) &&
98
100
  environment !== null && typeof environment === "object" && id(environment.platform) && id(environment.arch) &&
99
101
  id(environment.runtime) && (environment.toolchain === undefined || id(environment.toolchain)) &&
100
102
  (value.owner === "worker" || value.owner === "coordinator") &&
@@ -0,0 +1,13 @@
1
+ /** Bounded display observations from already delivered native events. No I/O or authority. */
2
+ import type { OperatorMission } from "./operator-mission.js";
3
+ import type { OperatorState } from "./operator-runtime.js";
4
+ import { type WorkQuantityEvent, type WorkQuantityState, type WorkQuantityProgress } from "./work-quantity.js";
5
+ export declare function rememberWorkMission(directory: string, mission: OperatorMission): void;
6
+ export declare function rememberWorkRun(directory: string, run: OperatorState): void;
7
+ export declare function rememberedWorkRun(directory: string, root: string): OperatorState | undefined;
8
+ export declare function workRootForActor(directory: string, actor: string): string | undefined;
9
+ /** Every event is folded at most once per saved projection; repeated edits are not completed work. */
10
+ export declare function observeActorWork(directory: string, actor: string, event: WorkQuantityEvent): string | undefined;
11
+ export declare function applyPendingWork(directory: string, root: string, state: WorkQuantityState): WorkQuantityState;
12
+ export declare function missionWorkState(directory: string, mission: OperatorMission, run?: OperatorState): WorkQuantityState;
13
+ export declare function cachedWorkProgress(directory: string, actor: string): WorkQuantityProgress | undefined;
@@ -0,0 +1,91 @@
1
+ import { observeWorkQuantity, planWorkQuantity, projectWorkQuantity, syncWorkQuantity } from "./work-quantity.js";
2
+ const views = new Map();
3
+ const owners = new Map();
4
+ const identity = (directory, root) => `${directory.replaceAll("\\", "/").replace(/\/$/u, "")}\0${root}`;
5
+ function view(directory, root) {
6
+ const id = identity(directory, root);
7
+ let value = views.get(id);
8
+ if (!value) {
9
+ value = { sequence: 0, events: new Map() };
10
+ views.set(id, value);
11
+ }
12
+ owners.set(id, root);
13
+ return value;
14
+ }
15
+ export function rememberWorkMission(directory, mission) {
16
+ const value = view(directory, mission.root);
17
+ if (value.mission?.id !== mission.id && value.mission?.workQuantity?.scopeID !== mission.workQuantity?.scopeID)
18
+ value.events.clear();
19
+ value.mission = mission;
20
+ value.sequence = Math.max(value.sequence, mission.workQuantity?.observationSequence ?? 0);
21
+ delete value.progress;
22
+ if (mission.coordinator)
23
+ owners.set(identity(directory, mission.coordinator), mission.root);
24
+ if (mission.review?.child)
25
+ owners.set(identity(directory, mission.review.child), mission.root);
26
+ for (const attempt of mission.attempts ?? [])
27
+ if (attempt.childSessionID)
28
+ owners.set(identity(directory, attempt.childSessionID), mission.root);
29
+ }
30
+ export function rememberWorkRun(directory, run) {
31
+ const value = view(directory, run.rootSessionID);
32
+ value.run = run;
33
+ value.sequence = Math.max(value.sequence, run.workQuantity?.observationSequence ?? 0);
34
+ delete value.progress;
35
+ if (run.operatorSessionID)
36
+ owners.set(identity(directory, run.operatorSessionID), run.rootSessionID);
37
+ for (const unit of run.units)
38
+ if (unit.childSessionID)
39
+ owners.set(identity(directory, unit.childSessionID), run.rootSessionID);
40
+ }
41
+ export function rememberedWorkRun(directory, root) {
42
+ return views.get(identity(directory, root))?.run;
43
+ }
44
+ export function workRootForActor(directory, actor) { return owners.get(identity(directory, actor)); }
45
+ /** Every event is folded at most once per saved projection; repeated edits are not completed work. */
46
+ export function observeActorWork(directory, actor, event) {
47
+ const root = owners.get(identity(directory, actor));
48
+ if (!root)
49
+ return undefined;
50
+ const value = view(directory, root);
51
+ if (value.mission && ["completed", "cancelled"].includes(value.mission.phase) || !value.mission &&
52
+ value.run && ["completed", "cancelled"].includes(value.run.phase))
53
+ return undefined;
54
+ const run = value.run;
55
+ const unit = run?.units.find(unit => unit.status === "running" && unit.childSessionID === actor);
56
+ const observed = { ...event, ...(unit ? { unit: `${run.runID}/${unit.unit.id}` } : {}) };
57
+ const id = JSON.stringify([event.kind, event.paths, event.command, event.commands, event.checkpoints, observed.unit]);
58
+ value.events.delete(id);
59
+ value.events.set(id, { sequence: ++value.sequence, event: observed });
60
+ delete value.progress;
61
+ if (value.events.size > 512)
62
+ value.events.delete(value.events.keys().next().value);
63
+ return root;
64
+ }
65
+ export function applyPendingWork(directory, root, state) {
66
+ const value = views.get(identity(directory, root));
67
+ if (!value)
68
+ return state;
69
+ for (const entry of value.events.values())
70
+ if (entry.sequence > (state.observationSequence ?? 0))
71
+ observeWorkQuantity(state, entry.event);
72
+ state.observationSequence = value.sequence;
73
+ return state;
74
+ }
75
+ export function missionWorkState(directory, mission, run) {
76
+ const state = mission.workQuantity ?? planWorkQuantity(mission, run?.runID === mission.runID ? run.units.map(unit => unit.unit) : [], run, undefined, undefined, directory);
77
+ return syncWorkQuantity(applyPendingWork(directory, mission.root, structuredClone(state)), mission, run);
78
+ }
79
+ export function cachedWorkProgress(directory, actor) {
80
+ const root = owners.get(identity(directory, actor));
81
+ const value = root && views.get(identity(directory, root));
82
+ if (!value)
83
+ return undefined;
84
+ if (value.progress)
85
+ return value.progress;
86
+ if (value.mission)
87
+ return value.progress = projectWorkQuantity(missionWorkState(directory, value.mission, value.run), value.mission.phase === "completed", value.mission.phase === "cancelled");
88
+ if (value.run?.workQuantity)
89
+ return value.progress = projectWorkQuantity(syncWorkQuantity(applyPendingWork(directory, root, structuredClone(value.run.workQuantity)), {}, value.run), value.run.phase === "completed", value.run.phase === "cancelled");
90
+ return undefined;
91
+ }