sortie-dogs 0.10.0 → 0.10.2

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
@@ -59,9 +59,13 @@ See [configuration details](#configuration) for model selection and other setup
59
59
  > **Project status: Beta.** v0.10.x is under active stabilization. Runtime
60
60
  > behavior, configuration, and runtime assets may still change before 1.0.
61
61
 
62
- [![npm](https://img.shields.io/npm/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
63
- [![license](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
64
- [![Node.js](https://img.shields.io/node/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
62
+ [![GitHub Release](https://img.shields.io/github/v/release/zufall-upon/Sortie-dogs)](https://github.com/zufall-upon/Sortie-dogs/releases/latest)
63
+ [![npm](https://img.shields.io/npm/v/sortie-dogs?label=npm)](https://www.npmjs.com/package/sortie-dogs)
64
+ [![Tests](https://github.com/zufall-upon/Sortie-dogs/actions/workflows/test.yml/badge.svg)](https://github.com/zufall-upon/Sortie-dogs/actions/workflows/test.yml)
65
+ [![OpenCode Plugin](https://img.shields.io/badge/OpenCode-Plugin-5C5CFF)](https://opencode.ai/)
66
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
67
+ [![Node.js](https://img.shields.io/node/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
68
+ [![MIT License](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
65
69
 
66
70
  ![Sortie-dogs coordinating a bounded implementation workflow](https://raw.githubusercontent.com/zufall-upon/Sortie-dogs/main/docs/assets/sortie-workflow.gif)
67
71
 
@@ -70,7 +74,7 @@ bounded implementation, canonical validation, and evidence-backed completion.
70
74
 
71
75
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [テスト実行](docs/testing.md) · [CLI testing](docs/cli-testing.md)
72
76
 
73
- Release: [v0.10.0](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.10.0)
77
+ Release: [v0.10.2](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.10.2)
74
78
 
75
79
  ## Latest local benchmark case study
76
80
 
@@ -88,38 +92,50 @@ Run configuration was fixed per product configuration:
88
92
  children on `openai/gpt-5.6-sol` / `medium`, with the pinned Sortie package and runtime assets.
89
93
  No Luna, Astra, or Opus messages were observed in these trials.
90
94
 
91
- | Metric · one frozen task | Bare OpenCode | Sortie v0.9.12 | Sortie v0.10.0 clean qualification |
92
- | --- | ---: | ---: | ---: |
93
- | Attempts needed | 3 | 5 | 1 |
94
- | Completed runs compared | 3 | 3 | 1 |
95
- | Verified PASS | 0/3 | 0/3 | 0/1 |
96
- | Task checks · F2P | 11.1% · 3/27 | 85.2% · 23/27 | 88.9% · 8/9 |
97
- | Retained checks · P2P | 282/282 | 282/282 | 94/94 |
98
- | Median agent wall | 24.5 min | 25.7 min | 29.9 min · n=1 |
99
- | Median model steps | 43 | 39 | 33 · CLI stream only |
100
- | Implementation child sessions · total | 0 | 13 | 4 |
101
- | Estimated API-equivalent cost · median completed run | $3.53 | $2.85 | **$3.94** · n=1 |
102
- | Estimated cost · completed runs | $10.69 | $9.74 | **$3.94** |
103
- | Additional interrupted-attempt cost | $0 | $6.20 | $0 |
104
- | Total cost to acquire completed runs | $10.69 | $15.94 | **$3.94** |
95
+ | Metric · one frozen task | Bare OpenCode | Sortie v0.9.12 | Sortie v0.10.1 RC qualification |
96
+ | --- | ---: | ---: | ---: |
97
+ | Attempts needed | 3 | 5 | 1 |
98
+ | Completed runs compared | 3 | 3 | 1 |
99
+ | Verified PASS | 0/3 | 0/3 | 0/1 |
100
+ | Task checks · F2P | 11.1% · 3/27 | 85.2% · 23/27 | 88.9% · 8/9 |
101
+ | Retained checks · P2P | 282/282 | 282/282 | 94/94 |
102
+ | Median agent wall | 24.5 min | 25.7 min | 23.2 min · n=1 |
103
+ | Median model steps | 43 | 39 | 41 · CLI stream only |
104
+ | Implementation child sessions · total | 0 | 13 | 3 |
105
+ | Estimated API-equivalent cost · median completed run | $3.53 | $2.85 | **$4.48** · n=1 |
106
+ | Estimated cost · completed runs | $10.69 | $9.74 | **$4.48** |
107
+ | Additional interrupted-attempt cost · same fixed snapshot | $0 | $6.20 | $0 |
108
+ | Total cost to acquire completed runs · same fixed snapshot | $10.69 | $15.94 | **$4.48** |
105
109
 
106
110
  All three Bare runs passed 1/9 task checks. The three completed Sortie runs passed 7/9, 8/9,
107
111
  and 8/9. Every compared candidate retained 94/94 prior checks, but every official verifier still
108
112
  returned reward 0. The two interrupted Sortie attempts are excluded from completed-run quality,
109
113
  time, and cost aggregates; their attempt count and estimated cost remain visible above.
110
114
 
111
- The v0.10 value is a standalone qualification-only reference, not a fourth matched run or a
112
- FrontierHarness leaderboard result. It used no fresh Bare control and a localized Docker-free
113
- verifier. Its estimated API-equivalent cost is **$3.94**: $3.26 from Sol and $0.68 from Terra,
114
- calculated from the root and descendant session tokens with 100% pricing coverage. A preceding
115
- debug run returned reward 1 (F2P 9/9, P2P 94/94), so that pass is not presented as reproducible.
115
+ The v0.10.1 RC value is a standalone qualification-only reference, not a fourth matched run or a
116
+ FrontierHarness leaderboard result. It used no fresh Bare control and a localized Docker-free
117
+ verifier. The RC label identifies an unreleased working-tree snapshot; the benchmark tarball's
118
+ package metadata remained `0.10.0`, fixed by package SHA-256
119
+ `67582ee023c3bd57813262e292c067a3f90f30d61c01d8caf6f91f09ae78f61d`.
120
+
121
+ Its estimated API-equivalent cost is **$4.478613**: $3.23 Sol and $1.25 Terra when rounded to cents.
122
+ The audit prices all root and descendant assistant messages: 84 priced requests, 4,215,698 tokens,
123
+ 100% pricing coverage, and no unpriced requests. It uses the product's 2026-09-14 Standard schedule
124
+ per request, including cached input, reasoning as output, and the long-context rate band. The earlier
125
+ delivery-incomplete package was a different source snapshot (`32dea0a3…`): it spent **$4.276578**
126
+ across 90 priced requests and 3,704,649 tokens, with no terminal outcome or verifier. It is not folded
127
+ into this RC's same-snapshot acquisition cost. Observed spend across both distinct snapshots was
128
+ **$8.755191**, which is development spend rather than this RC's acquisition cost. Historical Bare and
129
+ v0.9.12 costs retain their 2026-07-30 schedule; their cost cells are not same-rate comparisons with
130
+ this RC estimate.
116
131
 
117
132
  ![Latest local case study: Bare completed 11.1 percent of task checks at a median estimated API-equivalent cost of $3.53; Sortie completed 85.2 percent at $2.85. Sortie needed five attempts and $15.94 to collect three completed runs. Neither configuration achieved a Verified PASS.](docs/assets/quality-cost-reference.svg)
118
133
 
119
- Cost uses exported root and child session tokens, grouped by the model that produced each message,
120
- with a fixed standard short-context rate schedule. Completed-run cost shows execution efficiency;
121
- total acquisition cost includes the two interrupted Sortie attempts and shows reliability overhead.
122
- These are API-equivalent estimates, not invoices.
134
+ Cost audits use deduplicated root and descendant session tokens, grouped by the model that produced
135
+ each message. Historical values retain their frozen 2026-07-30 short-context schedule; the RC uses
136
+ the product's 2026-09-14 per-request schedule described above. Completed-run cost shows execution
137
+ efficiency; same-snapshot acquisition cost adds interrupted attempts. These are API-equivalent
138
+ estimates, not invoices.
123
139
 
124
140
  The product objective is **more verified outcomes per unit of cost and time without weakening the
125
141
  accepted goal**. This small, single-task local case study does not establish that claim, isolate
@@ -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-ref1-proposal1-review-remediation1-surface3";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.10.0-v0912-language4-cost-rpt10-compaction-ref1-proposal1-review-remediation1-surface3-proposal-recovery2-quality1-terminal1";
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-ref1-proposal1-review-remediation1-surface3";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.10.0-v0912-language4-cost-rpt10-compaction-ref1-proposal1-review-remediation1-surface3-proposal-recovery2-quality1-terminal1";
@@ -95,6 +95,9 @@ export declare class OperatorProposalRuntime {
95
95
  readonly projectRoot: string;
96
96
  constructor(projectRoot: string, profile: RuntimeProfile);
97
97
  private file;
98
+ private spendFile;
99
+ private readSpend;
100
+ private saveSpend;
98
101
  read(root: string): Promise<OperatorProposalState | undefined>;
99
102
  private readUnlocked;
100
103
  private save;
@@ -117,9 +120,16 @@ export declare class OperatorProposalRuntime {
117
120
  accountRead(root: string, actor: string, path?: string): Promise<OperatorProposalState>;
118
121
  private accountReadUnlocked;
119
122
  submit(root: string, actor: string, raw: unknown): Promise<OperatorProposalState>;
123
+ submitJSON(root: string, actor: string, source: string): Promise<OperatorProposalState>;
120
124
  private submitUnlocked;
121
125
  approve(root: string, raw: unknown, commit?: boolean): Promise<OperatorProposalState>;
122
126
  private approveUnlocked;
127
+ /**
128
+ * Release a pre-approval proposal grant on explicit root cancellation. Without this the root holds an
129
+ * immutable investigating intent whose admitted child can no longer bind, so no retry path exists at all.
130
+ * An approved proposal is already connected to the execution lane and stays durable.
131
+ */
132
+ discardPreApproval(root: string): Promise<OperatorProposalState | undefined>;
123
133
  required(root: string): Promise<OperatorProposalState>;
124
134
  assertExecutionApproved(root: string, planHash: string): Promise<void>;
125
135
  private requiredUnlocked;
@@ -120,23 +120,63 @@ function parseIntent(value) {
120
120
  function parsePacket(value, state) {
121
121
  if (Buffer.byteLength(JSON.stringify(value)) > 128 * 1024)
122
122
  throw new Error("operator-proposal-too-large");
123
- if (!record(value) || !exact(value, ["schema_version", "revision", "coverage", "existing_surface", "uncovered", "negative_handling", "read_scope", "write_scope", "budget_estimate", "plan"])) {
124
- return proposalError("/", "operator-proposal-invalid", "proposal-fields");
125
- }
126
- if (value.schema_version !== "0.1")
127
- return proposalError("/schema_version", "operator-proposal-invalid", "schema-version", { repair_paths: ["/schema_version"] });
128
- if (!Number.isSafeInteger(value.revision) || value.revision < 1) {
129
- return proposalError("/revision", "operator-proposal-revision-invalid", "positive-integer", { repair_paths: ["/revision"], expected: "positive-integer", actual_type: jsonType(value.revision) });
130
- }
131
- if (!Array.isArray(value.coverage) || !Array.isArray(value.uncovered) || !Array.isArray(value.negative_handling) ||
132
- !Array.isArray(value.read_scope) || !value.read_scope.every(normalizedRelativePath) ||
133
- !Array.isArray(value.write_scope) || !value.write_scope.every(normalizedRelativePath) || !record(value.budget_estimate) ||
134
- !exact(value.budget_estimate, ["proposal_reads", "execution_units"]) || !Number.isSafeInteger(value.budget_estimate.proposal_reads) || !Number.isSafeInteger(value.budget_estimate.execution_units)) {
135
- return proposalError("/", "operator-proposal-invalid", "proposal-fields");
123
+ if (!record(value))
124
+ return proposalError("/", "operator-proposal-invalid", "object", { expected: "object", actual_type: jsonType(value) });
125
+ const diagnostics = [];
126
+ const invalid = (pointer, rule, expected, actual, code = "operator-proposal-invalid") => {
127
+ diagnostics.push({ document: "proposal", pointer, code, rule, repair_kind: "repair-field",
128
+ repair_paths: [pointer], expected, actual_type: jsonType(actual) });
129
+ };
130
+ const fields = ["schema_version", "revision", "coverage", "existing_surface", "uncovered", "negative_handling", "read_scope", "write_scope", "budget_estimate", "plan"];
131
+ for (const key of Object.keys(value).filter(key => !fields.includes(key)).sort()) {
132
+ invalid(`/${pointerToken(key)}`, "unknown-field", "remove-unknown-field", value[key]);
133
+ }
134
+ for (const key of fields)
135
+ if (!Object.hasOwn(value, key))
136
+ invalid(`/${key}`, "required", "required-field", undefined);
137
+ if (Object.hasOwn(value, "schema_version") && value.schema_version !== "0.1") {
138
+ invalid("/schema_version", "schema-version", 'literal-"0.1"', value.schema_version);
139
+ }
140
+ if (Object.hasOwn(value, "revision") && (!Number.isSafeInteger(value.revision) || value.revision < 1)) {
141
+ invalid("/revision", "positive-integer", "positive-integer", value.revision, "operator-proposal-revision-invalid");
142
+ }
143
+ for (const key of ["coverage", "existing_surface", "uncovered", "negative_handling", "read_scope", "write_scope"]) {
144
+ if (!Object.hasOwn(value, key))
145
+ continue;
146
+ const items = value[key];
147
+ if (!Array.isArray(items)) {
148
+ invalid(`/${key}`, "array", "array", items);
149
+ continue;
150
+ }
151
+ if (key === "read_scope" || key === "write_scope")
152
+ items.forEach((item, index) => {
153
+ if (!normalizedRelativePath(item))
154
+ invalid(`/${key}/${index}`, "normalized-relative-path", "nonempty repository-relative path; forward slashes; no trailing slash, dot segments, traversal, or absolute path", item);
155
+ });
156
+ }
157
+ if (Object.hasOwn(value, "budget_estimate")) {
158
+ const budget = value.budget_estimate;
159
+ if (!record(budget))
160
+ invalid("/budget_estimate", "object", "object", budget);
161
+ else {
162
+ const keys = ["proposal_reads", "execution_units"];
163
+ for (const key of Object.keys(budget).filter(key => !keys.includes(key)).sort()) {
164
+ invalid(`/budget_estimate/${pointerToken(key)}`, "unknown-field", "remove-unknown-field", budget[key]);
165
+ }
166
+ for (const key of keys)
167
+ if (!Number.isSafeInteger(budget[key])) {
168
+ invalid(`/budget_estimate/${key}`, Object.hasOwn(budget, key) ? "integer" : "required", "integer", budget[key]);
169
+ }
170
+ }
136
171
  }
172
+ if (diagnostics.length > 0)
173
+ throw new OperatorContractError(diagnostics);
174
+ // The shape checks above are batched for repair; semantic checks below remain strict.
175
+ const coverage = value.coverage, uncovered = value.uncovered, negatives = value.negative_handling;
176
+ const budget = value.budget_estimate;
137
177
  const requirementIDs = new Set(state.intent.requirements.map(item => item.id));
138
178
  const coverageIDs = new Set(), uncoveredIDs = new Set();
139
- for (const item of value.coverage) {
179
+ for (const item of coverage) {
140
180
  if (!record(item) || !exact(item, ["requirement_id", "approach", "validation"]) || !identifier(item.requirement_id) ||
141
181
  !requirementIDs.has(item.requirement_id) || coverageIDs.has(item.requirement_id) || !text(item.approach) || !text(item.validation))
142
182
  throw new Error("operator-proposal-coverage-invalid");
@@ -159,7 +199,7 @@ function parsePacket(value, state) {
159
199
  }
160
200
  if ([...coverageIDs].some(id => !surfaced.has(id)))
161
201
  throw new Error("operator-proposal-existing-surface-incomplete");
162
- for (const item of value.uncovered) {
202
+ for (const item of uncovered) {
163
203
  if (!record(item) || !exact(item, ["requirement_id", "reason"]) || !identifier(item.requirement_id) || !requirementIDs.has(item.requirement_id) ||
164
204
  coverageIDs.has(item.requirement_id) || uncoveredIDs.has(item.requirement_id) || !text(item.reason))
165
205
  throw new Error("operator-proposal-uncovered-invalid");
@@ -169,7 +209,7 @@ function parsePacket(value, state) {
169
209
  throw new Error("operator-proposal-requirement-coverage-incomplete");
170
210
  const negativeIDs = new Set(state.intent.requirements.filter(item => item.kind === "negative").map(item => item.id));
171
211
  const handled = new Set();
172
- for (const item of value.negative_handling) {
212
+ for (const item of negatives) {
173
213
  if (!record(item) || !exact(item, ["requirement_id", "handling"]) || !identifier(item.requirement_id) || !negativeIDs.has(item.requirement_id) || handled.has(item.requirement_id) || !text(item.handling))
174
214
  throw new Error("operator-proposal-negative-handling-invalid");
175
215
  handled.add(item.requirement_id);
@@ -204,9 +244,17 @@ function parsePacket(value, state) {
204
244
  if (plan.acceptance.length !== acceptance.length || plan.acceptance.some((item, index) => item !== acceptance[index]))
205
245
  throw new Error("operator-proposal-acceptance-rewritten");
206
246
  const writes = [...new Set(plan.units.flatMap(unit => unit.write))].sort();
207
- if (JSON.stringify([...new Set(value.write_scope)].sort()) !== JSON.stringify(writes))
208
- throw new Error("operator-proposal-write-scope-mismatch");
209
- if (value.budget_estimate.proposal_reads !== state.read_count || value.budget_estimate.execution_units !== plan.units.length)
247
+ if (JSON.stringify([...new Set(value.write_scope)].sort()) !== JSON.stringify(writes)) {
248
+ proposalError("/write_scope", "operator-proposal-write-scope-mismatch", "exact-union-of-unit-write-scopes", { repair_paths: ["/write_scope", "/plan/units"] });
249
+ }
250
+ for (const [unitIndex, unit] of plan.units.entries())
251
+ for (const [pathIndex, path] of unit.read.entries()) {
252
+ const executionReads = [...value.read_scope, ...plan.units.slice(0, unitIndex + 1).flatMap(item => item.write)];
253
+ if (!executionReads.some(scope => path === scope || path.startsWith(`${scope}/`))) {
254
+ proposalError(`/plan/units/${unitIndex}/read/${pathIndex}`, "operator-proposal-unit-read-scope-expanded", "unit-input-within-declared-read-scope-or-generated-write-scope");
255
+ }
256
+ }
257
+ if (budget.proposal_reads !== state.read_count || budget.execution_units !== plan.units.length)
210
258
  throw new Error("operator-proposal-budget-estimate-mismatch");
211
259
  return { ...structuredClone(materialized), plan };
212
260
  }
@@ -233,6 +281,37 @@ export class OperatorProposalRuntime {
233
281
  this.projectRoot = resolve(projectRoot);
234
282
  }
235
283
  file(root) { return join(this.projectRoot, this.profile.stateDirectory, "operator-proposals", `${hash(root)}.json`); }
284
+ spendFile(root) { return join(this.projectRoot, this.profile.stateDirectory, "operator-proposals", `${hash(root)}.spend.json`); }
285
+ async readSpend(root) {
286
+ let source;
287
+ try {
288
+ source = await readFile(this.spendFile(root), "utf8");
289
+ }
290
+ catch (error) {
291
+ if (error.code === "ENOENT") {
292
+ return { schema_version: "0.1", profile: this.profile.id, root_session_id: root, reads: 0, submissions: 0 };
293
+ }
294
+ throw error;
295
+ }
296
+ const spend = JSON.parse(source);
297
+ if (spend.schema_version !== "0.1" || spend.profile !== this.profile.id || spend.root_session_id !== root ||
298
+ !Number.isSafeInteger(spend.reads) || spend.reads < 0 || !Number.isSafeInteger(spend.submissions) || spend.submissions < 0) {
299
+ throw new Error("operator-proposal-spend-invalid");
300
+ }
301
+ return spend;
302
+ }
303
+ async saveSpend(spend) {
304
+ const directory = join(this.projectRoot, this.profile.stateDirectory, "operator-proposals");
305
+ await mkdir(directory, { recursive: true });
306
+ const temporary = `${this.spendFile(spend.root_session_id)}.${randomUUID()}.tmp`;
307
+ try {
308
+ await writeFile(temporary, JSON.stringify(spend), { flag: "wx", mode: 0o600 });
309
+ await rename(temporary, this.spendFile(spend.root_session_id));
310
+ }
311
+ finally {
312
+ await rm(temporary, { force: true }).catch(() => undefined);
313
+ }
314
+ }
236
315
  async read(root) {
237
316
  return this.serial(root, () => this.readUnlocked(root));
238
317
  }
@@ -304,6 +383,11 @@ export class OperatorProposalRuntime {
304
383
  "Git mutationが明示要件の時だけplan.git_lifecycleを追加する。exact shape: {\"branch_create\":{\"branch\":\"validated destination branch\",\"start_ref\":\"existing commit/ref\"},\"commit\":{\"message\":\"single-line commit message\"},\"post_commit_validation\":[\"exact declared validation command\"]}。post_commit_validationは既存canonical command identityでgoal criterion commandと一致し、final unit.validationの同順・連続suffixでなければならない。全non-post validationを先に実行し、全source write完了後にsuffixを実行する。hostはworker spend前にclean root、existing start_ref、nonexisting destinationを検証し、fixed argvでbranch作成する。suffix先頭command直前に全unit.write union内変更を明示stageしてcommitし、以後のsource writeを拒否する。既存validation executorが各post-commit commandのfresh evidenceを記録し、全evidenceなしのroot acceptanceは禁止。任意Git command、git add -A、commit -a、amend、force、push、既存branch上書きは禁止。git_lifecycle省略時のGit mutationはない。",
305
384
  "acceptance_indicesは0-based。各unitのacceptance_indicesに置く全indexについて、そのacceptance_proof[index]が参照するcriterionのresolved validation_commandを同じunit.validationへ文字列完全一致で最低1個含める。範囲外index禁止。さらに各unitはそれ以前のunitにないgoal criterion validation_commandを少なくとも1個validationへ含める。同じvalidation commandだけを全unitで再利用禁止。順序付き2 unitなら例としてunit 1に中間状態を許すcommand、unit 2に最終commandを割り当てる。",
306
385
  "existing_surfaceは各covered requirementにつき最低1件。対象codebaseで、その要件が制約・変更する既存構文や既存経路を実際にReadして特定し、そのfileのproject相対pathと見つけた形(form)を書く。formは文面の言い換えでなく、observedした構文形・node種別・dispatch分岐を書く。生成・宣言・束縛の経路と変更・代入の経路は別項目として挙げる。単数形しか要件に書かれていなくても、grammarやdispatchに複数値・分配・入れ子・暗黙形があればそれも挙げる。既存形が無いと判断した場合も、探索したfileのpathと「既存形なし」の根拠をformへ書く。pathはこのTaskで実際にReadしたfileでなければhostが拒否する。",
386
+ "調査順: authoritative_refsと既知entrypointから始める。対象pathが分かっているならdirectory巡回を挟まず直接そこへ進み、path未知の時だけ許可scope内のdirectoryやindexをReadして実在を確認する。名前を推測しただけのReadをしない。各requirementについて、それが変更する既存経路、symbol、validation oracle、build recipeを結び付ける。巨大な概念書やtest群を既定で全文読込せず、既知location・目次・周辺範囲から必要なoffset/limitを選ぶ。部分Readは探索手段であって完了条件ではない。既存分岐、関連呼出先、値の表現、負要件の影響範囲が不明なら範囲を広げ、必要ならfile全体を読む。読込量の少なさ自体を達成度にしない。authoritative Makefile、言語のgenerator directive、package/build scriptは後回しにせず関連実装経路と併せて早期に確認する。同じ内容の再読は、新しい未解決点、参照先、範囲不足、source変化のいずれかを理由とする。Read以外のtoolは要求しない。",
387
+ "記述は既存fieldで簡潔に行う。raw sourceやlogの全文再掲、同じplanの自然文による二重転記をしない。ただしcoverage、existing_surface、ordered validation、read/write、negative_handling、uncoveredは省略しない。初回提出で全requirementを扱えるproposalを目指し、調査できなかった点はuncoveredへ正直に残す。薄いproposalを出してrootへ追加調査を戻す往復を前提にしない。",
388
+ "提出前に正本commandと照合する。観測済みのexecutable、引数、test path、build target範囲をそのまま保持し、全体buildを一部targetへ縮めたり未確認の絶対executable pathを補完したりしない。検証script自身の副作用まで確認し、全生成物をunit.writeへ宣言する。unit.readはread_scopeまたは宣言済みwrite_scope内。再提出は診断fieldとactual_readsだけを直し、既に正しいcommand・scopeを再生成しない。",
389
+ "unit.validationとcriterionのvalidation_commandには実行commandだけを書く。手動GUI確認、root専有review、運用上の予算会計を説明文やラベル付きの疑似commandへ変換しない。fixture・新規Sessionの自動生成は実Backendの既存Session表示の証拠とは限らず、oracleが実際に確認する対象と副作用を照合する。現行契約で表現できない要件はuncoveredへ理由を残し、無関係なtestを全要件の証明として割り当てない。",
390
+ "status=submittedが返ったら、この調査Taskは完了。追加toolなしで親へ返す。汎用の続行指示が来てもoperator_nextやworker起動へ進まない。提出は承認でも実行許可でもない。",
307
391
  "許可sourceをReadで1回以上調査後、返答前にproposal packetをsortie_v010_submit_operator_proposalへ提出する。invalid-proposal返却時だけ、そのcodeに該当するfieldを修正する。返却されたactual_readsはhostのcanonical accountingであり、再提出時はcodeに関係なくbudget_estimate.proposal_readsをその値へ一致させる。これはread budgetをresetせず、成功/失敗tool表示からcountを推測しない。失敗したReadが一律に未計上とも仮定しない。有限submission budget内で再提出する。proseだけ返して終了禁止。未対応要件はuncoveredへ明示する。承認・実行を主張しない。"].join("\n") };
308
392
  }
309
393
  /** Root-visible bounded handle; the canonical prompt remains only in durable host state. */
@@ -357,9 +441,10 @@ export class OperatorProposalRuntime {
357
441
  throw new Error("operator-proposal-active-intent-immutable");
358
442
  throw new Error("operator-proposal-new-intent-requires-explicit-root-revision");
359
443
  }
444
+ const spend = await this.readSpend(root);
360
445
  const state = { schema_version: "0.1", profile: this.profile.id, root_session_id: root,
361
446
  intent_id: `intent-${intentHash.slice(0, 24)}`, intent_hash: intentHash, intent, created_at: new Date().toISOString(), phase: "investigating",
362
- goal_binding: null, proposal_call_id: null, proposal_session_id: null, read_count: 0, read_paths: [], submission_count: 0, proposal_id: null,
447
+ goal_binding: null, proposal_call_id: null, proposal_session_id: null, read_count: spend.reads, read_paths: [], submission_count: spend.submissions, proposal_id: null,
363
448
  proposal_revision: null, proposal_hash: null, proposal: null, approval_rationale: null };
364
449
  await this.save(state);
365
450
  return state;
@@ -448,9 +533,19 @@ export class OperatorProposalRuntime {
448
533
  return state;
449
534
  }
450
535
  async submit(root, actor, raw) {
451
- return this.serial(root, () => this.submitUnlocked(root, actor, raw));
536
+ return this.serial(root, () => this.submitUnlocked(root, actor, () => raw));
537
+ }
538
+ async submitJSON(root, actor, source) {
539
+ return this.serial(root, () => this.submitUnlocked(root, actor, () => {
540
+ try {
541
+ return JSON.parse(source);
542
+ }
543
+ catch {
544
+ return proposalError("/", "operator-proposal-json-invalid", "json");
545
+ }
546
+ }));
452
547
  }
453
- async submitUnlocked(root, actor, raw) {
548
+ async submitUnlocked(root, actor, readPacket) {
454
549
  const state = await this.requiredUnlocked(root);
455
550
  if (state.proposal_session_id !== actor || state.phase !== "investigating")
456
551
  throw new Error("operator-proposal-submit-grant-invalid");
@@ -458,7 +553,7 @@ export class OperatorProposalRuntime {
458
553
  throw new Error("operator-proposal-submission-budget-exhausted");
459
554
  let packet;
460
555
  try {
461
- packet = parsePacket(raw, state);
556
+ packet = parsePacket(readPacket(), state);
462
557
  }
463
558
  catch (error) {
464
559
  state.submission_count++;
@@ -493,6 +588,23 @@ export class OperatorProposalRuntime {
493
588
  }
494
589
  return state;
495
590
  }
591
+ /**
592
+ * Release a pre-approval proposal grant on explicit root cancellation. Without this the root holds an
593
+ * immutable investigating intent whose admitted child can no longer bind, so no retry path exists at all.
594
+ * An approved proposal is already connected to the execution lane and stays durable.
595
+ */
596
+ async discardPreApproval(root) {
597
+ return this.serial(root, async () => {
598
+ const state = await this.readUnlocked(root);
599
+ if (!state || state.phase === "approved")
600
+ return undefined;
601
+ await this.saveSpend({ schema_version: "0.1", profile: this.profile.id, root_session_id: root,
602
+ reads: state.read_count, submissions: state.submission_count });
603
+ await rm(this.file(root), { force: true });
604
+ this.states.delete(root);
605
+ return state;
606
+ });
607
+ }
496
608
  async required(root) { const state = await this.read(root); if (!state)
497
609
  throw new Error("operator-proposal-missing"); return state; }
498
610
  async assertExecutionApproved(root, planHash) {
@@ -41,6 +41,15 @@ export function operatorGitPathAuthorized(path, scopes, platform = process.platf
41
41
  }
42
42
  const contractError = (diagnostic) => { throw new OperatorContractError([diagnostic]); };
43
43
  const planError = (pointer, code, rule, repair_kind = "repair-field") => contractError({ document: "plan", pointer, code, rule, repair_kind });
44
+ function rejectValidationAnnotation(command, pointer) {
45
+ // A bare multi-character label followed by ':' and whitespace is an instruction annotation,
46
+ // not a supported executable validation. Do not interpret or strip it into a fake passing oracle.
47
+ // This is deliberately not a shell parser or a claim of semantic proof; quoted commands, drive
48
+ // paths, URLs in arguments, and shell syntax remain the executor's responsibility.
49
+ if (/^\s*[A-Za-z][A-Za-z0-9_-]*(?:\s+[A-Za-z][A-Za-z0-9_-]*)*:(?:\s|$)/u.test(command)) {
50
+ planError(pointer, "operator-validation-annotation-invalid", "executable-command-not-instruction-label");
51
+ }
52
+ }
44
53
  function strings(value, nonempty = false) {
45
54
  return Array.isArray(value) && (!nonempty || value.length > 0) && value.every(text);
46
55
  }
@@ -104,6 +113,12 @@ export function parseOperatorPlan(value) {
104
113
  return contractError({ document: "plan", pointer: `/goal_declaration/criteria/${proofCommands.size}/goal_validation_command`,
105
114
  code: "operator-goal-command-required", rule: "declared-validation-command-for-criterion", repair_kind: "repair-proof-mapping",
106
115
  repair_paths: [`/goal_declaration/criteria/${proofCommands.size}/goal_validation_command`] });
116
+ const commandOwner = raw.goal_validation_command != null || raw.validation_command != null ? raw
117
+ : record(declaration.defaults) && (declaration.defaults.goal_validation_command != null || declaration.defaults.validation_command != null)
118
+ ? declaration.defaults : declaration;
119
+ const commandPrefix = commandOwner === raw ? `/goal_declaration/criteria/${proofCommands.size}`
120
+ : commandOwner === declaration ? "/goal_declaration" : "/goal_declaration/defaults";
121
+ rejectValidationAnnotation(command, `${commandPrefix}/${commandOwner.goal_validation_command != null ? "goal_validation_command" : "validation_command"}`);
107
122
  proofCommands.set(id, command);
108
123
  }
109
124
  if (!Array.isArray(value.acceptance_proof) || value.acceptance_proof.length !== value.acceptance.length ||
@@ -119,6 +134,7 @@ export function parseOperatorPlan(value) {
119
134
  !identifier(unit.id) || ids.has(unit.id) || !text(unit.title) || !text(unit.objective) ||
120
135
  !strings(unit.read) || !strings(unit.write, true) || !strings(unit.validation, true))
121
136
  return planError(`/units/${unitIndex}`, "operator-unit-invalid", "operator-unit-shape");
137
+ unit.validation.forEach((command, index) => rejectValidationAnnotation(command, `/units/${unitIndex}/validation/${index}`));
122
138
  ids.add(unit.id);
123
139
  if (!Array.isArray(unit.acceptance_indices) || unit.acceptance_indices.length === 0 ||
124
140
  unit.acceptance_indices.some(index => !Number.isSafeInteger(index) || index < 0 || index >= acceptanceCount ||
@@ -286,8 +286,11 @@ export function createProfiledPlugin(profile, assetVersion) {
286
286
  : { ...(record(stringSchema) ? stringSchema : { type: "string" }), description: OPERATOR_INTENT_CONTRACT };
287
287
  const proposalContract = "proposal_json must encode one JSON object with exactly these required fields and types: " +
288
288
  'schema_version string "0.1"; revision positive integer, for example "revision":1, never string "revision":"1"; ' +
289
- "coverage array of {requirement_id:string,approach:string,validation:string}; uncovered array of {requirement_id:string,reason:string}; " +
290
- "negative_handling array of {requirement_id:string,handling:string}; read_scope string array; write_scope string array; " +
289
+ "coverage array of {requirement_id:string,approach:string,validation:string}; " +
290
+ "existing_surface array of {requirement_id:string,path:string,form:string}, with at least one observed form per covered requirement and each path actually Read by this child; " +
291
+ "uncovered array of {requirement_id:string,reason:string}; " +
292
+ "negative_handling array of {requirement_id:string,handling:string}, containing only and all IDs whose intent kind is negative; " +
293
+ "read_scope string array; write_scope string array; all scope and existing_surface paths must be normalized repository-relative paths with forward slashes, no trailing slash, dot segments, traversal, or absolute paths; " +
291
294
  "budget_estimate object with proposal_reads:integer and execution_units:integer; plan object with schema_version:string, " +
292
295
  "acceptance_proof:string[][], source_refs:string[], goal_declaration:object, units:object[], and optional git_lifecycle with exact shape " +
293
296
  "Each unit.validation is the complete ordered execution list, not a tests-only list: commands required by observed authoritative Makefiles, language generator directives, or repository scripts for generation, build, formatting, and exact cleanup precede post-commit or canonical criterion tests. Put every required input in unit.read and every persistent or transient generated output in unit.write. Cleanup may remove only declared unit.write outputs; never approve arbitrary ignore rules or removal of undeclared paths. Do not guess a tool-specific command or output, claim an unobserved capability, or add a preparatory or cleanup command as a goal criterion unless it independently proves acceptance. The host preserves declared order and authority but does not statically discover or inject every build dependency or generator output. " +
@@ -329,7 +332,15 @@ export function createProfiledPlugin(profile, assetVersion) {
329
332
  await control?.stopAutomaticRecovery(root);
330
333
  const proposal = await proposals.read(root);
331
334
  if (proposal?.phase === "investigating" && proposal.proposal_call_id !== null) {
332
- await control?.settleProposalBudget(root, proposal.intent_id, proposal.proposal_call_id, "cancelled");
335
+ // The admitted proposal Task already settles this reservation when its child returns. A terminal
336
+ // settlement is final, so an explicit cancellation must release the grant instead of failing on it.
337
+ try {
338
+ await control?.settleProposalBudget(root, proposal.intent_id, proposal.proposal_call_id, "cancelled");
339
+ }
340
+ catch (error) {
341
+ if (!(error instanceof Error) || error.message !== "operator-proposal-budget-settlement-conflict")
342
+ throw error;
343
+ }
333
344
  }
334
345
  const children = await operators.interrupted(root, reason);
335
346
  for (const child of children) {
@@ -369,6 +380,14 @@ export function createProfiledPlugin(profile, assetVersion) {
369
380
  catch (error) {
370
381
  if (error instanceof OperatorContractError)
371
382
  return JSON.stringify({ status: "invalid-plan", diagnostics: error.diagnostics, diagnostics_truncated: error.diagnostics_truncated });
383
+ // An immutable active contract is a local routing defect, not an external blocker. Return the
384
+ // existing durable state and its next action so the root continues instead of retrying prepare.
385
+ if (error instanceof Error && error.message === "operator-active-contract-immutable") {
386
+ return JSON.stringify({ status: "active-contract-immutable", code: error.message,
387
+ packet: await operatorPacket(await operators.required(context.sessionID)),
388
+ next_action: `This root already owns an immutable active contract. Do not resend a plan or cancel an unchanged contract: ` +
389
+ `read ${status} and continue the existing run's next_task_ref or next_action. Cancel only for an actual scope change or explicit stop.` });
390
+ }
372
391
  throw error;
373
392
  }
374
393
  await registerPreparedGoal(context.sessionID, state);
@@ -425,7 +444,7 @@ export function createProfiledPlugin(profile, assetVersion) {
425
444
  const proposal = await proposals.read(context.sessionID);
426
445
  const proposalNextAction = proposal?.phase === "investigating"
427
446
  ? proposal.proposal_call_id !== null
428
- ? "proposal Task is already admitted; do not redispatch it or call operator_next. Continue submission repairs only in the same active claimed child. If that child terminated without submission, report the terminal proposal failure; remaining read or submission capacity does not authorize a new Task, budget reset, or replacement child"
447
+ ? "proposal Task is already admitted; do not redispatch it or call operator_next. Continue submission repairs only in the same active claimed child. If that child terminated without submission, report the terminal proposal failure; remaining read or submission capacity does not authorize a new Task, budget reset, or replacement child. Only an explicit root decision to retry may call cancel_operator with no reason to release this grant, which discards the spent proposal accounting and never reuses the terminated child"
429
448
  : proposal.submission_count >= proposal.intent.proposal_budget.max_submissions
430
449
  ? "proposal submission budget exhausted; do not call operator_next; report the bounded proposal failure"
431
450
  : "proposal is not submitted; do not call operator_next; complete or repair the bounded proposal submission"
@@ -441,13 +460,29 @@ export function createProfiledPlugin(profile, assetVersion) {
441
460
  next_action: proposalNextAction }
442
461
  : { status: "absent", profile: profile.id });
443
462
  } };
444
- tools[cancel] = { description: "Revoke this root's operator grant and stop only its owned children before releasing core state. reason is a closed set: omit it for a plain cancellation, including replanning or contract revision, and never send free text such as a written justification. Use reason=acceptance-remediation only for decision=operator-acceptance-remediation-required. At awaiting-acceptance, reason=review-blocking authorizes a same-goal review-remediation replacement only within the exact acceptance, committed head, approved write union, and retained remaining budget.",
463
+ tools[cancel] = { description: "Revoke this root's operator grant and stop only its owned children before releasing core state. Before approval it instead releases the bounded proposal grant, including one whose admitted child already terminated, so the root can begin a new investigation; it never reuses that child or restores spent proposal budget. reason is a closed set: omit it for a plain cancellation, including replanning, contract revision, and any pre-approval proposal release, and never send free text such as a written justification. Use reason=acceptance-remediation only for decision=operator-acceptance-remediation-required. At awaiting-acceptance, reason=review-blocking authorizes a same-goal review-remediation replacement only within the exact acceptance, committed head, approved write union, and retained remaining budget.",
445
464
  args: { reason: optionalStringSchema }, execute: async (args, context) => {
446
465
  await requireRoot(context.sessionID);
447
466
  const requestedReason = args.reason;
448
467
  if (requestedReason !== undefined && requestedReason !== "review-blocking" && requestedReason !== "acceptance-remediation")
449
468
  throw new Error("operator-cancel-reason-invalid");
450
- const current = await operators.required(context.sessionID);
469
+ const pending = await operators.read(context.sessionID);
470
+ if (!pending) {
471
+ // A pre-approval proposal owns no execution lane. Its admitted child can never be rebound, so without
472
+ // this release the root can neither resume, redispatch, nor start any replacement investigation.
473
+ const proposal = await proposals.read(context.sessionID);
474
+ if (!proposal || proposal.phase === "approved")
475
+ throw new Error("operator-run-missing");
476
+ if (requestedReason !== undefined)
477
+ throw new Error("operator-cancel-reason-invalid");
478
+ await stop(context.sessionID, "explicit-cancellation", false);
479
+ const discarded = await proposals.discardPreApproval(context.sessionID);
480
+ return JSON.stringify({ profile: profile.id, status: "cancelled", scope: "proposal",
481
+ released_proposal: discarded ? proposals.packet(discarded) : null,
482
+ next_action: "the bounded proposal grant is released and its spent reads/submissions are not restored; " +
483
+ "begin a new proposal investigation only on an explicit root decision to retry, and never reuse the cancelled child" });
484
+ }
485
+ const current = pending;
451
486
  if (requestedReason === "acceptance-remediation" && current.decision !== "operator-acceptance-remediation-required")
452
487
  throw new Error("operator-cancel-reason-invalid");
453
488
  if (requestedReason === "review-blocking" && current.phase !== "awaiting-acceptance")
@@ -483,8 +518,14 @@ export function createProfiledPlugin(profile, assetVersion) {
483
518
  await control.authorizeOperatorContractRepairValidation(context.sessionID, taskID, unit.childSessionID, args.repair_fingerprint);
484
519
  }
485
520
  catch (error) {
521
+ // The repair is aborted and the run keeps a durable decision. Return that preserved state so an
522
+ // unauthorized resume cannot leave the root without a recognizable terminal or next action.
486
523
  applied = await operators.abortAppliedContractRepair(context.sessionID, "operator-contract-repair-validation-resume-unavailable");
487
- throw error;
524
+ return JSON.stringify({ status: "contract-repair-validation-unavailable",
525
+ code: error instanceof Error ? error.message : "operator-contract-repair-validation-resume-unavailable",
526
+ packet: await operatorPacket(applied),
527
+ next_action: `The validation-only resume was not authorized and this repair is closed. Do not retry ${resolveContractRepair} or ` +
528
+ `${resume} for the same fingerprint: read ${status}, then either continue the reported decision or return one terminal checkpoint naming this refusal.` });
488
529
  }
489
530
  return JSON.stringify({ status: "repair-applied", run_id: applied.runID, unit_id: unit.unit.id,
490
531
  repair_generation: applied.repairGeneration, task: operators.nextWorkerTask(applied) });
@@ -610,7 +651,10 @@ export function createProfiledPlugin(profile, assetVersion) {
610
651
  if (!root)
611
652
  throw new Error("operator-proposal-session-inactive");
612
653
  try {
613
- return JSON.stringify(proposals.packet(await proposals.submit(root, context.sessionID, JSON.parse(args.proposal_json))));
654
+ const submitted = await proposals.submitJSON(root, context.sessionID, args.proposal_json);
655
+ return JSON.stringify({ ...proposals.packet(submitted),
656
+ next_action: "Proposal submitted. This investigation child must now return to its parent without further tools. " +
657
+ "Do not call operator_next, start a worker, or claim approval; the root must compare and approve the proposal." });
614
658
  }
615
659
  catch (error) {
616
660
  const contract = error instanceof OperatorContractError;
@@ -618,6 +662,7 @@ export function createProfiledPlugin(profile, assetVersion) {
618
662
  throw error;
619
663
  const state = await proposals.required(root);
620
664
  return JSON.stringify({ status: "invalid-proposal", code: error.message, actual_reads: state.read_count,
665
+ remaining_reads: state.intent.proposal_budget.max_reads - state.read_count,
621
666
  submissions: state.submission_count, remaining_submissions: state.intent.proposal_budget.max_submissions - state.submission_count,
622
667
  ...(contract ? { diagnostics: error.diagnostics, diagnostics_truncated: error.diagnostics_truncated } : {}) });
623
668
  }
@@ -1118,6 +1163,14 @@ export function createProfiledPlugin(profile, assetVersion) {
1118
1163
  (output.system ??= []).push(`SORTIE_RUNTIME_PROFILE ${profile.id}; marker ${assetVersion}. ` +
1119
1164
  `Shared MkII protocol role names are logical: ${protocolMap}. Use only ${profile.toolPrefix} tools for this profile. ` +
1120
1165
  "Never rewrite user acceptance or evidence to rename protocol roles. Final acceptance belongs only to the root coordinator.");
1166
+ const proposal = await proposals.read(root);
1167
+ if (proposal?.phase === "investigating" && proposal.proposal_session_id === request.sessionID) {
1168
+ (output.system ??= []).push(`SORTIE_PROPOSAL_PHASE investigating; intent=${proposal.intent_id}; root=${root}; child=${request.sessionID}. ` +
1169
+ `This durable phase remains authoritative after compaction even when the latest message is a generic continuation. ` +
1170
+ `Continue the admitted read-only investigation and submit through ${submitProposal}. Do not call ${next} or dispatch workers: no execution run exists yet. ` +
1171
+ `actual_reads=${proposal.read_count}; remaining_reads=${proposal.intent.proposal_budget.max_reads - proposal.read_count}; ` +
1172
+ `submissions=${proposal.submission_count}; remaining_submissions=${proposal.intent.proposal_budget.max_submissions - proposal.submission_count}.`);
1173
+ }
1121
1174
  },
1122
1175
  "experimental.text.complete": async (request, output) => {
1123
1176
  const role = (await identity(request.sessionID)).role;
@@ -1142,6 +1195,18 @@ export function createProfiledPlugin(profile, assetVersion) {
1142
1195
  if (!root)
1143
1196
  return;
1144
1197
  if ((await identity(request.sessionID)).role === "dog-operator") {
1198
+ const proposal = await proposals.read(root);
1199
+ if (proposal?.phase === "investigating" && proposal.proposal_session_id === request.sessionID) {
1200
+ (output.context ??= []).push(`Proposal continuation: ${JSON.stringify({
1201
+ root, child: request.sessionID, intent_id: proposal.intent_id, intent_hash: proposal.intent_hash,
1202
+ intent: proposal.intent, goal_binding: proposal.goal_binding, read_paths: proposal.read_paths,
1203
+ actual_reads: proposal.read_count, remaining_reads: proposal.intent.proposal_budget.max_reads - proposal.read_count,
1204
+ submissions: proposal.submission_count, remaining_submissions: proposal.intent.proposal_budget.max_submissions - proposal.submission_count,
1205
+ })}. Preserve the latest proposal draft and field diagnostics in the summary. Continue only in this same claimed read-only proposal child. ` +
1206
+ `Repair only diagnosed fields, then call ${submitProposal}; do not call ${next}, dispatch Tasks, execute work, or reset budgets. ` +
1207
+ "This is investigation, not an execution run. Compaction grants no new reads or submissions.");
1208
+ return;
1209
+ }
1145
1210
  const state = await operators.required(root);
1146
1211
  (output.context ??= []).push(`Operator continuation: root=${root}; run=${state.runID}; generation=${state.generation}; contract=${state.planHash}. ` +
1147
1212
  `Read ${next} for current authoritative state. Do not reconstruct acceptance or reset the queue.`);
@@ -1150,9 +1215,13 @@ export function createProfiledPlugin(profile, assetVersion) {
1150
1215
  await core["experimental.session.compacting"]?.(request, output);
1151
1216
  },
1152
1217
  "experimental.compaction.autocontinue": async (request, output) => {
1153
- if (!await rootFor(request.sessionID))
1218
+ const root = await rootFor(request.sessionID);
1219
+ if (!root)
1154
1220
  return;
1155
1221
  if ((await identity(request.sessionID)).role === "dog-operator") {
1222
+ const proposal = await proposals.read(root);
1223
+ if (proposal?.phase === "investigating" && proposal.proposal_session_id === request.sessionID)
1224
+ return;
1156
1225
  output.enabled = false;
1157
1226
  return;
1158
1227
  }
@@ -1,4 +1,5 @@
1
1
  import { type RuntimeAsset } from "./runtime-assets.ts";
2
2
  export declare const PREVIEW_PRESENTATION_POLICY: string;
3
+ export declare const PREVIEW_TERMINAL_REPORT_POLICY: string;
3
4
  export declare const COMMUNICATION_LANGUAGE_POLICY = "\n## Communication language continuity\n\nUse the language of the user's latest instruction sentences for all user-visible communication,\nincluding progress, Task descriptions/titles, delegated questions, handoff prose, findings and final replies.\nJapanese instructions require Japanese communication; English instructions require English communication.\nMixed-language identifiers or quoted English documents do not change the user's instruction language.\nIf the latest message supplies no detectable language, preserve the previous instruction language.\n\nBefore delegating, write the question and every prose field in that same language and explicitly ask the\nchild to reply in it. Do not translate a Japanese request into English merely because these agent instructions\nor examples are English. Child sessions and their prompts are visible to the user: parent-only translation\nafter an English exchange is not sufficient. All descendants preserve the requested language; it takes\nprecedence over the language of protocol boilerplate. Do not add a separate translation pass or model call.\n\nKeep protocol keys, enums, commands, paths, identifiers, model names, exact quoted evidence and code verbatim.\nFor example strategy_trigger, architecture-choice, review_phase and PASS must not be localized. Translate\ntheir explanatory prose, not these tokens. Preserve immutable criteria and host-generated Task packets\nverbatim; author their user-controlled prose in the correct language before the contract is frozen.\nGenerated control labels are not a reason to switch the surrounding explanation to English.\n";
4
5
  export declare const runtimeAssets: readonly RuntimeAsset[];
@@ -8,10 +8,14 @@ const coordinator = profileAgent(profile, "dog-coordinator");
8
8
  const operator = profileAgent(profile, "dog-operator");
9
9
  const worker = profileAgent(profile, "dog-worker");
10
10
  const canonicalCoordinator = canonicalAssets.find(asset => asset.name === "dog-coordinator").content;
11
- const presentationStart = canonicalCoordinator.indexOf("READABLE_OUTPUT_FIXTURE\n");
12
- const presentationEnd = canonicalCoordinator.indexOf("END_READABLE_OUTPUT_FIXTURE", presentationStart);
13
- if (presentationStart < 0 || presentationEnd < 0)
14
- throw new Error("canonical-presentation-fixture-missing");
11
+ /** Reuse the canonical fixture bodies so this profile cannot drift from the shared terminal vocabulary. */
12
+ function canonicalFixture(marker) {
13
+ const start = canonicalCoordinator.indexOf(`${marker}\n`);
14
+ const end = start < 0 ? -1 : canonicalCoordinator.indexOf(`END_${marker}`, start);
15
+ if (start < 0 || end < 0)
16
+ throw new Error(`canonical-fixture-missing:${marker}`);
17
+ return canonicalCoordinator.slice(start, end + `END_${marker}`.length);
18
+ }
15
19
  export const PREVIEW_PRESENTATION_POLICY = `
16
20
  ## Sortie presentation continuity
17
21
 
@@ -20,7 +24,34 @@ Use the user's language for prose, concise icon-led plan/progress/evidence block
20
24
  after the host accepts the result. The host supplies the 🐾 return report, mission/proof, cost/pack and career panels;
21
25
  do not fabricate scores, counts, medals or success, and do not suppress these panels as redundant decoration.
22
26
 
23
- ${canonicalCoordinator.slice(presentationStart, presentationEnd + "END_READABLE_OUTPUT_FIXTURE".length)}
27
+ ${canonicalFixture("READABLE_OUTPUT_FIXTURE")}
28
+ `;
29
+ export const PREVIEW_TERMINAL_REPORT_POLICY = `
30
+ ## Terminal report contract
31
+
32
+ A task turn that ends without another tool call is a terminal return. Its first non-empty line must be one
33
+ machine checkpoint: exactly one of DONE, INTERRUPTED, BLOCKED, or NEED_DECISION with that status icon,
34
+ followed by a short conclusion in the user's language. Never close a task turn with bare prose, an unlabeled
35
+ summary, a plan, a progress note, or a preamble, and never leave the run without one of these four tokens.
36
+ The status token, its icon, TRUE_INTERRUPTION and TRUE_BLOCKER are protocol tokens: keep them verbatim
37
+ even when the surrounding conclusion is translated. Translate only the display labels and keep their order.
38
+
39
+ DONE requires a succeeded ${profile.toolPrefix}complete_operator receipt for the accepted goal; the host renders
40
+ the measured return report from that receipt. Without it, return INTERRUPTED, BLOCKED, or NEED_DECISION naming
41
+ the exact unresolved condition. An exhausted budget, an unapproved or failed proposal, a terminated child, a
42
+ refused contract operation, or an unreachable acceptance is an INTERRUPTED return, never a silent stop.
43
+ A genuine interruption also requires the canonical machine line \`TRUE_INTERRUPTION: user: <condition>\` or
44
+ \`TRUE_INTERRUPTION: internal: <condition>\`; without it the host keeps the run on its same-session continuation path.
45
+
46
+ A refused control operation is a local process defect, not a terminal blocker. Read the returned status and
47
+ next_action, apply that one correction, and continue in the same turn. Never reissue an unchanged refused
48
+ request: an active contract returns ${profile.toolPrefix}operator_status and the existing next Task, and an
49
+ unavailable contract-repair validation resume returns the preserved run state and its decision. When the same
50
+ refusal repeats with unchanged state, stop retrying and return one INTERRUPTED checkpoint naming that refusal.
51
+
52
+ ${canonicalFixture("TERMINAL_STATUS_SEMANTICS_FIXTURE")}
53
+
54
+ ${canonicalFixture("TERMINAL_OUTPUT_TEMPLATE")}
24
55
  `;
25
56
  export const COMMUNICATION_LANGUAGE_POLICY = `
26
57
  ## Communication language continuity
@@ -102,9 +133,18 @@ their literal ASCII keys; translated prose belongs in the values. Do not guess a
102
133
  For a nontrivial request whose source facts, unit boundaries, or exact validation contract still require investigation,
103
134
  do not author a giant speculative plan at the root. Freeze the original request once with stable ordered requirement IDs,
104
135
  including every negative and quality condition, authoritative references, finite proposal read/submission budgets, and the
105
- maximum read prefixes. Call ${profile.toolPrefix}begin_operator_proposal. Dispatch its exact ${operator} Task unchanged.
136
+ maximum read prefixes. Call ${profile.toolPrefix}begin_operator_proposal. Dispatch its exact ${operator} Task unchanged.
137
+ Freeze product requirements from the original request, not extra implementation criteria invented from workflow
138
+ bookkeeping. Keep proposal read/submission allowances in proposal_budget and host counters; do not turn spent
139
+ budgets or your own reporting obligations into worker validation commands. Preserve any explicit user requirement.
106
140
  That child remains the same logical operations role: it may inspect only the host-approved read prefixes and submit one
107
141
  requirement-mapped proposal; it cannot edit, use shell, dispatch a worker/advisor/scout, widen read scope, or execute work.
142
+ An admitted proposal child is never redispatched or replaced. If it terminates without a submitted proposal, report that
143
+ terminal failure first; only an explicit decision to retry may call ${profile.toolPrefix}cancel_operator with no reason to
144
+ release the grant before freezing the requirements again. Spent proposal reads and submissions are never restored.
145
+ Do not cancel/reinvestigate the same known contract defect merely to get another draft. Preserve exact observed
146
+ commands and correction evidence. If a user-only acceptance condition cannot be expressed by the existing contract,
147
+ report that specific limitation for a scope/design decision instead of inventing a passing test or another investigation.
108
148
 
109
149
  The proposal must map every original requirement ID to approach and validation, explicitly list uncovered IDs, preserve
110
150
  negative-condition handling, and include exact read/write/unit/GoalDeclaration plans and budget estimates. Its summary is
@@ -246,7 +286,7 @@ Use ${profile.toolPrefix}cancel_operator to stop an active grant before changing
246
286
  operator-acceptance-remediation-required replacement action, or performing the bounded awaiting-acceptance
247
287
  reason=review-blocking replacement above. Agent switching revokes this
248
288
  runtime's ownership; do not restart it from a stale summary. The initial preview supports the serial lane only.
249
- ${PREVIEW_PRESENTATION_POLICY}
289
+ ${PREVIEW_PRESENTATION_POLICY}${PREVIEW_TERMINAL_REPORT_POLICY}
250
290
  `;
251
291
  const operatorContent = `---
252
292
  description: Sortie-dogs ${V010_RUNTIME_ASSET_VERSION} hidden dogs-coordinator operations delegate; no source or acceptance authority.
@@ -267,10 +307,14 @@ tools:
267
307
  # ${operator}
268
308
 
269
309
  You operate one coordinator-approved serial queue for runtime ${V010_RUNTIME_ASSET_VERSION}. You are not a second coordinator.
270
- When the prompt starts SORTIE_OPERATOR_PROPOSAL, perform only its bounded read investigation and submit the complete packet
271
- through ${profile.toolPrefix}submit_operator_proposal before returning; a prose-only return is forbidden. If it returns invalid-proposal,
272
- repair only the named code (using actual_reads for a budget estimate mismatch) within the finite submission budget. Do not call operator_next or dispatch a worker. Otherwise call
273
- ${profile.toolPrefix}operator_next. If it returns a task, pass its subagent_type, description, and prompt unchanged to Task.
310
+ When the prompt starts SORTIE_OPERATOR_PROPOSAL or the host supplies SORTIE_PROPOSAL_PHASE investigating,
311
+ perform only its bounded read investigation and submit the complete packet
312
+ through ${profile.toolPrefix}submit_operator_proposal before returning; a prose-only return is forbidden. If it returns invalid-proposal,
313
+ repair only the named code (using actual_reads for a budget estimate mismatch) within the finite submission budget.
314
+ A successful status=submitted ends this investigation Task: return to the parent without further tools, even if a generic
315
+ continuation asks for the next step. Submission is not execution admission; never call operator_next or dispatch a worker.
316
+ For an admitted execution queue, call
317
+ ${profile.toolPrefix}operator_next. If it returns a task, pass its subagent_type, description, and prompt unchanged to Task.
274
318
  After the worker returns, inspect the host's bounded packet and call next again only when the queue still has pending work.
275
319
  The worker owns implementation, diagnosis, correction and declared validation inside its Task invocation. Do not duplicate it.
276
320
 
@@ -280,8 +324,10 @@ or replace a child to bypass a refusal or a budget. The plugin enforces root/pro
280
324
 
281
325
  Keep coordination concise and use the handoff's language. Do not return intermediate progress merely to wake the coordinator.
282
326
  On awaiting-decision, cancelled, or awaiting-acceptance, stop and return the packet's status and unresolved evidence. Do not
283
- claim the feature is accepted; only the root coordinator can do that. After compaction, call next to read authoritative state
284
- and its current short Task reference rather than reconstructing criteria from a summary. Never use a standalone/generic worker as a fallback.
327
+ claim the feature is accepted; only the root coordinator can do that. After compaction in the proposal phase, preserve the
328
+ same child, intent, read evidence and remaining budgets; continue investigation/submission repair, never call next.
329
+ Only in the execution phase, call next to read authoritative queue state and its current short Task reference rather than
330
+ reconstructing criteria from a summary. Never use a standalone/generic worker as a fallback.
285
331
  `;
286
332
  /**
287
333
  * A constraint added to an existing construct is only complete when every existing form that reaches
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",
@@ -23,7 +23,7 @@
23
23
  "homepage": "https://github.com/zufall-upon/Sortie-dogs#readme",
24
24
  "publishConfig": {
25
25
  "access": "public",
26
- "tag": "beta"
26
+ "tag": "latest"
27
27
  },
28
28
  "type": "module",
29
29
  "engines": {