sortie-dogs 0.10.18 → 0.10.20

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
@@ -28,8 +28,6 @@ implementation, validation, review, and model routing.
28
28
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) ·
29
29
  [Testing](docs/testing.md) · [CLI testing](docs/cli-testing.md)
30
30
 
31
- Release: [v0.10.6](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.10.6)
32
-
33
31
  > **Beta:** v0.10.x is under active stabilization. Runtime behavior,
34
32
  > configuration, and generated assets may still change before 1.0.
35
33
 
@@ -85,78 +83,23 @@ The v0.10 profile is serial by design. The stable profile's Luna fabric and
85
83
  parallel integration path are not exposed in this profile. More agents are not a
86
84
  goal; preserving quality while reducing unnecessary expensive work is.
87
85
 
88
- ### SWE-bench policy from v0.10.6
89
-
90
- Starting with v0.10.6, development proceeds alongside recurring SWE-bench
91
- measurement. This is a measurement policy, not a claim that an unrun suite passed.
92
-
93
- - Freeze the task input, package/source snapshot, model routes, budgets, tools,
94
- and stop endpoint before comparing runs.
95
- - Freeze the candidate only after all writers stop, then grade a separate copy
96
- with one pinned official verifier execution.
97
- - Record benchmark completion, official task correctness, and harness terminal
98
- state independently. A Sortie `DONE` or review `PASS` is not an official
99
- verifier reward.
100
- - Record agent-to-freeze and verifier time separately, plus root and descendant
101
- tokens, model steps, children, cache behavior, estimated cost, and coverage.
102
- - Keep infrastructure failure distinct from scored failure. Small or unmatched
103
- samples remain case studies, not leaderboard or general success-rate claims.
104
-
105
- The pre-v0.10.6 local benchmark condition is closed historical evidence. New
106
- development decisions use SWE-bench measurements collected under the frozen
107
- contract in [Coding benchmark completion and correctness](docs/benchmark-completion-contract.md).
108
-
109
- ### v0.10.14 SWE-bench Lite dev23 baseline
110
-
111
- The first complete v0.10.14 baseline was run against all 23 `dev` instances and
112
- graded with the pinned official harness. It is a fixed candidate baseline, not a
113
- leaderboard or general success-rate claim.
114
-
115
- - Candidate: `sortie-dogs@0.10.14`, package SHA-256
116
- `f2caf67268a5f69b67a252a0d84bfc5ad09db82684aa8c1e0f5c49f8d2986993`.
117
- - Dataset: `princeton-nlp/SWE-bench_Lite`, revision
118
- `6ec7bb89b9342f664a54a6e0a6ea6501d3437cc2`, split `dev`.
119
- - Conditions: pass@1, `attempts_per_instance=1`, `retry_count=0`, per-instance
120
- inference limit `$1.50`, four independent inference slots, and official
121
- scoring with `max_workers=1`.
122
- - Official score: **6/23 (26.1%)**. All 23 rows received an official result.
123
- - Resolved instances: `marshmallow-code__marshmallow-1343`,
124
- `marshmallow-code__marshmallow-1359`, `pydicom__pydicom-1256`,
125
- `pydicom__pydicom-1694`, `pylint-dev__astroid-1333`, and
126
- `sqlfluff__sqlfluff-1733`.
127
- - Inference spend: `$15.7542788` under the separate `$50` v0.10.14 campaign.
128
- - Environment deviations retained in the result: five `pvlib` rows hit the
129
- NumPy 2.0 removal of `np.Inf`, and `pyvista__pyvista-4315` lacked
130
- `libGL.so.1`. These rows remain in the denominator and were not silently
131
- removed.
132
- - Four non-successful inference terminals (`timeout` or `patch-index-failed`)
133
- produced empty patches and are counted as unresolved official results.
134
-
135
- This baseline is the comparison point for any later v0.10.15 candidate. A
136
- later candidate must use its own package digest and preserve the same dataset,
137
- official scoring boundary, and declared budget conditions before its score is
138
- compared with this value.
139
-
140
- ## Historical local case study
141
-
142
- These are completion-filtered local references on one frozen DeepSWE task,
143
- `datacurve/anko-typed-variable-bindings`, collected from 2026-09-14 through
144
- 2026-09-18. They are not matched pairs or a leaderboard result.
145
-
146
- - Bare OpenCode: Verified PASS `0/3`, F2P `3/27`, median agent wall `24.5 min`,
147
- median completed-run API-equivalent cost `$3.53`.
148
- - Sortie v0.9.12: Verified PASS `0/3`, F2P `23/27`, median wall `25.7 min`,
149
- median completed-run cost `$2.85`; two additional interrupted attempts cost
150
- an estimated `$6.20`.
151
- - Sortie v0.10.3 one-shot: Verified PASS `0/1`, F2P `7/9`, wall `22.7 min`,
152
- estimated cost `$3.79`.
153
- - Sortie v0.10.5 one-shot: Verified PASS `1/1`, F2P `9/9`, P2P `94/94`, wall
154
- `43.8 min`, estimated cost `$2.58`.
155
-
156
- The v0.10.5 result is one verified success, not a success rate. Historical rate
157
- schedules and endpoints differ, and the host-reported zero cost is not treated as
158
- a bill. See [definitions, frozen inputs, and limitations](docs/benchmark-reference.md)
159
- and [machine-readable values](docs/benchmarks/provisional-reference.json).
86
+ ### SWE-bench evaluation
87
+
88
+ A frozen Sortie-dogs v0.10.14 build was evaluated on 23 fixed tasks.
89
+
90
+ - Official verifier PASS: 6 / 23
91
+ - Scored FAIL: 17 / 23
92
+ - Infrastructure blocked: 0 / 23
93
+ - Total estimated model cost: $15.75
94
+ - Median agent runtime: 15.2 min
95
+
96
+ The same Sortie-dogs candidate, model routes, budgets, tools, and verification
97
+ procedure were held fixed for all tasks. This is one frozen evaluation, not a
98
+ general success-rate claim.
99
+
100
+ Full methodology and per-task results: [benchmark details](docs/benchmark-v0.10.14-dev23.md)
101
+
102
+ Historical qualification references remain in [benchmark reference](docs/benchmark-reference.md).
160
103
 
161
104
  ## How v0.10.6 works
162
105
 
@@ -3,5 +3,5 @@
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.10.0-v0912-language4-cost-rpt10-compaction-ref2-proposal1-review-remediation1-surface4-proposal-recovery2-quality1-terminal2-route1-path1-permission2-prerequisite1-v2-bridge1-intent1-reflection1-cancel1-proposal-budget1-goal-recovery1-delegate-execute1-v2-depth1-process-replacement1-unstarted-v2-1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.10.0-v0912-language4-cost-rpt10-compaction-ref2-proposal1-review-remediation1-surface4-proposal-recovery2-quality1-terminal2-route1-path1-permission2-prerequisite1-v2-bridge1-intent1-reflection1-cancel1-proposal-budget1-goal-recovery1-delegate-execute1-v2-depth1-process-replacement1-unstarted-v2-1-cold-turn1-return-report-v2-2-root-revision1";
7
7
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION;
@@ -3,4 +3,4 @@
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.10.0-v0912-language4-cost-rpt10-compaction-ref2-proposal1-review-remediation1-surface4-proposal-recovery2-quality1-terminal2-route1-path1-permission2-prerequisite1-v2-bridge1-intent1-reflection1-cancel1-proposal-budget1-goal-recovery1-delegate-execute1-v2-depth1-process-replacement1-unstarted-v2-1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.10.0-v0912-language4-cost-rpt10-compaction-ref2-proposal1-review-remediation1-surface4-proposal-recovery2-quality1-terminal2-route1-path1-permission2-prerequisite1-v2-bridge1-intent1-reflection1-cancel1-proposal-budget1-goal-recovery1-delegate-execute1-v2-depth1-process-replacement1-unstarted-v2-1-cold-turn1-return-report-v2-2-root-revision1";
@@ -1,10 +1,23 @@
1
1
  /** Shared planner guidance and admission enums; no inferred acceptance or dispatch authority. */
2
2
  export declare const GOAL_DELIVERY_INTENTS: readonly ["design", "registration", "implementation", "repair", "controlled-change"];
3
3
  export declare const GOAL_DELIVERY_MODES: readonly ["planning-only", "mvp-first", "repair-first", "controlled-change"];
4
+ export declare const GOAL_CRITERION_ENUMS: {
5
+ readonly build_boundary: readonly ["included", "excluded", "not-applicable"];
6
+ readonly proof_scope: readonly ["requested-full", "document-deliverable", "expected-negative"];
7
+ readonly expected_outcome: readonly ["pass", "fail"];
8
+ readonly source_binding: readonly ["declared", "current-protected"];
9
+ readonly candidate_binding: readonly ["declared", "current-protected"];
10
+ };
4
11
  export declare function hasGoalCommandAliasConflict(values: Record<string, unknown>): boolean;
5
12
  /** Shared root fields are defaults too. Explicit defaults and then per-criterion
6
13
  * fields take precedence; no commands, identifiers or criteria are rewritten. */
7
14
  export declare function goalDeclarationDefaults(object: Record<string, unknown>): Record<string, unknown>;
15
+ export interface GoalDeclarationFieldDiagnostic {
16
+ readonly pointer: string;
17
+ readonly expected: string;
18
+ }
19
+ /** Validate structured planner fields before immutable controls exist; expansion itself remains lossless. */
20
+ export declare function goalDeclarationFieldDiagnostics(object: Record<string, unknown>): readonly GoalDeclarationFieldDiagnostic[];
8
21
  export declare const GOAL_DECLARATION_FORMAT: string;
9
22
  /** Normalize a shared declaration without guessing acceptance, coverage, delivery, or validation. */
10
23
  export declare function expandGoalDeclaration(value: unknown): string;
@@ -2,6 +2,13 @@ import { goalFingerprint } from "./goal-bound.js";
2
2
  /** Shared planner guidance and admission enums; no inferred acceptance or dispatch authority. */
3
3
  export const GOAL_DELIVERY_INTENTS = ["design", "registration", "implementation", "repair", "controlled-change"];
4
4
  export const GOAL_DELIVERY_MODES = ["planning-only", "mvp-first", "repair-first", "controlled-change"];
5
+ export const GOAL_CRITERION_ENUMS = {
6
+ build_boundary: ["included", "excluded", "not-applicable"],
7
+ proof_scope: ["requested-full", "document-deliverable", "expected-negative"],
8
+ expected_outcome: ["pass", "fail"],
9
+ source_binding: ["declared", "current-protected"],
10
+ candidate_binding: ["declared", "current-protected"],
11
+ };
5
12
  const CRITERION_FIELDS = ["criterion_id", "target", "entrypoint", "workload", "oracle_coverage", "build_boundary", "source",
6
13
  "candidate", "source_binding", "candidate_binding", "fixture", "proof_scope", "expected_outcome", "validation_command"];
7
14
  export function hasGoalCommandAliasConflict(values) {
@@ -23,6 +30,67 @@ export function goalDeclarationDefaults(object) {
23
30
  ? object.defaults : {};
24
31
  return { ...normalizedCriterionFields(object), ...normalizedCriterionFields(defaults) };
25
32
  }
33
+ /** Validate structured planner fields before immutable controls exist; expansion itself remains lossless. */
34
+ export function goalDeclarationFieldDiagnostics(object) {
35
+ const defects = new Map();
36
+ const add = (pointer, expected) => { defects.set(pointer, { pointer, expected }); };
37
+ const scalar = (value) => typeof value === "string" ? value.trim()
38
+ : typeof value === "boolean" || typeof value === "number" ? String(value) : undefined;
39
+ const singleLine = (value) => {
40
+ const text = scalar(value);
41
+ return text !== undefined && text.length > 0 && !/[\r\n]/u.test(String(value));
42
+ };
43
+ for (const [field, allowed, required] of [
44
+ ["delivery_intent", GOAL_DELIVERY_INTENTS, true], ["delivery_mode", GOAL_DELIVERY_MODES, false],
45
+ ["usable_path_established", ["true", "false"], true], ["controlled_change", ["true", "false"], true],
46
+ ]) {
47
+ if ((required || object[field] !== undefined) && (!singleLine(object[field]) || !allowed.some(item => item === scalar(object[field])))) {
48
+ add(`/goal_declaration/${field}`, allowed.join(" | "));
49
+ }
50
+ }
51
+ if (object.goal_acceptance_fingerprint !== undefined &&
52
+ !/^sha256:[a-f0-9]{64}$/u.test(String(object.goal_acceptance_fingerprint))) {
53
+ add("/goal_declaration/goal_acceptance_fingerprint", "sha256: followed by 64 lowercase hexadecimal characters");
54
+ }
55
+ const defaults = object.defaults !== null && typeof object.defaults === "object" && !Array.isArray(object.defaults)
56
+ ? object.defaults : undefined;
57
+ const criteria = Array.isArray(object.criteria) ? object.criteria : [];
58
+ for (const [index, raw] of criteria.entries()) {
59
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
60
+ continue;
61
+ const criterion = raw;
62
+ const fieldValue = (field) => {
63
+ for (const [owner, prefix] of [[criterion, `/goal_declaration/criteria/${index}`],
64
+ [defaults, "/goal_declaration/defaults"], [object, "/goal_declaration"]]) {
65
+ if (!owner)
66
+ continue;
67
+ for (const key of [`goal_${field}`, field])
68
+ if (owner[key] !== undefined && owner[key] !== null) {
69
+ return { value: owner[key], pointer: `${prefix}/${key}` };
70
+ }
71
+ }
72
+ return { value: undefined, pointer: `/goal_declaration/${defaults ? "defaults/" : ""}${field}` };
73
+ };
74
+ for (const field of ["target", "entrypoint", "workload", "source", "candidate", "fixture"]) {
75
+ const { value, pointer } = fieldValue(field);
76
+ if (!singleLine(value))
77
+ add(pointer, "nonblank single-line scalar");
78
+ }
79
+ for (const [field, allowed] of Object.entries(GOAL_CRITERION_ENUMS)) {
80
+ const { value, pointer } = fieldValue(field);
81
+ if (value === undefined && (field === "source_binding" || field === "candidate_binding"))
82
+ continue;
83
+ if (!singleLine(value) || !allowed.some(item => item === scalar(value)))
84
+ add(pointer, allowed.join(" | "));
85
+ }
86
+ const oracle = fieldValue("oracle_coverage");
87
+ if (!Array.isArray(oracle.value) || oracle.value.length === 0 ||
88
+ !oracle.value.every(item => typeof item === "string" && item.length > 0 && item.length <= 512) ||
89
+ new Set(oracle.value).size !== oracle.value.length)
90
+ add(oracle.pointer, "nonempty unique string array; each string has 1..512 characters");
91
+ }
92
+ return [...defects.values()];
93
+ }
26
94
  export const GOAL_DECLARATION_FORMAT = `Declare a goal once, then reference it with goal_declaration_path in the Task prompt.
27
95
  The referenced JSON may contain shared defaults and a criteria array; the host expands them privately.
28
96
  An inline ext["sortie-dogs/goal-declaration"] in the registered handoff is also supported.
@@ -8,6 +8,7 @@ export declare const OPERATOR_PROPOSAL_BUDGET_CAPS: Readonly<{
8
8
  max_reads: 192;
9
9
  max_submissions: 24;
10
10
  }>;
11
+ export declare const OPERATOR_PROPOSAL_REVISION_CONTRACT: string;
11
12
  export declare const OPERATOR_APPROVAL_CONTRACT: string;
12
13
  export interface OperatorIntentRequirement {
13
14
  readonly id: string;
@@ -90,6 +91,23 @@ export interface OperatorProposalState {
90
91
  proposal_hash: string | null;
91
92
  proposal: OperatorProposalPacket | null;
92
93
  approval_rationale: string | null;
94
+ /** Preparation may publish execution controls before approval commits; never revise that pinned plan. */
95
+ approval_started?: boolean;
96
+ root_revisions?: OperatorProposalRevision[];
97
+ }
98
+ interface OperatorProposalIdentity {
99
+ readonly proposal_id: string;
100
+ readonly revision: number;
101
+ readonly content_hash: string;
102
+ }
103
+ export interface OperatorProposalRevision {
104
+ readonly actor: string;
105
+ readonly at: string;
106
+ readonly rationale: string;
107
+ readonly from: OperatorProposalIdentity;
108
+ readonly to: OperatorProposalIdentity;
109
+ readonly patch_hash: string;
110
+ readonly paths: readonly string[];
93
111
  }
94
112
  export interface OperatorProposalBudgetDiagnostic {
95
113
  readonly status: "proposal-budget-exhausted";
@@ -112,15 +130,17 @@ export declare class OperatorProposalBudgetError extends Error {
112
130
  /** Durable pre-execution lane. It grants investigation and submission, never source mutation or execution. */
113
131
  export declare class OperatorProposalRuntime {
114
132
  readonly profile: RuntimeProfile;
115
- private readonly states;
116
133
  private readonly pending;
117
134
  private serial;
135
+ private locked;
118
136
  readonly projectRoot: string;
119
137
  constructor(projectRoot: string, profile: RuntimeProfile);
120
138
  private file;
121
139
  private spendFile;
122
140
  private readSpend;
123
141
  private saveSpend;
142
+ /** A released investigation still belongs to its accepted order across a host restart. No grant is revived. */
143
+ continuationCheckpoint(root: string): Promise<string | undefined>;
124
144
  read(root: string): Promise<OperatorProposalState | undefined>;
125
145
  private readUnlocked;
126
146
  private save;
@@ -146,6 +166,11 @@ export declare class OperatorProposalRuntime {
146
166
  submit(root: string, actor: string, raw: unknown): Promise<OperatorProposalState>;
147
167
  submitJSON(root: string, actor: string, source: string): Promise<OperatorProposalState>;
148
168
  private submitUnlocked;
169
+ /** A read-free root correction of an ended child's submitted packet, never a new investigation grant. */
170
+ reviseJSON(root: string, actor: string, source: string): Promise<OperatorProposalState>;
171
+ private patchedPacket;
172
+ /** Close revision before any prepare/register side effect, including when that later step fails. */
173
+ pinApproval(root: string, raw: unknown): Promise<void>;
149
174
  approve(root: string, raw: unknown, commit?: boolean): Promise<OperatorProposalState>;
150
175
  private approveUnlocked;
151
176
  /**
@@ -159,3 +184,4 @@ export declare class OperatorProposalRuntime {
159
184
  private requiredUnlocked;
160
185
  packet(state: OperatorProposalState): unknown;
161
186
  }
187
+ export {};
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
4
  import { normalizeRelativePath } from "./path.js";
5
5
  import { OperatorContractError, parseOperatorPlan } from "./operator-runtime.js";
@@ -10,6 +10,18 @@ const ORIGINAL_REQUEST_MAX_CHARACTERS = 128 * 1024;
10
10
  const ORIGINAL_REQUEST_MAX_BYTES = 128 * 1024;
11
11
  export const DEFAULT_OPERATOR_PROPOSAL_BUDGET = Object.freeze({ max_reads: 45, max_submissions: 9 });
12
12
  export const OPERATOR_PROPOSAL_BUDGET_CAPS = Object.freeze({ max_reads: 192, max_submissions: 24 });
13
+ export const OPERATOR_PROPOSAL_REVISION_CONTRACT = "revision_json must encode exactly {proposal_id:string,revision:positive integer,content_hash:string,rationale:nonblank single-line string,patches:array}. " +
14
+ "Pin the current submitted identity from operator_status. patches contains 1..32 {op,path,value} objects. " +
15
+ "replace is allowed only at /coverage, /existing_surface, /uncovered, /negative_handling, /read_scope, /plan/units, /plan/acceptance_proof, " +
16
+ "/plan/units/<index>/{title,objective,read,validation,acceptance_indices}; add or replace is also allowed at " +
17
+ "/plan/goal_declaration/criteria/<index>/{validation_command,goal_validation_command}. No duplicate or overlapping paths. " +
18
+ "Read spellings may normalize separators and dot segments without traversal; read authority can only shrink. " +
19
+ "The exact write union, ordered acceptance, source_refs, intent, authoritative_refs, goal binding, goal defaults, criterion identities, git_lifecycle and budgets are immutable. " +
20
+ "Unit count cannot increase. The host derives the next revision, hash and budget_estimate, validates the complete packet, and records root-patch provenance atomically. " +
21
+ "Each authorized attempt, including malformed input or rejected patches, consumes one remaining submission; foreign/stale calls do not spend the live grant. No reads or execution units are granted or restored. " +
22
+ "Approval preparation or approval closes this revision lane. Revision never restarts a child, prepares a run, or approves anything. " +
23
+ "Compare every original requirement again before explicit approval with the new identity. Use only already observed evidence. " +
24
+ "Keep root-owned push/global apply and user-only acceptance pending until separately evidenced; worker tests never prove those obligations. Unrepresentable or unobserved requirements stay uncovered.";
13
25
  const OPERATOR_APPROVAL_IDENTITY_FIELDS = ["proposal_id", "revision", "content_hash", "compared_requirement_ids", "decision"];
14
26
  const OPERATOR_APPROVAL_RATIONALE_FIELDS = ["rationale", "comparison_rationale"];
15
27
  export const OPERATOR_APPROVAL_CONTRACT = "approval_json must encode one JSON object using exactly one six-field shape. " +
@@ -287,10 +299,9 @@ function parsePacket(value, state) {
287
299
  /** Durable pre-execution lane. It grants investigation and submission, never source mutation or execution. */
288
300
  export class OperatorProposalRuntime {
289
301
  profile;
290
- states = new Map();
291
302
  pending = new Map();
292
303
  async serial(root, action) {
293
- const result = (this.pending.get(root) ?? Promise.resolve()).then(action);
304
+ const result = (this.pending.get(root) ?? Promise.resolve()).then(() => this.locked(root, action));
294
305
  const tail = result.then(() => undefined, () => undefined);
295
306
  this.pending.set(root, tail);
296
307
  try {
@@ -301,6 +312,31 @@ export class OperatorProposalRuntime {
301
312
  this.pending.delete(root);
302
313
  }
303
314
  }
315
+ async locked(root, action) {
316
+ await mkdir(join(this.projectRoot, this.profile.stateDirectory, "operator-proposals"), { recursive: true });
317
+ const lock = `${this.file(root)}.lock`;
318
+ let handle;
319
+ for (let attempt = 0; attempt < 100; attempt++) {
320
+ try {
321
+ handle = await open(lock, "wx", 0o600);
322
+ break;
323
+ }
324
+ catch (error) {
325
+ if (error.code !== "EEXIST")
326
+ throw error;
327
+ await new Promise(resolve => setTimeout(resolve, 10));
328
+ }
329
+ }
330
+ if (!handle)
331
+ throw new Error("operator-proposal-state-busy");
332
+ try {
333
+ return await action();
334
+ }
335
+ finally {
336
+ await handle.close();
337
+ await rm(lock, { force: true });
338
+ }
339
+ }
304
340
  projectRoot;
305
341
  constructor(projectRoot, profile) {
306
342
  this.profile = profile;
@@ -324,6 +360,13 @@ export class OperatorProposalRuntime {
324
360
  !Number.isSafeInteger(spend.reads) || spend.reads < 0 || !Number.isSafeInteger(spend.submissions) || spend.submissions < 0) {
325
361
  throw new Error("operator-proposal-spend-invalid");
326
362
  }
363
+ if (spend.retry !== undefined && (!record(spend.retry) || !/^[a-f0-9]{64}$/u.test(spend.retry.intent_hash) ||
364
+ !record(spend.retry.goal_binding) || !text(spend.retry.goal_binding.goal_id) ||
365
+ !Number.isSafeInteger(spend.retry.goal_binding.revision) || spend.retry.goal_binding.revision < 1 ||
366
+ !Number.isSafeInteger(spend.retry.goal_binding.scope_epoch) || spend.retry.goal_binding.scope_epoch < 1 ||
367
+ !/^sha256:[a-f0-9]{64}$/u.test(spend.retry.goal_binding.acceptance_fingerprint))) {
368
+ throw new Error("operator-proposal-spend-invalid");
369
+ }
327
370
  return spend;
328
371
  }
329
372
  async saveSpend(spend) {
@@ -338,13 +381,23 @@ export class OperatorProposalRuntime {
338
381
  await rm(temporary, { force: true }).catch(() => undefined);
339
382
  }
340
383
  }
384
+ /** A released investigation still belongs to its accepted order across a host restart. No grant is revived. */
385
+ async continuationCheckpoint(root) {
386
+ return this.serial(root, async () => {
387
+ const state = await this.readUnlocked(root);
388
+ if (state?.phase === "approved")
389
+ return undefined;
390
+ const retry = state?.goal_binding ? { intent_hash: state.intent_hash, goal_binding: state.goal_binding }
391
+ : state ? undefined : (await this.readSpend(root)).retry;
392
+ return retry === undefined ? undefined : JSON.stringify({ authority: "durable-proposal-continuity",
393
+ root_session_id: root, ...retry, status: state?.phase ?? "cancelled",
394
+ next_action: "Preserve the accepted goal and cumulative spend. Read operator_status; a cancelled proposal requires an explicit root retry decision and a new exact proposal Task, never revival of its old child." });
395
+ });
396
+ }
341
397
  async read(root) {
342
398
  return this.serial(root, () => this.readUnlocked(root));
343
399
  }
344
400
  async readUnlocked(root) {
345
- const cached = this.states.get(root);
346
- if (cached)
347
- return structuredClone(cached);
348
401
  let source;
349
402
  try {
350
403
  source = await readFile(this.file(root), "utf8");
@@ -376,8 +429,7 @@ export class OperatorProposalRuntime {
376
429
  else {
377
430
  throw new Error("operator-proposal-state-invalid");
378
431
  }
379
- this.states.set(root, state);
380
- return structuredClone(state);
432
+ return state;
381
433
  }
382
434
  async save(state) {
383
435
  const directory = join(this.projectRoot, this.profile.stateDirectory, "operator-proposals");
@@ -390,7 +442,6 @@ export class OperatorProposalRuntime {
390
442
  finally {
391
443
  await rm(temporary, { force: true }).catch(() => undefined);
392
444
  }
393
- this.states.set(state.root_session_id, structuredClone(state));
394
445
  }
395
446
  assertBudgetAvailable(state) {
396
447
  const remainingReads = state.intent.proposal_budget.max_reads - state.read_count;
@@ -489,7 +540,8 @@ export class OperatorProposalRuntime {
489
540
  const spend = await this.readSpend(root);
490
541
  const state = { schema_version: "0.1", profile: this.profile.id, root_session_id: root,
491
542
  intent_id: `intent-${intentHash.slice(0, 24)}`, intent_hash: intentHash, intent, created_at: new Date().toISOString(), phase: "investigating",
492
- goal_binding: null, proposal_call_id: null, proposal_session_id: null, read_count: spend.reads, read_paths: [], submission_count: spend.submissions, proposal_id: null,
543
+ goal_binding: spend.retry?.intent_hash === intentHash ? structuredClone(spend.retry.goal_binding) : null,
544
+ proposal_call_id: null, proposal_session_id: null, read_count: spend.reads, read_paths: [], submission_count: spend.submissions, proposal_id: null,
493
545
  ...(spend.reads > 0 || spend.submissions > 0 ? { prior_spend: { reads: spend.reads, submissions: spend.submissions } } : {}),
494
546
  proposal_revision: null, proposal_hash: null, proposal: null, approval_rationale: null };
495
547
  this.assertBudgetAvailable(state);
@@ -617,6 +669,132 @@ export class OperatorProposalRuntime {
617
669
  await this.save(state);
618
670
  return state;
619
671
  }
672
+ /** A read-free root correction of an ended child's submitted packet, never a new investigation grant. */
673
+ async reviseJSON(root, actor, source) {
674
+ return this.serial(root, async () => {
675
+ if (root !== actor)
676
+ throw new Error("operator-proposal-revision-root-required");
677
+ const state = await this.requiredUnlocked(root);
678
+ if (state.phase !== "submitted" || !state.proposal || state.approval_started)
679
+ throw new Error("operator-proposal-not-revisable");
680
+ if (state.submission_count >= state.intent.proposal_budget.max_submissions)
681
+ throw new Error("operator-proposal-submission-budget-exhausted");
682
+ let request, candidate;
683
+ try {
684
+ if (Buffer.byteLength(source) > 128 * 1024)
685
+ proposalError("/", "operator-proposal-revision-too-large", "maxBytes");
686
+ let raw;
687
+ try {
688
+ raw = JSON.parse(source);
689
+ }
690
+ catch {
691
+ proposalError("/", "operator-proposal-revision-json-invalid", "json");
692
+ }
693
+ if (!record(raw) || !exactSet(raw, ["proposal_id", "revision", "content_hash", "rationale", "patches"]) ||
694
+ !text(raw.proposal_id) || !Number.isSafeInteger(raw.revision) || !text(raw.content_hash) ||
695
+ !text(raw.rationale) || raw.rationale.length > 2000 || !Array.isArray(raw.patches) ||
696
+ raw.patches.length < 1 || raw.patches.length > 32) {
697
+ return proposalError("/", "operator-proposal-revision-invalid", "exact-identity-rationale-bounded-patches");
698
+ }
699
+ request = raw;
700
+ if (raw.proposal_id !== state.proposal_id || raw.revision !== state.proposal_revision || raw.content_hash !== state.proposal_hash) {
701
+ throw new Error("operator-proposal-revision-identity-mismatch");
702
+ }
703
+ candidate = this.patchedPacket(state, raw.patches);
704
+ }
705
+ catch (error) {
706
+ if (error instanceof Error && error.message === "operator-proposal-revision-identity-mismatch")
707
+ throw error;
708
+ // Rejections retain the old body/identity; only the same finite attempt counter advances.
709
+ state.submission_count++;
710
+ await this.save(state);
711
+ throw error;
712
+ }
713
+ const from = { proposal_id: state.proposal_id, revision: state.proposal_revision, content_hash: state.proposal_hash };
714
+ state.submission_count++;
715
+ state.proposal = candidate;
716
+ state.proposal_revision = candidate.revision;
717
+ state.proposal_hash = hash(JSON.stringify(candidate));
718
+ state.proposal_id = proposalID(state.intent_id, candidate.revision, state.proposal_hash);
719
+ (state.root_revisions ??= []).push({ actor, at: new Date().toISOString(), rationale: request.rationale,
720
+ from, to: { proposal_id: state.proposal_id, revision: candidate.revision, content_hash: state.proposal_hash },
721
+ patch_hash: hash(JSON.stringify(request.patches)), paths: request.patches.map(patch => patch.path) });
722
+ await this.save(state);
723
+ return state;
724
+ });
725
+ }
726
+ patchedPacket(state, patches) {
727
+ const before = state.proposal;
728
+ const draft = structuredClone(before);
729
+ const paths = [];
730
+ for (const [index, patch] of patches.entries()) {
731
+ const pointer = `/patches/${index}`;
732
+ if (!record(patch) || !exactSet(patch, ["op", "path", "value"]) || typeof patch.path !== "string") {
733
+ proposalError(pointer, "operator-proposal-revision-patch-invalid", "exact-op-path-value");
734
+ }
735
+ const entry = patch;
736
+ const command = /^\/plan\/goal_declaration\/criteria\/(0|[1-9][0-9]*)\/(validation_command|goal_validation_command)$/u.test(entry.path);
737
+ const replace = /^\/(coverage|existing_surface|uncovered|negative_handling|read_scope|plan\/(units|acceptance_proof))$/u.test(entry.path) ||
738
+ /^\/plan\/units\/(0|[1-9][0-9]*)\/(title|objective|read|validation|acceptance_indices)$/u.test(entry.path);
739
+ if (!(command && (entry.op === "add" || entry.op === "replace") || replace && entry.op === "replace") ||
740
+ paths.some(path => path === entry.path || path.startsWith(`${entry.path}/`) || entry.path.startsWith(`${path}/`))) {
741
+ proposalError(`${pointer}/path`, "operator-proposal-revision-path-denied", "nonoverlapping-allowlisted-field");
742
+ }
743
+ paths.push(entry.path);
744
+ const tokens = entry.path.slice(1).split("/");
745
+ let owner = draft;
746
+ for (const token of tokens.slice(0, -1)) {
747
+ if (owner === null || typeof owner !== "object" || !Object.hasOwn(owner, token)) {
748
+ proposalError(`${pointer}/path`, "operator-proposal-revision-path-missing", "existing-parent");
749
+ }
750
+ owner = owner[token];
751
+ }
752
+ const key = tokens.at(-1);
753
+ if (owner === null || typeof owner !== "object" || (entry.op === "replace" && !Object.hasOwn(owner, key)) ||
754
+ (entry.op === "add" && Object.hasOwn(owner, key))) {
755
+ proposalError(`${pointer}/path`, "operator-proposal-revision-path-missing", "replace-existing-or-add-absent-field");
756
+ }
757
+ owner[key] = structuredClone(entry.value);
758
+ }
759
+ const normalizeReads = (value) => Array.isArray(value) ? value.map(path => {
760
+ if (typeof path !== "string")
761
+ return path;
762
+ try {
763
+ return normalizeRelativePath(path);
764
+ }
765
+ catch {
766
+ return proposalError("/read_scope", "operator-proposal-revision-read-invalid", "safe-relative-spelling");
767
+ }
768
+ }) : value;
769
+ draft.read_scope = normalizeReads(draft.read_scope);
770
+ if (Array.isArray(draft.plan.units))
771
+ for (const unit of draft.plan.units)
772
+ if (record(unit))
773
+ unit.read = normalizeReads(unit.read);
774
+ draft.revision = before.revision + 1;
775
+ draft.budget_estimate = { proposal_reads: state.read_count, execution_units: draft.plan.units?.length };
776
+ const candidate = parsePacket(draft, state);
777
+ const within = (path, scopes) => scopes.some(scope => path === scope || path.startsWith(`${scope}/`));
778
+ if (candidate.read_scope.some(path => !within(path, before.read_scope)) ||
779
+ candidate.plan.units.flatMap(unit => unit.read).some(path => !within(path, before.plan.units.flatMap(unit => unit.read)))) {
780
+ proposalError("/read_scope", "operator-proposal-revision-read-expanded", "read-authority-only-shrinks");
781
+ }
782
+ if (candidate.plan.units.length > before.plan.units.length) {
783
+ proposalError("/plan/units", "operator-proposal-revision-units-expanded", "execution-unit-count-cannot-increase");
784
+ }
785
+ if (JSON.stringify({ ...candidate, revision: before.revision }) === JSON.stringify(before)) {
786
+ proposalError("/patches", "operator-proposal-revision-no-change", "substantive-correction-required");
787
+ }
788
+ return candidate;
789
+ }
790
+ /** Close revision before any prepare/register side effect, including when that later step fails. */
791
+ async pinApproval(root, raw) {
792
+ return this.serial(root, async () => {
793
+ const state = await this.approveUnlocked(root, raw, false);
794
+ state.approval_started = true;
795
+ await this.save(state);
796
+ });
797
+ }
620
798
  async approve(root, raw, commit = true) {
621
799
  return this.serial(root, () => this.approveUnlocked(root, raw, commit));
622
800
  }
@@ -647,9 +825,9 @@ export class OperatorProposalRuntime {
647
825
  if (!state || state.phase === "approved")
648
826
  return undefined;
649
827
  await this.saveSpend({ schema_version: "0.1", profile: this.profile.id, root_session_id: root,
650
- reads: state.read_count, submissions: state.submission_count });
828
+ reads: state.read_count, submissions: state.submission_count,
829
+ ...(state.goal_binding === null ? {} : { retry: { intent_hash: state.intent_hash, goal_binding: state.goal_binding } }) });
651
830
  await rm(this.file(root), { force: true });
652
- this.states.delete(root);
653
831
  return state;
654
832
  });
655
833
  }
@@ -671,6 +849,7 @@ export class OperatorProposalRuntime {
671
849
  remaining_reads: state.intent.proposal_budget.max_reads - state.read_count, submissions: state.submission_count,
672
850
  remaining_submissions: state.intent.proposal_budget.max_submissions - state.submission_count,
673
851
  task_admitted: state.proposal_call_id !== null, uncovered: state.proposal?.uncovered ?? [], proposal: state.proposal,
674
- approval_rationale: state.approval_rationale };
852
+ approval_rationale: state.approval_rationale, root_revisions: state.root_revisions ?? [],
853
+ revision_available: state.phase === "submitted" && !state.approval_started && state.submission_count < state.intent.proposal_budget.max_submissions };
675
854
  }
676
855
  }
@@ -4,7 +4,7 @@ import { lstat, mkdir, open, readFile, realpath, rename, rm, unlink, writeFile }
4
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { acceptanceContinuityFingerprint, inspectAcceptanceContinuity, normalizeAcceptanceCriteria, ACCEPTANCE_CONTINUITY_EXTENSION, MAX_ACCEPTANCE_CONTINUITY_BYTES, MAX_ACCEPTANCE_CRITERIA } from "./acceptance-continuity.js";
7
- import { expandGoalDeclaration, goalDeclarationDefaults, hasGoalCommandAliasConflict } from "./goal-declaration-format.js";
7
+ import { expandGoalDeclaration, goalDeclarationDefaults, goalDeclarationFieldDiagnostics, hasGoalCommandAliasConflict } from "./goal-declaration-format.js";
8
8
  import { normalizeManifestPath, normalizeRelativePath } from "./path.js";
9
9
  import { CONTRACT_TEXT_LIMITS, validateHandoffSchema, validateOperationManifestSchema } from "./validate-schema.js";
10
10
  import { validateManifest } from "./validate-manifest.js";
@@ -235,6 +235,12 @@ export function parseOperatorPlan(value) {
235
235
  return planError("/git_lifecycle/post_commit_validation", "operator-git-post-commit-validation-invalid", "canonical-declared-goal-validations-must-be-exclusive-contiguous-final-unit-suffix", "repair-proof-mapping");
236
236
  }
237
237
  }
238
+ const declarationDefects = goalDeclarationFieldDiagnostics(value.goal_declaration);
239
+ if (declarationDefects.length)
240
+ throw new OperatorContractError(declarationDefects.map(defect => ({
241
+ document: "plan", pointer: defect.pointer, code: "operator-goal-field-invalid", rule: "declared-goal-field",
242
+ repair_kind: "repair-field", repair_paths: [defect.pointer], expected: defect.expected,
243
+ })));
238
244
  expandGoalDeclaration(value.goal_declaration);
239
245
  return { ...value, acceptance: normalizeAcceptanceCriteria(value.acceptance) };
240
246
  }
@@ -574,6 +580,21 @@ export class OperatorRuntime {
574
580
  if (!record(patch) || !exactKeys(patch, ["op", "path", "value"]) || !["replace", "add"].includes(String(patch.op)) || typeof patch.path !== "string" || !Object.hasOwn(patch, "value")) {
575
581
  throw new Error("operator-repair-invalid");
576
582
  }
583
+ const goalFieldPath = /^\/goal_declaration\/(delivery_intent|delivery_mode|usable_path_established|controlled_change|goal_acceptance_fingerprint)$/.test(patch.path) ||
584
+ /^\/goal_declaration\/(?:defaults\/|criteria\/(?:0|[1-9]\d*)\/)?(?:goal_)?(?:target|entrypoint|workload|source|candidate|fixture|oracle_coverage|build_boundary|proof_scope|expected_outcome|source_binding|candidate_binding)$/.test(patch.path);
585
+ if (goalFieldPath && Array.isArray(draft.diagnostics) && draft.diagnostics.some((item) => item.document === "plan" && item.pointer === patch.path && item.code === "operator-goal-field-invalid")) {
586
+ // Only the exact invalid or missing field is editable; acceptance, valid declaration fields,
587
+ // budgets and scope never become mutable just because another field was diagnosed.
588
+ const keys = patch.path.slice(1).split("/");
589
+ let owner = draft.plan;
590
+ for (const key of keys.slice(0, -1))
591
+ owner = owner?.[key];
592
+ const key = keys.at(-1);
593
+ if (!record(owner) || (patch.op === "replace" && !Object.hasOwn(owner, key)))
594
+ throw new Error("operator-repair-path-forbidden");
595
+ owner[key] = patch.value;
596
+ continue;
597
+ }
577
598
  const readMatch = /^\/units\/(0|[1-9]\d*)\/read\/(0|[1-9]\d*)$/.exec(patch.path);
578
599
  if (readMatch) {
579
600
  const unitIndex = Number(readMatch[1]), pathIndex = Number(readMatch[2]);
@@ -13,6 +13,8 @@ export interface OpenCodePluginInput {
13
13
  childLifecycleCheckWaitMs?: number;
14
14
  /** Installed-profile adapter only; never supplied by a model or project configuration. */
15
15
  runtimeBridge?: RuntimeBridge;
16
+ /** V2 has no assistant-part update API; return the canonical panel with explicit acceptance. */
17
+ returnReportTransport?: "tool-result";
16
18
  /** The host SDK client. Absent in hosts that construct the plugin without one. */
17
19
  client?: SessionMessageReader & RunMetricsClient & ContinuationClient & OpenCodeModelAvailabilityClient & {
18
20
  app?: {
@@ -10,7 +10,7 @@ import { createHash, randomUUID } from "node:crypto";
10
10
  import { lstat, mkdir, open, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
11
11
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
12
12
  import { RUNTIME_ASSET_VERSION } from "../asset-version.js";
13
- import { GOAL_DECLARATION_FORMAT, GOAL_DELIVERY_INTENTS, GOAL_DELIVERY_MODES, expandGoalDeclaration } from "../core/goal-declaration-format.js";
13
+ import { GOAL_DECLARATION_FORMAT, GOAL_DELIVERY_INTENTS, GOAL_DELIVERY_MODES, GOAL_CRITERION_ENUMS, expandGoalDeclaration } from "../core/goal-declaration-format.js";
14
14
  import { ACCEPTANCE_CONTINUITY_AUTHORITY, ACCEPTANCE_CONTINUITY_EXTENSION, ACCEPTANCE_CONTINUITY_SCHEMA_VERSION, acceptanceContinuityFingerprint, inspectAcceptanceContinuity, normalizeAcceptanceCriteria, } from "../core/acceptance-continuity.js";
15
15
  import { resolveGlobalConfigRoot } from "../core/initialize.js";
16
16
  import { admitLunaFabric } from "../core/luna-fabric-contract.js";
@@ -1770,11 +1770,14 @@ export const SortieDogsPlugin = async (input, options) => {
1770
1770
  origin_user_message_id: ticket.origin_user_message_id,
1771
1771
  } } };
1772
1772
  }
1773
- async function terminalGoal(sessionID, stopReason, status) {
1773
+ async function terminalGoal(sessionID, stopReason, status, explicitAcceptance = false) {
1774
1774
  const ledger = await goalLedger(sessionID);
1775
1775
  const state = (await ledger.readGoal()).state;
1776
1776
  if (state.goal_id === null || state.receipt !== null || state.outstanding_reservations.length > 0 || state.acceptance_fingerprint === null)
1777
1777
  return state.receipt ?? undefined;
1778
+ if (status === "succeeded" && !explicitAcceptance &&
1779
+ await input.runtimeBridge?.requiresExplicitAcceptance?.(sessionID) === true)
1780
+ return undefined;
1778
1781
  const records = (await ledger.readGoal()).records;
1779
1782
  const settledEvidence = records.flatMap(({ event }) => event.kind === "unit.settled" || event.kind === "unit.evidence-reconciled" ? event.evidence : []);
1780
1783
  if (status === "succeeded" && (state.acceptance_contract === null ||
@@ -1857,7 +1860,8 @@ export const SortieDogsPlugin = async (input, options) => {
1857
1860
  if (receipt === undefined && delivery === "failed") {
1858
1861
  receipt = await terminalGoal(sessionID, "stopped", "stopped").catch(() => undefined);
1859
1862
  }
1860
- else if (receipt === undefined && proved) {
1863
+ else if (receipt === undefined && proved &&
1864
+ await input.runtimeBridge?.requiresExplicitAcceptance?.(sessionID) !== true) {
1861
1865
  receipt = await terminalGoal(sessionID, "completed", "succeeded").catch(() => undefined);
1862
1866
  }
1863
1867
  else if (receipt === undefined && outcome === "INTERRUPTED") {
@@ -1934,19 +1938,19 @@ export const SortieDogsPlugin = async (input, options) => {
1934
1938
  const expectedOutcome = values.goal_expected_outcome;
1935
1939
  const sourceBinding = handoffValue(entries, ["goal_source_binding"]);
1936
1940
  const candidateBinding = handoffValue(entries, ["goal_candidate_binding"]);
1937
- if (buildBoundary !== undefined && buildBoundary !== "included" && buildBoundary !== "excluded" && buildBoundary !== "not-applicable") {
1941
+ if (buildBoundary !== undefined && !GOAL_CRITERION_ENUMS.build_boundary.some(value => value === buildBoundary)) {
1938
1942
  defects.push(contractDefect("contract", pointer("goal_build_boundary"), "goal_build_boundary_invalid"));
1939
1943
  }
1940
- if (proofScope !== undefined && proofScope !== "requested-full" && proofScope !== "document-deliverable" && proofScope !== "expected-negative") {
1944
+ if (proofScope !== undefined && !GOAL_CRITERION_ENUMS.proof_scope.some(value => value === proofScope)) {
1941
1945
  defects.push(contractDefect("contract", pointer("goal_proof_scope"), "goal_proof_scope_invalid"));
1942
1946
  }
1943
- if (expectedOutcome !== undefined && expectedOutcome !== "pass" && expectedOutcome !== "fail") {
1947
+ if (expectedOutcome !== undefined && !GOAL_CRITERION_ENUMS.expected_outcome.some(value => value === expectedOutcome)) {
1944
1948
  defects.push(contractDefect("contract", pointer("goal_expected_outcome"), "goal_expected_outcome_invalid"));
1945
1949
  }
1946
- if (sourceBinding !== undefined && sourceBinding !== "declared" && sourceBinding !== "current-protected") {
1950
+ if (sourceBinding !== undefined && !GOAL_CRITERION_ENUMS.source_binding.some(value => value === sourceBinding)) {
1947
1951
  defects.push(contractDefect("contract", pointer("goal_source_binding"), "goal_source_binding_invalid"));
1948
1952
  }
1949
- if (candidateBinding !== undefined && candidateBinding !== "declared" && candidateBinding !== "current-protected") {
1953
+ if (candidateBinding !== undefined && !GOAL_CRITERION_ENUMS.candidate_binding.some(value => value === candidateBinding)) {
1950
1954
  defects.push(contractDefect("contract", pointer("goal_candidate_binding"), "goal_candidate_binding_invalid"));
1951
1955
  }
1952
1956
  const declaredValidation = handoffValue(entries, ["validation"]);
@@ -7721,7 +7725,7 @@ export const SortieDogsPlugin = async (input, options) => {
7721
7725
  throw new Error("operator-goal-stopped");
7722
7726
  // Same proof/freshness/reservation gate used by terminal text, requested
7723
7727
  // explicitly by the root instead of inferred from a model's wording.
7724
- const receipt = goal.receipt ?? await terminalGoal(sessionID, "completed", "succeeded");
7728
+ const receipt = goal.receipt ?? await terminalGoal(sessionID, "completed", "succeeded", true);
7725
7729
  if (receipt?.status !== "succeeded")
7726
7730
  return { status: "awaiting-evidence" };
7727
7731
  // Acceptance must return its receipt to the active tool call. Revoking
@@ -1,6 +1,6 @@
1
1
  import { V010_RUNTIME_ASSET_VERSION } from "../asset-version.js";
2
2
  import { OperatorContractError, OperatorRuntime } from "../core/operator-runtime.js";
3
- import { DEFAULT_OPERATOR_PROPOSAL_BUDGET, OPERATOR_APPROVAL_CONTRACT, OPERATOR_PROPOSAL_BUDGET_CAPS, OperatorProposalBudgetError, OperatorProposalRuntime } from "../core/operator-proposal.js";
3
+ import { DEFAULT_OPERATOR_PROPOSAL_BUDGET, OPERATOR_APPROVAL_CONTRACT, OPERATOR_PROPOSAL_BUDGET_CAPS, OPERATOR_PROPOSAL_REVISION_CONTRACT, OperatorProposalBudgetError, OperatorProposalRuntime } from "../core/operator-proposal.js";
4
4
  import { CANONICAL_AGENT_ROLES, canonicalAgent, profileAgent, profileTool, V010_RUNTIME_PROFILE } from "../core/runtime-profile.js";
5
5
  import { SortieDogsPlugin as canonicalPlugin } from "./index.js";
6
6
  import { taskChildSessionID } from "./task-result-repair.js";
@@ -8,7 +8,7 @@ import { relative, resolve, sep } from "node:path";
8
8
  import { readFile, realpath } from "node:fs/promises";
9
9
  import { BUILT_IN_MODEL_CATALOG } from "./model-routing.js";
10
10
  import { goalFingerprint } from "../core/goal-bound.js";
11
- import { decoratePreviewHeadings } from "./receipt-presentation.js";
11
+ import { decoratePreviewHeadings, returnReportPanel } from "./receipt-presentation.js";
12
12
  import { sanitizeTerminalReport, terminalRunOutcome } from "./run-metrics.js";
13
13
  import { normalizeCommand } from "./gate.js";
14
14
  import { normalizeRelativePath } from "../core/path.js";
@@ -266,7 +266,11 @@ export function createProfiledPlugin(profile, assetVersion) {
266
266
  });
267
267
  const runtimeBridge = {
268
268
  profile, assetVersion,
269
- continuationCheckpoint: root => operators.continuationCheckpoint(root),
269
+ continuationCheckpoint: async (root) => await operators.continuationCheckpoint(root) ?? await proposals.continuationCheckpoint(root),
270
+ requiresExplicitAcceptance: async (root) => {
271
+ const state = await operators.read(root);
272
+ return state !== undefined && state.phase !== "completed" && operatorTurnLifecycle.get(root) !== "historical";
273
+ },
270
274
  ownsCanonicalValidation: async (root, taskID, child, command) => {
271
275
  const state = await operators.read(root);
272
276
  if (state === undefined || state.phase === "cancelled" || state.phase === "completed")
@@ -391,6 +395,9 @@ export function createProfiledPlugin(profile, assetVersion) {
391
395
  const approvalSchema = record(stringSchema) && typeof stringSchema.describe === "function"
392
396
  ? stringSchema.describe(OPERATOR_APPROVAL_CONTRACT)
393
397
  : { ...(record(stringSchema) ? stringSchema : { type: "string" }), description: OPERATOR_APPROVAL_CONTRACT };
398
+ const revisionSchema = record(stringSchema) && typeof stringSchema.describe === "function"
399
+ ? stringSchema.describe(OPERATOR_PROPOSAL_REVISION_CONTRACT)
400
+ : { ...(record(stringSchema) ? stringSchema : { type: "string" }), description: OPERATOR_PROPOSAL_REVISION_CONTRACT };
394
401
  const planContract = "plan_json must encode the exact operator plan object: schema_version, acceptance, acceptance_proof, source_refs, " +
395
402
  "goal_declaration, units, and optional git_lifecycle only. git_lifecycle exact shape is " +
396
403
  '{branch_create:{branch:string,start_ref:string},commit:{message:string},post_commit_validation:string[],remediation_reserve?:string[],remediation_scope_expansion?:string[]}. Its presence is root authorization for only a clean, non-overwriting ' +
@@ -416,6 +423,7 @@ export function createProfiledPlugin(profile, assetVersion) {
416
423
  const beginProposal = profileTool(profile, "sortie_begin_operator_proposal");
417
424
  const submitProposal = profileTool(profile, "sortie_submit_operator_proposal");
418
425
  const approveProposal = profileTool(profile, "sortie_approve_operator_proposal");
426
+ const reviseProposal = profileTool(profile, "sortie_revise_operator_proposal");
419
427
  async function stop(root, reason, retireRoot = true) {
420
428
  if (retireRoot)
421
429
  retired.add(root);
@@ -486,7 +494,7 @@ export function createProfiledPlugin(profile, assetVersion) {
486
494
  await registerPreparedGoal(context.sessionID, state);
487
495
  return preparedTask(state);
488
496
  } };
489
- tools[repair] = { description: "Repair a root-owned draft with named fields, git_lifecycle branch/start/message fields, or an already-declared criterion command. A diagnosed /units/i/read/j scope error may be replaced with a normalized repository-relative spelling of the same resource only: absolute inputs require an existing relative alias whose realpath is identical; read expansion, redirection, whole-array replacement and write-scope repair are forbidden. Empty patches revalidate the saved draft without resending it. Acceptance, unit count and write scope remain fixed.",
497
+ tools[repair] = { description: "Repair a root-owned draft with named fields, git_lifecycle branch/start/message fields, or an already-declared criterion command. A diagnosed operator-goal-field-invalid may repair only its exact invalid or missing goal declaration field using the reported expected values; valid fields, budgets and whole declaration replacement remain forbidden. A diagnosed /units/i/read/j scope error may be replaced with a normalized repository-relative spelling of the same resource only: absolute inputs require an existing relative alias whose realpath is identical; read expansion, redirection, whole-array replacement and write-scope repair are forbidden. Empty patches revalidate the saved draft without resending it. Acceptance, unit count and write scope remain fixed.",
490
498
  args: { draft_id: stringSchema, patches_json: stringSchema }, execute: async (args, context) => {
491
499
  await requireRoot(context.sessionID);
492
500
  let patches;
@@ -555,7 +563,7 @@ export function createProfiledPlugin(profile, assetVersion) {
555
563
  ? "proposal submission budget exhausted; do not call operator_next; report the bounded proposal failure"
556
564
  : "proposal is not submitted; do not call operator_next; complete or repair the bounded proposal submission"
557
565
  : proposal?.phase === "submitted"
558
- ? "compare and approve the exact proposal; do not call operator_next before approval prepares a run. Approval authorizes implementation, not product acceptance. Preserve every required live measurement, consultation and user approval as pending acceptance obligations; canonical commands do not substitute for them. Use the host budget counters, not plan.goal_declaration.goal_budget_units, for remaining capacity; approval validates the retained goal binding and execution budget. A bounded diagnostic run may use cancel_operator after admission without inventing a plan validation command for cancellation."
566
+ ? `compare every original requirement with the exact proposal. For a known semantic defect, use ${reviseProposal} with its current identity and bounded field patches, then compare again; do not redispatch the ended child or cancel/reinvestigate the same defect. Approval preparation pins the plan and closes revision. Do not call operator_next before approval prepares a run. Approval authorizes implementation, not product acceptance. Preserve every required live measurement, consultation, root-owned push/global apply and user approval as pending acceptance obligations; canonical commands do not substitute for them. Use the host budget counters, not plan.goal_declaration.goal_budget_units, for remaining capacity; approval validates the retained goal binding and execution budget. A bounded diagnostic run may use cancel_operator after admission without inventing a plan validation command for cancellation.`
559
567
  : "approved proposal has no operator run; do not call operator_next; reconcile the approval or preparation failure";
560
568
  return JSON.stringify(state ? { ...await operatorPacket(state), ...(draft ? { pending_draft: draft } : {}),
561
569
  ...(proposal ? { proposal: proposalIdentity(proposal) } : {}) }
@@ -654,16 +662,27 @@ export function createProfiledPlugin(profile, assetVersion) {
654
662
  if (!["awaiting-acceptance", "completed"].includes(state.phase) || state.units.some(unit => unit.status !== "succeeded")) {
655
663
  return JSON.stringify({ status: "not-ready", packet: operators.packet(state) });
656
664
  }
657
- const goalFingerprint = await operators.completionGoalFingerprint(state);
665
+ const declarationFingerprint = await operators.completionGoalFingerprint(state);
658
666
  if (state.phase === "awaiting-acceptance") {
659
- await relinkRegisteredGoal(context.sessionID, state, goalFingerprint);
660
- await control.assertActiveGoal(context.sessionID, goalFingerprint);
667
+ await relinkRegisteredGoal(context.sessionID, state, declarationFingerprint);
668
+ await control.assertActiveGoal(context.sessionID, declarationFingerprint);
661
669
  }
662
- const result = await control.completeRoot(context.sessionID, goalFingerprint);
670
+ const result = await control.completeRoot(context.sessionID, declarationFingerprint);
663
671
  if (result.receipt)
664
672
  await operators.terminal(context.sessionID, result.receipt);
673
+ let panel;
674
+ if (input.returnReportTransport === "tool-result" && result.receipt?.status === "succeeded") {
675
+ const text = `✅ **DONE** \`${state.runID}\` — ${state.acceptance.length} acceptance requirements verified.\n\n` +
676
+ `**変更点:** ${state.units.map(unit => unit.unit.title).join("; ")}\n\n` +
677
+ `**確認結果:** PASS — ${[...new Set(state.units.flatMap(unit => unit.unit.validation))].join("; ")}\n\n**次:** なし`;
678
+ const rendered = await control.renderReturnReport(context.sessionID, text, goalFingerprint(result.receipt)).catch(() => undefined);
679
+ if (rendered)
680
+ panel = returnReportPanel(rendered);
681
+ }
665
682
  return JSON.stringify({ status: result.status, run_id: state.runID,
666
- acceptance_fingerprint: state.acceptanceFingerprint, receipt: result.receipt ?? null });
683
+ acceptance_fingerprint: state.acceptanceFingerprint, receipt: result.receipt ?? null,
684
+ ...(panel ? { return_report: panel,
685
+ return_report_instruction: "Append return_report verbatim exactly once to your final answer as Markdown, outside any code fence. It is a host-authored receipt display, not a new task or input. Do not summarize, recalculate or request another model turn for it." } : {}) });
667
686
  } };
668
687
  tools[resume] = { description: "Reconcile a finished process-defect unit from native host validation records and unchanged source. For the exact first validation-only contract-repair continuation process defect, unchanged durable controls/candidate and available validation budget permit one same-worker validation-only retry; the result includes the exact Task to invoke. Never reimplements a unit or resets implementation/goal spend.",
669
688
  args: { run_id: stringSchema, acceptance_fingerprint: stringSchema }, execute: async (args, context) => {
@@ -808,6 +827,26 @@ export function createProfiledPlugin(profile, assetVersion) {
808
827
  ...(contract ? { diagnostics: error.diagnostics, diagnostics_truncated: error.diagnostics_truncated } : {}) });
809
828
  }
810
829
  } };
830
+ tools[reviseProposal] = { description: "Root-only: revise an unapproved submitted proposal using a hash-pinned field patch and the remaining submission allowance. " + OPERATOR_PROPOSAL_REVISION_CONTRACT,
831
+ args: { revision_json: revisionSchema }, execute: async (args, context) => {
832
+ await requireRoot(context.sessionID);
833
+ return serializeDispatchTransition(context.sessionID, async () => {
834
+ try {
835
+ const revised = await proposals.reviseJSON(context.sessionID, context.sessionID, args.revision_json);
836
+ return JSON.stringify({ ...proposals.packet(revised),
837
+ next_action: "Compare all original ordered requirements again. Explicitly approve only this new identity if every obligation is genuinely covered; no execution or approval was granted by revision." });
838
+ }
839
+ catch (error) {
840
+ const contract = error instanceof OperatorContractError;
841
+ if (!contract && (!(error instanceof Error) || !error.message.startsWith("operator-proposal-")))
842
+ throw error;
843
+ const state = await proposals.read(context.sessionID);
844
+ return JSON.stringify({ status: "invalid-revision", code: error.message,
845
+ ...(state ? { proposal: proposalIdentity(state) } : {}),
846
+ ...(contract ? { diagnostics: error.diagnostics, diagnostics_truncated: error.diagnostics_truncated } : {}) });
847
+ }
848
+ });
849
+ } };
811
850
  tools[approveProposal] = { description: "Root-only: record semantic comparison of the exact proposal revision/hash, then connect its immutable plan to the existing execution lane. " + OPERATOR_APPROVAL_CONTRACT,
812
851
  args: { approval_json: approvalSchema }, execute: async (args, context) => {
813
852
  await requireRoot(context.sessionID);
@@ -837,6 +876,7 @@ export function createProfiledPlugin(profile, assetVersion) {
837
876
  if (approved.goal_binding === null)
838
877
  throw new Error("operator-proposal-goal-binding-missing");
839
878
  await control.assertProposalExecutionBudget(context.sessionID, approved.goal_binding, proposal.plan.units.length);
879
+ await proposals.pinApproval(context.sessionID, approval);
840
880
  const prepared = await operators.propose(context.sessionID, proposal.plan);
841
881
  if (prepared.status !== "prepared")
842
882
  throw new Error("operator-proposal-approved-plan-invalid");
@@ -846,7 +886,7 @@ export function createProfiledPlugin(profile, assetVersion) {
846
886
  });
847
887
  } };
848
888
  const ownTools = new Set([prepare, repair, next, status, cancel, complete, resume, resolveContractRepair,
849
- beginProposal, submitProposal, approveProposal]);
889
+ beginProposal, submitProposal, approveProposal, reviseProposal]);
850
890
  const protocolMap = CANONICAL_AGENT_ROLES.map(role => `${role}=${profileAgent(profile, role)}`).join(", ");
851
891
  function rememberRendered(key, text) {
852
892
  renderedParts.set(key, goalFingerprint(text));
@@ -978,8 +1018,9 @@ export function createProfiledPlugin(profile, assetVersion) {
978
1018
  output.parts.some(part => record(part) && part.type === "text" && typeof part.text === "string" && part.text.trim().length > 0);
979
1019
  if (realTurn) {
980
1020
  const state = await operators.read(chat.sessionID);
981
- if ((state !== undefined && (state.phase === "cancelled" || state.phase === "completed")) ||
982
- operatorTurnLifecycle.get(chat.sessionID) === "cancelled") {
1021
+ if (((state !== undefined && (state.phase === "cancelled" || state.phase === "completed")) ||
1022
+ operatorTurnLifecycle.get(chat.sessionID) === "cancelled") &&
1023
+ await runtimeBridge.continuationCheckpoint(chat.sessionID) === undefined) {
983
1024
  operatorTurnLifecycle.set(chat.sessionID, "historical");
984
1025
  const messageID = typeof chat.messageID === "string" && chat.messageID.length > 0
985
1026
  ? chat.messageID
@@ -1,4 +1,6 @@
1
1
  import type { GoalTerminalReceipt } from "../core/goal-bound.js";
2
+ /** Extract only the host card, excluding the terminal prose that follows it. */
3
+ export declare function returnReportPanel(rendered: string): string | undefined;
2
4
  /** Cosmetic headings only; no acceptance or execution state is inferred. */
3
5
  export declare function decoratePreviewHeadings(text: string): string;
4
6
  /** Only a host-verified current receipt may supply a missing success heading. */
@@ -1,4 +1,13 @@
1
1
  import { terminalRunOutcome } from "./run-metrics.js";
2
+ /** Extract only the host card, excluding the terminal prose that follows it. */
3
+ export function returnReportPanel(rendered) {
4
+ const summary = rendered.indexOf("<summary><strong>🐾 SORTIE DOGS — 帰還報告");
5
+ if (summary < 0)
6
+ return undefined;
7
+ const start = rendered.lastIndexOf("<details", summary);
8
+ const end = rendered.indexOf("\n</details>", summary);
9
+ return start < 0 || end < 0 ? undefined : rendered.slice(start, end + "\n</details>".length).trim();
10
+ }
2
11
  /** Cosmetic headings only; no acceptance or execution state is inferred. */
3
12
  export function decoratePreviewHeadings(text) {
4
13
  const icons = { "変更点": "🔧", "確認結果": "🔍", "次": "➡️", changes: "🔧", validation: "🔍", next: "➡️" };
@@ -25,6 +25,7 @@ export interface RuntimeBridge {
25
25
  readonly defaultModelCatalog?: import("./model-routing.js").ModelCatalog;
26
26
  transformConfiguration?(value: unknown): unknown;
27
27
  continuationCheckpoint?(rootSessionID: string): Promise<string | undefined>;
28
+ requiresExplicitAcceptance?(rootSessionID: string): Promise<boolean>;
28
29
  ownsCanonicalValidation?(rootSessionID: string, unitID: string, childSessionID: string, command: string): Promise<boolean>;
29
30
  onSerialSettlement?(settlement: SerialDispatchSettlement): Promise<void>;
30
31
  onRootTerminal?(rootSessionID: string, receipt: GoalTerminalReceipt): Promise<void>;
@@ -1,5 +1,4 @@
1
1
  import type { OpenCodeHooks, OpenCodePlugin } from "./index.js";
2
- export declare const V2_RETURN_REPORT_METADATA_KEY = "sortie-dogs/return-report";
3
2
  type JsonObject = Record<string, unknown>;
4
3
  type Registration = {
5
4
  dispose(): Promise<void> | void;
@@ -33,6 +32,7 @@ export interface OpenCodeV2Context {
33
32
  hook(name: "execute.before" | "execute.after", callback: (event: JsonObject) => Promise<void> | void): Promise<Registration>;
34
33
  };
35
34
  readonly session: {
35
+ list?(input: JsonObject): Promise<unknown>;
36
36
  get(input: {
37
37
  sessionID: string;
38
38
  }): Promise<unknown>;
@@ -46,6 +46,9 @@ export interface OpenCodeV2Context {
46
46
  switchModel(input: JsonObject): Promise<unknown>;
47
47
  hook(name: "prompt" | "context" | "compaction", callback: (event: JsonObject) => Promise<void> | void): Promise<Registration>;
48
48
  };
49
+ readonly message?: {
50
+ list(input: JsonObject): Promise<unknown>;
51
+ };
49
52
  readonly permission: {
50
53
  hook(name: "evaluate", callback: (event: JsonObject) => Promise<void> | void): Promise<Registration>;
51
54
  };
@@ -60,8 +63,8 @@ export interface OpenCodeV2Plugin {
60
63
  readonly id: string;
61
64
  setup(context: OpenCodeV2Context): Promise<(() => void) | void>;
62
65
  }
63
- /** V2 has no public assistant-part update API; publish one durable, non-resuming synthetic report card instead. */
64
- export declare function createV2ReturnReportPublisher(context: OpenCodeV2Context, hooks: OpenCodeHooks): (session: string) => Promise<void>;
66
+ /** Observe terminal text for accounting only. Synthetic input is queued work, even with resume=false. */
67
+ export declare function createV2ReturnReportFinalizer(context: OpenCodeV2Context, hooks: OpenCodeHooks): (session: string) => Promise<void>;
65
68
  export declare function createSortieDogsV2Plugin(legacyFactory?: OpenCodePlugin): OpenCodeV2Plugin;
66
69
  export declare const SortieDogsV2Plugin: OpenCodeV2Plugin;
67
70
  export default SortieDogsV2Plugin;
package/dist/plugin/v2.js CHANGED
@@ -1,7 +1,4 @@
1
- import { createHash } from "node:crypto";
2
1
  import { SortieDogsV010Plugin } from "./profiled.js";
3
- const RETURN_REPORT_MARKER = "<summary><strong>🐾 SORTIE DOGS — 帰還報告";
4
- export const V2_RETURN_REPORT_METADATA_KEY = "sortie-dogs/return-report";
5
2
  function record(value) {
6
3
  return value !== null && typeof value === "object" && !Array.isArray(value);
7
4
  }
@@ -49,8 +46,17 @@ function legacyPart(value, session, message) {
49
46
  return { ...value, sessionID: session, messageID: message };
50
47
  const state = record(value.state) ? value.state : {};
51
48
  const output = toolContentText(state.content);
52
- return { ...value, tool: string(value.name) ?? string(value.tool), callID: string(value.id), sessionID: session, messageID: message,
53
- state: { ...state, ...(output === undefined ? {} : { output }) } };
49
+ const name = string(value.name) ?? string(value.tool);
50
+ const tool = name === "subagent" ? "task" : name === "shell" ? "bash" : name === "patch" ? "apply_patch" : name;
51
+ const time = record(value.time) ? value.time : undefined;
52
+ const metadata = record(state.metadata) ? { ...state.metadata } : undefined;
53
+ if (metadata && Array.isArray(metadata.files))
54
+ metadata.files = metadata.files.map(file => record(file) && typeof file.patch === "string"
55
+ ? { ...file, diff: file.patch } : file);
56
+ return { ...value, tool, callID: string(value.id), sessionID: session, messageID: message,
57
+ state: { ...state, input: legacyToolInput(name, state.input), ...(metadata ? { metadata } : {}),
58
+ ...(time ? { time: { start: time.ran ?? time.created, end: time.completed } } : {}),
59
+ ...(output === undefined ? {} : { output }) } };
54
60
  }
55
61
  function legacyMessage(value, session, agent) {
56
62
  if (!record(value) || typeof value.id !== "string" || typeof value.type !== "string")
@@ -62,13 +68,15 @@ function legacyMessage(value, session, agent) {
62
68
  }
63
69
  if (value.type !== "assistant") {
64
70
  if (value.type === "synthetic")
65
- return { info: { id, sessionID: session, role: "system", time: value.time },
71
+ return { info: { id, sessionID: session, role: "user", agent, time: value.time },
66
72
  parts: [{ id: `${id}-text`, sessionID: session, messageID: id, type: "text", text: String(value.text ?? ""), synthetic: true }] };
67
73
  return undefined;
68
74
  }
69
75
  const model = modelReference(value.model);
70
76
  return { info: { id, sessionID: session, role: "assistant", agent: string(value.agent) ?? agent,
71
- ...(model === undefined ? {} : { model }), finish: value.finish, time: value.time, tokens: value.tokens, cost: value.cost },
77
+ ...(model === undefined ? {} : { model, providerID: model.providerID, modelID: model.modelID }),
78
+ ...(record(value.providerState) && typeof value.providerState.serviceTier === "string" ? { serviceTier: value.providerState.serviceTier } : {}),
79
+ ...(value.error === undefined ? {} : { error: value.error }), finish: value.finish, time: value.time, tokens: value.tokens, cost: value.cost },
72
80
  parts: array(value.content).flatMap(part => {
73
81
  const converted = legacyPart(part, session, id);
74
82
  return converted === undefined ? [] : [converted];
@@ -77,7 +85,8 @@ function legacyMessage(value, session, agent) {
77
85
  async function legacyMessages(context, id) {
78
86
  const [info, history] = await Promise.all([
79
87
  context.session.get({ sessionID: id }).catch(() => undefined),
80
- context.session.context({ sessionID: id }).catch(() => []),
88
+ context.message ? nativePages(cursor => context.message.list({ sessionID: id, limit: 100, ...(cursor ? { cursor } : { order: "asc" }) }))
89
+ : context.session.context({ sessionID: id }),
81
90
  ]);
82
91
  const agent = record(info) ? string(info.agent) : undefined;
83
92
  return array(history).flatMap(value => {
@@ -85,6 +94,24 @@ async function legacyMessages(context, id) {
85
94
  return converted === undefined ? [] : [converted];
86
95
  });
87
96
  }
97
+ async function nativePages(fetch) {
98
+ const items = [], seen = new Set();
99
+ let cursor;
100
+ for (let page = 0; page < 100; page += 1) {
101
+ const result = await fetch(cursor);
102
+ if (!record(result) || !Array.isArray(result.data) || !record(result.cursor))
103
+ throw new Error("v2-history-page-invalid");
104
+ items.push(...result.data);
105
+ const next = string(result.cursor.next);
106
+ if (!next)
107
+ return items;
108
+ if (seen.has(next))
109
+ throw new Error("v2-history-cursor-repeated");
110
+ seen.add(next);
111
+ cursor = next;
112
+ }
113
+ throw new Error("v2-history-page-limit");
114
+ }
88
115
  function legacyClient(context) {
89
116
  const directory = context.location.directory;
90
117
  const session = {
@@ -101,7 +128,13 @@ function legacyClient(context) {
101
128
  return { data: id === undefined || wanted === undefined ? undefined
102
129
  : (await legacyMessages(context, id)).find(message => record(message.info) && message.info.id === wanted) };
103
130
  },
104
- children: async () => ({ data: [] }),
131
+ ...(context.session.list ? { children: async (request) => {
132
+ const id = sessionID(request);
133
+ if (id === undefined)
134
+ throw new Error("v2-history-parent-missing");
135
+ return { data: await nativePages(cursor => context.session.list({ parentID: id, limit: 100,
136
+ ...(cursor ? { cursor } : { order: "asc" }) })) };
137
+ } } : {}),
105
138
  abort: async (request) => {
106
139
  const id = sessionID(request);
107
140
  return id === undefined ? undefined : await context.session.interrupt({ sessionID: id, resume: false });
@@ -185,24 +218,8 @@ function assistantText(message) {
185
218
  return record(text) && typeof text.text === "string" ? text.text.trim() : undefined;
186
219
  }
187
220
  const V2_SUBAGENT_PROMPT_PREFIX = "You are a subagent spawned by another session.\n";
188
- function reportPanel(rendered) {
189
- const summary = rendered.indexOf(RETURN_REPORT_MARKER);
190
- if (summary < 0)
191
- return undefined;
192
- const details = rendered.lastIndexOf("<details", summary);
193
- return rendered.slice(details < 0 ? summary : details).trim();
194
- }
195
- function reportIdentity(session, sourceMessageID, panel) {
196
- return createHash("sha256").update(`${session}\0${sourceMessageID}\0${panel}`).digest("hex");
197
- }
198
- function reportMetadata(message) {
199
- if (!record(message) || message.type !== "synthetic" || !record(message.metadata))
200
- return undefined;
201
- const value = message.metadata[V2_RETURN_REPORT_METADATA_KEY];
202
- return record(value) ? value : undefined;
203
- }
204
- /** V2 has no public assistant-part update API; publish one durable, non-resuming synthetic report card instead. */
205
- export function createV2ReturnReportPublisher(context, hooks) {
221
+ /** Observe terminal text for accounting only. Synthetic input is queued work, even with resume=false. */
222
+ export function createV2ReturnReportFinalizer(context, hooks) {
206
223
  const active = new Map();
207
224
  return async (session) => {
208
225
  const previous = active.get(session) ?? Promise.resolve();
@@ -217,15 +234,6 @@ export function createV2ReturnReportPublisher(context, hooks) {
217
234
  const base = assistantText(source);
218
235
  const output = { text: base };
219
236
  await hooks["experimental.text.complete"]?.({ sessionID: session, messageID: source.id }, output);
220
- const panel = reportPanel(output.text);
221
- if (panel === undefined)
222
- return;
223
- const identity = reportIdentity(session, source.id, panel);
224
- if (history.some(message => reportMetadata(message)?.identity === identity))
225
- return;
226
- await context.session.synthetic({ sessionID: session, id: `sortie-report-${identity}`, text: panel,
227
- description: "Sortie Dogs return report", delivery: "queue", resume: false,
228
- metadata: { [V2_RETURN_REPORT_METADATA_KEY]: { schema_version: "0.1", identity, source_message_id: source.id } } });
229
237
  return;
230
238
  }
231
239
  }).finally(() => { if (active.get(session) === operation)
@@ -311,9 +319,10 @@ export function createSortieDogsV2Plugin(legacyFactory = SortieDogsV010Plugin) {
311
319
  return {
312
320
  id: "sortie-dogs.v010",
313
321
  async setup(context) {
314
- const hooks = await legacyFactory({ directory: context.location.directory, client: legacyClient(context) }, context.options ?? {});
322
+ const hooks = await legacyFactory({ directory: context.location.directory, client: legacyClient(context),
323
+ returnReportTransport: "tool-result" }, context.options ?? {});
315
324
  await registerV2Hooks(context, hooks);
316
- const publish = createV2ReturnReportPublisher(context, hooks);
325
+ const finalize = createV2ReturnReportFinalizer(context, hooks);
317
326
  const controller = new AbortController();
318
327
  void (async () => {
319
328
  for await (const event of context.event.subscribe({ signal: controller.signal })) {
@@ -327,7 +336,7 @@ export function createSortieDogsV2Plugin(legacyFactory = SortieDogsV010Plugin) {
327
336
  if (id === undefined)
328
337
  continue;
329
338
  if (event.type === "session.execution.succeeded")
330
- await publish(id);
339
+ await finalize(id);
331
340
  if (event.type === "session.created" || event.type === "session.deleted" || event.type === "session.idle") {
332
341
  const info = event.type === "session.created" ? await context.session.get({ sessionID: id }).catch(() => ({ id })) : { id };
333
342
  await hooks.event?.({ event: { type: String(event.type), properties: { sessionID: id, info } } });
@@ -23,6 +23,8 @@ Retain the canonical product's game-like user guidance and icons. Role separatio
23
23
  Use the user's language for prose, concise icon-led plan/progress/evidence blocks, and the canonical terminal heading
24
24
  after the host accepts the result. The host supplies the 🐾 return report, mission/proof, cost/pack and career panels;
25
25
  do not fabricate scores, counts, medals or success, and do not suppress these panels as redundant decoration.
26
+ When complete_operator returns return_report, append that host-authored Markdown verbatim exactly once to the existing
27
+ final answer, outside any code fence. Do not calculate its values or turn the panel into another task or model call.
26
28
 
27
29
  ${canonicalFixture("READABLE_OUTPUT_FIXTURE")}
28
30
  `;
@@ -166,7 +168,19 @@ report that specific limitation for a scope/design decision instead of inventing
166
168
  The proposal must map every original requirement ID to approach and validation, explicitly list uncovered IDs, preserve
167
169
  negative-condition handling, and include exact read/write/unit/GoalDeclaration plans and budget estimates. Its summary is
168
170
  not the source of truth and proposal reads are not acceptance evidence. Compare the submitted proposal directly with the
169
- original request. Only the root may call ${profile.toolPrefix}approve_operator_proposal with the exact proposal_id,
171
+ original request. For a known defect in a submitted, unapproved proposal, the root may call
172
+ ${profile.toolPrefix}revise_operator_proposal with revision_json containing the current proposal_id, revision, content_hash,
173
+ a substantive rationale, and 1..32 allowlisted {op,path,value} patches. Follow its exact schema; do not resend the full packet.
174
+ Correct coverage/uncovered, observed surface claims, negative handling, unit/milestone/proof mapping, or shrink/normalize reads
175
+ using only the completed investigation's evidence. Ordered requirements/acceptance, references, goal binding, budgets,
176
+ criterion identities/defaults, Git authority and the exact write union stay fixed; execution unit count cannot increase.
177
+ Successful and invalid patch attempts share the remaining submission allowance. Revision grants no new read, child or worker
178
+ and requires no live proposal child. The host atomically records provenance and derives the next revision/hash.
179
+ Approval preparation closes revision even if preparation later fails. Preserve that pinned plan for approval recovery.
180
+ Never claim worker tests prove root-owned push/global apply or user-only acceptance; keep those obligations explicitly pending
181
+ until their actual evidence exists. If existing contracts cannot express a required obligation, keep it uncovered and report
182
+ the exact limitation instead of substituting a passing test. A root patch is not acceptance or automatic approval.
183
+ Compare every original requirement again after revision. Only the root may call ${profile.toolPrefix}approve_operator_proposal with the exact proposal_id,
170
184
  revision, content_hash, ordered compared_requirement_ids, decision=approve, and a substantive comparison rationale.
171
185
  Uncovered requirements, stale hashes/revisions, another root, widened scope, rewritten acceptance, or reset proposal
172
186
  accounting must be rejected. Hash matching records identity; it never replaces this semantic root decision. Approval alone
@@ -219,7 +233,9 @@ directly (fast path); a larger plan returns ${operator}. Do not launch another i
219
233
  Do not manually reconstruct, translate, shorten, or append to that returned prompt. Do not add task_id to resume a
220
234
  fresh dispatch. If the exact Task is no longer in context, call operator_next once to retrieve it rather than guess.
221
235
  If prepare reports an invalid plan, repair only the reported JSON pointer using its code and repair_kind; preserve the
222
- original objective, acceptance, and unit count. Do not regenerate the full plan or send placeholder/probe plans. If it reports
236
+ original objective, acceptance, and unit count. For operator-goal-field-invalid, use the exact expected enum or required
237
+ field value at the diagnosed pointer; add a missing field without replacing defaults, criteria, budgets or the whole declaration.
238
+ Do not regenerate the full plan or send placeholder/probe plans. If it reports
223
239
  an immutable active contract, read operator_status and reuse the existing next Task. Cancel only when an actual scope
224
240
  change or explicit stop requires it; do not cancel/recreate an unchanged plan to work around an admission error.
225
241
  Cancellation does not close the accepted user order. A replacement repair plan must keep the exact previous ordered
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.10.18",
3
+ "version": "0.10.20",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",