sortie-dogs 0.13.5 → 0.13.7

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
@@ -35,10 +35,10 @@ implementation, validation, review, and model routing.
35
35
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) ·
36
36
  [Testing](docs/testing.md) · [CLI testing](docs/cli-testing.md)
37
37
 
38
- **Current release: [v0.13.5](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.5)**
39
- ([release notes](docs/release-v0.13.5.md)). The default Mission runtime retains the `v010`
38
+ **Current release: [v0.13.7](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.7)**
39
+ ([release notes](docs/release-v0.13.7.md)). The default Mission runtime retains the `v010`
40
40
  profile, command and configuration names for compatibility; these names do not mean v0.10 is installed.
41
- The current asset marker is `0.13.5-live-state-v1`.
41
+ The current asset marker is `0.13.7-review-recovery-v1`.
42
42
 
43
43
  ## SWE-bench Lite: 170/300 (56.67%)
44
44
 
@@ -48,7 +48,7 @@ The fixed **Sortie-dogs v0.12.24** harness resolved **170 of 300 SWE-bench Lite
48
48
 
49
49
  The single official 300-instance report and frozen predictions are hash-bound in the report. Confirmed inference expense was **$162.99**; a separate **$34.60** of usage has unknown pricing and is held against the campaign cap, **not** counted as known expense. Leaderboard registration and maintainer acceptance are separate from this official local evaluation.
50
50
 
51
- Historical scores below belong to their fixed candidates, not v0.13.5. SWE-bench is a separate,
51
+ Historical scores below belong to their fixed candidates, not v0.13.7. SWE-bench is a separate,
52
52
  optional measurement rather than a mandatory release gate.
53
53
 
54
54
  > **Beta:** v0.13.x is still stabilizing. Runtime behavior,
@@ -93,7 +93,30 @@ existing local bridge loads enforcement and model routing. OpenCode can reload w
93
93
  but replacing an installed dependency may require a full restart. A new chat session alone does not
94
94
  prove the newly installed plugin is loaded.
95
95
 
96
- ## v0.13.5 runtime updates
96
+ ## v0.13.7 runtime updates
97
+
98
+ PRs #158/#159 prevent late write-only reports from falsely invalidating bound non-generating
99
+ native checks, while preserving actual source/output freshness, failed checks and legacy recipes.
100
+ Whole-root grants without a concrete inventory keep full-candidate freshness. Non-Git Mission roots
101
+ can prepare independent Review from declared nested repositories/worktrees/plain outputs, including
102
+ committed content; corrupt Git/HEAD errors remain visible. Operator performs authorized local recovery
103
+ in the same turn without another approval, changed requirements or reset spending. Ubuntu's Git
104
+ filesystem-boundary diagnostic is recognized alongside the standard non-repository error.
105
+ The PR native observation reached Reviewer startup, not final acceptance. Release receipts:
106
+ `_testenv/releases/0.13.7/`; see [release notes](docs/release-v0.13.7.md).
107
+
108
+ ## v0.13.6 runtime updates (retained)
109
+
110
+ PR #156 references recorded formal PASS evidence in acceptance summaries instead of fetching
111
+ the same successful unit's native history again for display. Missing/failed proof retains history
112
+ diagnostics; historical evidence is not current freshness or acceptance. Identical external artifact
113
+ inventories are shared only inside one snapshot refresh, never across later calls. Operator guidance
114
+ proceeds to acceptance when current evidence covers the request and no concrete gap remains.
115
+ Existing freshness/review guards, provider/model selection and cache-prefix machinery remain unchanged.
116
+ No end-to-end speedup or live token/cache-hit improvement was measured. Release receipts:
117
+ `_testenv/releases/0.13.6/`; see [release notes](docs/release-v0.13.6.md).
118
+
119
+ ## v0.13.5 runtime updates (retained)
97
120
 
98
121
  PR #154 preserves stable model instructions and appends changed host state at native history
99
122
  boundaries, with complete current-state reconstruction after compaction. Genuine Reviewer tools
@@ -413,7 +436,7 @@ register stable and `v010` from the same package installation path in one host.
413
436
  Project-local installation is recommended. To expose the current Mission assets globally:
414
437
 
415
438
  ```sh
416
- npm install --global sortie-dogs@0.13.5
439
+ npm install --global sortie-dogs@0.13.7
417
440
  sortie-dogs init --global --profile v010
418
441
  ```
419
442
 
@@ -425,7 +448,7 @@ can resolve a **separate dependency** under that config root. Updating npm-globa
425
448
  it. For that layout, also install the same release at the actual config root, then rerun global init:
426
449
 
427
450
  ```sh
428
- npm install --prefix "$HOME/.config/opencode" sortie-dogs@0.13.5
451
+ npm install --prefix "$HOME/.config/opencode" sortie-dogs@0.13.7
429
452
  sortie-dogs init --global --profile v010
430
453
  ```
431
454
 
@@ -448,7 +471,7 @@ version, preserves user configuration, and stops safely on unknown ownership or
448
471
  conflicting files.
449
472
 
450
473
  Align any exact version pin or separate bridge dependency with the intended release too. An installed
451
- marker of `0.13.5-live-state-v1` identifies the assets; it does not prove an already-running
474
+ marker of `0.13.7-review-recovery-v1` identifies the assets; it does not prove an already-running
452
475
  OpenCode process has reloaded the plugin.
453
476
 
454
477
  There is no supported uninstall command. Remove the npm dependency separately,
@@ -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.13.5-live-state-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.13.7-review-recovery-v1";
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.13.5-live-state-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.13.7-review-recovery-v1";
@@ -42,6 +42,9 @@ export interface GoalEvidence {
42
42
  /** New captures distinguish a whole-project output grant from explicitly named control-like artifacts.
43
43
  * Missing on old evidence: keep its original candidate recipe, including bookkeeping bytes. */
44
44
  readonly candidate_policy?: "project-root-artifacts-v1";
45
+ /** Non-generating native validation pins inputs/concrete outputs, not write-only directory bookkeeping.
46
+ * Missing on old evidence: retain its full source/output recipe. */
47
+ readonly validation_policy?: "inputs-and-concrete-outputs-v1";
45
48
  /** Fixed when validation starts. Full manifest_hash still identifies the historical execution contract. */
46
49
  readonly freshness?: {
47
50
  readonly contract_hash: string;
@@ -66,6 +66,7 @@ export function validGoalEvidence(value, state) {
66
66
  (protectedBinding.source_policy === undefined || protectedBinding.source_policy === "project-files-v1" ||
67
67
  protectedBinding.source_policy === "declared-paths-v1") &&
68
68
  (protectedBinding.candidate_policy === undefined || protectedBinding.candidate_policy === "project-root-artifacts-v1") &&
69
+ (protectedBinding.validation_policy === undefined || protectedBinding.validation_policy === "inputs-and-concrete-outputs-v1") &&
69
70
  (protectedBinding.freshness === undefined || (protectedBinding.freshness !== null && typeof protectedBinding.freshness === "object" &&
70
71
  HASH.test(protectedBinding.freshness.contract_hash) &&
71
72
  Array.isArray(protectedBinding.freshness.scratch_paths) && protectedBinding.freshness.scratch_paths.every(text) &&
@@ -534,6 +534,15 @@ export async function missionAcceptanceSummary(mission, run, operators, observe)
534
534
  const provenance = { run_id: item.state.runID, unit_id: unit.unit.id, worker_session_id: unit.childSessionID,
535
535
  state_archive_path: item.path, handoff_path: unit.handoffPath, historical: item.historical,
536
536
  authority: "native declared-command observations; not additional formal evidence or current freshness" };
537
+ // Settled proof already records these commands. Do not fetch the same native
538
+ // history again merely to render acceptance; freshness is checked separately.
539
+ const proofs = unit.evidence ?? [];
540
+ if (unit.status === "succeeded" && unit.unit.validation.every(command => proofs.some(proof => proof.execution.exit_code === 0 && proof.execution.outcome === "pass" &&
541
+ proof.execution.command.includes(command)))) {
542
+ return { ...provenance, status: "recorded-formal-evidence",
543
+ evidence_ids: proofs.map(proof => proof.evidence_id),
544
+ details_ref: { field: "formal_validation", run_id: item.state.runID, unit_id: unit.unit.id } };
545
+ }
537
546
  try {
538
547
  if (!observe || !unit.childSessionID)
539
548
  throw new Error("native-worker-history-unavailable");
@@ -51,7 +51,7 @@ import { collectRunMetrics, createSortieResult, createGoalReport, insertRunMetri
51
51
  import { STABLE_RUNTIME_PROFILE } from "../core/runtime-profile.js";
52
52
  import { evidenceFromObservedExecution } from "../core/observed-goal-evidence.js";
53
53
  import { receiptBoundTerminalText } from "./receipt-presentation.js";
54
- import { operationInputSnapshot, protectedSnapshot, refreshProtectedSnapshot, validationInputSnapshot } from "./protected-snapshot.js";
54
+ import { operationInputSnapshot, protectedSnapshot, refreshProtectedSnapshot, validatedSourceSnapshot, validationInputSnapshot } from "./protected-snapshot.js";
55
55
  import { settledUnitUsage } from "./unit-usage.js";
56
56
  import { goalCompletionReadiness } from "./goal-completion.js";
57
57
  const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024, parallel: 512 * 1024 };
@@ -1517,12 +1517,15 @@ export const SortieDogsPlugin = async (input, options) => {
1517
1517
  ? await operationInputSnapshot(authorization.projectRoot, snapshot.binding).catch(() => undefined) : undefined;
1518
1518
  const validationInputs = (validation !== undefined || correction !== undefined) && operationInputs === undefined
1519
1519
  ? await validationInputSnapshot(authorization.projectRoot, snapshot.binding).catch(() => undefined) : undefined;
1520
+ const validationSource = correction && validationInputs !== undefined
1521
+ ? await validatedSourceSnapshot(authorization.projectRoot, snapshot.binding).catch(() => undefined) : undefined;
1520
1522
  hostGoalExecutions.set(toolInput.callID, { root, projectRoot: authorization.projectRoot,
1521
1523
  sessionID: toolInput.sessionID,
1522
1524
  callID: toolInput.callID, tool: toolInput.tool, command: [rawCommand], startedAt: new Date().toISOString(),
1523
1525
  binding: snapshot.binding, source: snapshot.source, candidate: snapshot.candidate,
1524
1526
  ...(operationInputs === undefined ? {} : { operationInputs }),
1525
1527
  ...(validationInputs === undefined ? {} : { validationInputs }),
1528
+ ...(validationSource === undefined ? {} : { validationSource }),
1526
1529
  owner: validation?.request.owner ?? "worker", validation,
1527
1530
  ...(correction ? { correction } : {}),
1528
1531
  ...(reusedEvidence === undefined ? {} : { reusedEvidence }) });
@@ -1562,19 +1565,28 @@ export const SortieDogsPlugin = async (input, options) => {
1562
1565
  child_session_id: execution.sessionID, call_id: execution.callID, command: execution.command,
1563
1566
  started_at: observedStartedAt, ended_at: endedAt, exit_code: exitCode ?? null, outcome,
1564
1567
  source: execution.source, candidate: execution.candidate });
1568
+ const generated = execution.validationInputs !== undefined && refreshed &&
1569
+ (refreshed.source !== execution.source || refreshed.candidate !== execution.candidate);
1570
+ // Capture this narrower recipe only from an actual non-generating native check.
1571
+ // Legacy records never gain new exclusions when refreshed or recovered later.
1572
+ const validationBinding = execution.correction && fresh && !generated && execution.validationSource !== undefined
1573
+ ? { ...execution.binding, validation_policy: "inputs-and-concrete-outputs-v1" } : undefined;
1574
+ const validatedSource = validationBinding && await validatedSourceSnapshot(execution.projectRoot, validationBinding).catch(() => undefined);
1575
+ const proofFresh = fresh && (!validationBinding || validatedSource === execution.validationSource);
1576
+ const observed = proofFresh && validatedSource && validationBinding
1577
+ ? { binding: validationBinding, source: validatedSource, candidate: validatedSource } : refreshed;
1565
1578
  hostGoalExecutions.set(execution.callID, { ...execution,
1566
- ...(refreshed === undefined ? {} : { source: refreshed.source, candidate: refreshed.candidate }),
1579
+ ...observed,
1567
1580
  startedAt: observedStartedAt, endedAt, exitCode: exitCode ?? null,
1568
- ...(outcome === undefined ? {} : { outcome }), ...(immutableRef === undefined ? {} : { immutableRef }), fresh });
1581
+ ...(outcome === undefined ? {} : { outcome }), ...(immutableRef === undefined ? {} : { immutableRef }), fresh: proofFresh });
1569
1582
  if (execution.correction) {
1570
1583
  await input.runtimeBridge?.recordReviewerCorrectionCheck?.(execution.root, execution.correction.taskID, {
1571
1584
  dispatchCallID: execution.correction.dispatchCallID, childSessionID: execution.sessionID, callID: execution.callID,
1572
1585
  command: execution.correction.commands, startedAt: observedStartedAt, endedAt, exitCode: exitCode ?? null,
1573
- binding: execution.binding, source: refreshed?.source ?? execution.source, candidate: refreshed?.candidate ?? execution.candidate,
1574
- fresh: fresh && outcome === "pass",
1575
- ...(execution.validationInputs !== undefined && refreshed &&
1576
- (refreshed.source !== execution.source || refreshed.candidate !== execution.candidate)
1577
- ? { generatedInputs: execution.validationInputs } : {}),
1586
+ binding: proofFresh && validationBinding && validatedSource ? validationBinding : execution.binding,
1587
+ source: observed?.source ?? execution.source, candidate: observed?.candidate ?? execution.candidate,
1588
+ fresh: proofFresh && outcome === "pass",
1589
+ ...(generated ? { generatedInputs: execution.validationInputs } : {}),
1578
1590
  });
1579
1591
  }
1580
1592
  }
@@ -12,8 +12,26 @@ import { taskChildSessionID } from "./task-result-repair.js";
12
12
  import { normalizeManifestScope } from "../core/path.js";
13
13
  import { declaredArtifacts } from "./declared-artifacts.js";
14
14
  import { canonicalDeclaredValidationMembers, normalizeCommand } from "./gate.js";
15
- import { currentSnapshotProtection, refreshProtectedSnapshot, snapshotScratchExclusion, validationInputSnapshot } from "./protected-snapshot.js";
15
+ import { currentSnapshotProtection, isRuntimeControlPath, refreshProtectedSnapshot, snapshotScratchExclusion, validationInputSnapshot } from "./protected-snapshot.js";
16
16
  const exec = promisify(execFile);
17
+ const notRepository = (error) => /fatal: not a git repository \(or (?:any of the parent directories|any parent up to mount point [^\r\n)]+)\)(?:: \.git)?/u
18
+ .test(String(error.stderr ?? ""));
19
+ async function currentGitDiff(git, paths) {
20
+ try {
21
+ return await git(["diff", "--no-ext-diff", "--no-textconv", "HEAD", "--", ...paths]);
22
+ }
23
+ catch (error) {
24
+ try {
25
+ await git(["rev-parse", "--verify", "HEAD"]);
26
+ }
27
+ catch (headError) {
28
+ if (!/fatal: Needed a single revision/u.test(String(headError.stderr ?? "")))
29
+ throw headError;
30
+ return git(["diff", "--no-ext-diff", "--no-textconv", "--", ...paths]); // Unborn HEAD only.
31
+ }
32
+ throw error; // A real HEAD with missing/corrupt objects needs repair, not an empty diff.
33
+ }
34
+ }
17
35
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
18
36
  const validationDirectoryMatches = (input, directory) => directory === undefined || typeof input.workdir !== "string" || resolve(directory, input.workdir) === resolve(directory);
19
37
  /** Reviewer-facing evidence preserves execution/coverage; the host retains the full snapshot recipe. */
@@ -124,15 +142,23 @@ export function reviewerCorrectionValidation(validation, child, history, notBefo
124
142
  }
125
143
  const matched = [];
126
144
  let complete;
127
- let previousEnd = notBefore, failed;
145
+ let previousEnd = notBefore, overlap = false, failed;
128
146
  for (const attempt of attempts.sort((a, b) => a.started - b.started || a.completed - b.completed)) {
129
- if (attempt.started < editedAt)
147
+ // Bound native checks are compared with their actual input bytes below. A later
148
+ // report/cache write is not itself a source change. Unbound history stays conservative.
149
+ if (!checks && attempt.started < editedAt)
130
150
  continue;
131
151
  const commands = canonicalDeclaredValidationMembers(attempt.command, declared, matched.length % declared.length);
132
152
  if (!commands)
133
153
  continue; // Focused diagnostics are not formal proof.
134
- if (attempt.started < previousEnd)
135
- return { ready: false, reason: "mission-review-correction-validation-order:overlap" };
154
+ if (attempt.started < previousEnd) {
155
+ complete = undefined;
156
+ matched.length = 0;
157
+ failed = undefined;
158
+ overlap = true;
159
+ previousEnd = Math.max(previousEnd, attempt.completed);
160
+ continue; // A later complete ordered retry can recover in this same admission.
161
+ }
136
162
  previousEnd = attempt.completed;
137
163
  if (commands[0] !== declared[matched.length] || matched.length === declared.length)
138
164
  matched.length = 0;
@@ -151,12 +177,16 @@ export function reviewerCorrectionValidation(validation, child, history, notBefo
151
177
  continue;
152
178
  }
153
179
  matched.push({ callID: attempt.callID, member, occurrence: matched.length });
154
- if (matched.length === declared.length)
180
+ if (matched.length === declared.length) {
155
181
  complete = [...matched];
182
+ overlap = false;
183
+ }
156
184
  }
157
185
  }
158
186
  if (failed)
159
187
  return { ready: false, reason: `mission-review-correction-validation-failed:${failed.command.join(" && ")}`, failure: failed, nextOccurrence: 0 };
188
+ if (overlap && !matched.length)
189
+ return { ready: false, reason: "mission-review-correction-validation-order:overlap", nextOccurrence: 0 };
160
190
  if (!complete)
161
191
  return { ready: false, reason: `mission-review-correction-validation-missing:${declared[matched.length]}`, nextOccurrence: matched.length };
162
192
  for (const item of complete) {
@@ -465,18 +495,87 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
465
495
  const toolInput = scope.read.some(path => normalizeManifestScope(path).path.startsWith(TOOL_ENVIRONMENT));
466
496
  const scopes = [...local, ...(toolInput ? [] : [`:(exclude)${TOOL_ENVIRONMENT}`])];
467
497
  const git = async (args) => (await exec("git", args, { cwd: directory, maxBuffer: 8 * 1024 * 1024 })).stdout;
468
- const names = local.length ? await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...scopes]) : "";
469
- const untracked = new Set((local.length ? await git(["ls-files", "-z", "--others", "--exclude-standard", "--", ...scopes]) : "").split("\0").filter(Boolean));
498
+ let names = "", ignored = "", repository = true, scopedDiff = "";
499
+ const untracked = new Set(), currentFiles = new Set();
500
+ try {
501
+ if (local.length) {
502
+ names = await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...scopes]);
503
+ for (const path of (await git(["ls-files", "-z", "--others", "--exclude-standard", "--", ...scopes])).split("\0").filter(Boolean))
504
+ untracked.add(path);
505
+ ignored = await git(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", ...scopes]);
506
+ }
507
+ }
508
+ catch (error) {
509
+ if (!notRepository(error))
510
+ throw error;
511
+ repository = false;
512
+ // Mission storage can be outside Git while its declared outputs are repositories,
513
+ // worktrees or plain files. Inventory those outputs in place, not the storage root.
514
+ const collected = new Set(), walked = new Set();
515
+ const collect = async (path) => {
516
+ if (walked.has(path) || excluded(path))
517
+ return;
518
+ walked.add(path);
519
+ if (local.includes(".") && isRuntimeControlPath(path) && !local.some(scope => scope !== "." &&
520
+ (path === scope || path.startsWith(`${scope}/`))))
521
+ return;
522
+ if (!toolInput && (path === TOOL_ENVIRONMENT || path.startsWith(`${TOOL_ENVIRONMENT}/`)))
523
+ return;
524
+ const absolute = resolve(directory, path);
525
+ const stat = await lstat(absolute).catch((error) => {
526
+ if (error.code !== "ENOENT")
527
+ throw error;
528
+ return undefined;
529
+ });
530
+ if (!stat?.isDirectory()) {
531
+ collected.add(path);
532
+ untracked.add(path);
533
+ return;
534
+ }
535
+ const scopedGit = async (args) => (await exec("git", args, { cwd: absolute, maxBuffer: 8 * 1024 * 1024 })).stdout;
536
+ const pathspec = ["."]; // Shared tooling is rooted at the Mission, not each output repository.
537
+ let tracked;
538
+ try {
539
+ tracked = await scopedGit(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspec]);
540
+ }
541
+ catch (error) {
542
+ if (!notRepository(error))
543
+ throw error;
544
+ for (const child of (await readdir(absolute)).filter(name => name !== ".git").sort()) {
545
+ await collect(join(path, child).replaceAll("\\", "/"));
546
+ }
547
+ return;
548
+ }
549
+ const prefix = (name) => join(path, name).replaceAll("\\", "/");
550
+ for (const name of tracked.split("\0").filter(Boolean)) {
551
+ collected.add(prefix(name));
552
+ currentFiles.add(prefix(name));
553
+ }
554
+ for (const name of (await scopedGit(["ls-files", "-z", "--others", "--exclude-standard", "--", ...pathspec])).split("\0").filter(Boolean))
555
+ untracked.add(prefix(name));
556
+ for (const name of (await scopedGit(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", ...pathspec])).split("\0").filter(Boolean)) {
557
+ collected.add(prefix(name));
558
+ untracked.add(prefix(name));
559
+ }
560
+ // The mission's baseline belongs to its root, not to these different repositories.
561
+ // With no baseline here, committed outputs still appear as current file content.
562
+ const delta = await currentGitDiff(scopedGit, pathspec);
563
+ if (delta)
564
+ scopedDiff += `\n--- repository output: ${path} ---\n${delta}`;
565
+ };
566
+ for (const path of local)
567
+ await collect(path);
568
+ names = [...collected].sort().join("\0");
569
+ }
470
570
  // Go's ignored in-project caches may be writable during validation but are not candidate
471
571
  // output. Keep every other ignored declared output visible and fingerprinted, including
472
572
  // directories of generated artifacts; focused references can still pin a cache file.
473
- const ignored = local.length ? await git(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", ...scopes]) : "";
474
573
  for (const path of ignored.split("\0").filter(Boolean))
475
574
  untracked.add(path);
476
575
  const omitted = [];
477
576
  const unreadable = [];
478
- let diff = "";
479
- if (local.length && displayBaseline) {
577
+ let diff = scopedDiff;
578
+ if (repository && local.length && displayBaseline) {
480
579
  // HEAD-only diffs are empty once the author commits. Display the original mission delta
481
580
  // or the focused correction delta without changing the full fingerprint basis.
482
581
  const changed = (await git(["diff", "--name-only", "-z", "--no-ext-diff", displayBaseline, "--", ...scopes]))
@@ -501,9 +600,8 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
501
600
  }
502
601
  omitted.push(...changed.slice(shown.length));
503
602
  }
504
- else if (local.length) {
505
- diff = await git(["diff", "--no-ext-diff", "--no-textconv", "HEAD", "--", ...scopes])
506
- .catch(() => git(["diff", "--no-ext-diff", "--no-textconv", "--", ...scopes]));
603
+ else if (repository && local.length) {
604
+ diff = await currentGitDiff(git, scopes);
507
605
  }
508
606
  const unchanged = diff.length === 0;
509
607
  let excerpt = selected + diff;
@@ -519,7 +617,7 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
519
617
  try {
520
618
  const absolute = resolve(directory, path), stat = await lstat(absolute);
521
619
  sourceUpdate(String(stat.mode));
522
- const include = includedByParent || untracked.has(path) || unchanged;
620
+ const include = includedByParent || untracked.has(path) || currentFiles.has(path) || unchanged;
523
621
  const heading = `\n--- ${includedByParent || untracked.has(path) ? "new file" : "current file"}: ${path} ---\n`;
524
622
  const room = include ? Math.max(0, 24_000 - Buffer.byteLength(excerpt) - Buffer.byteLength(heading)) : 0;
525
623
  let preview = Buffer.alloc(0);
@@ -16,6 +16,10 @@ export declare function operationInputSnapshot(projectRoot: string, binding: Bin
16
16
  /** A validation may populate write-only caches. Keep its declared read inputs stable
17
17
  * while binding the resulting candidate (including those outputs) after the command. */
18
18
  export declare function validationInputSnapshot(projectRoot: string, binding: Binding): Promise<string | undefined>;
19
+ /** Pin a non-generating check's inputs and concrete source outputs, not incidental
20
+ * files created later under a directory execution grant (for example result reports).
21
+ * Exact outputs and tracked source remain protected even when omitted from read. */
22
+ export declare function validatedSourceSnapshot(projectRoot: string, binding: Binding): Promise<string | undefined>;
19
23
  export declare function protectedSnapshot(authorization: {
20
24
  manifestPath: string;
21
25
  manifestHash: string;
@@ -164,13 +164,19 @@ function outside(projectRoot, path) {
164
164
  const scoped = relative(projectRoot, path).replaceAll("\\", "/");
165
165
  return scoped === ".." || scoped.startsWith("../") || isAbsolute(scoped);
166
166
  }
167
- async function declaredScopeDigest(projectRoot, paths, manifestHash, source, excluded = [], binding, candidatePolicy) {
167
+ async function declaredScopeDigest(projectRoot, paths, manifestHash, source, excluded = [], binding, candidatePolicy, artifactsMemo) {
168
168
  const local = paths.filter(path => !outside(projectRoot, path));
169
169
  const external = paths.filter(path => outside(projectRoot, path));
170
170
  const project = await protectedScopeDigest(projectRoot, local, manifestHash, source ? "project-files-v1" : undefined, excluded, binding, candidatePolicy);
171
171
  if (project === undefined)
172
172
  return undefined;
173
- const artifacts = await declaredArtifacts(external).catch(() => undefined);
173
+ const key = JSON.stringify(external);
174
+ let pending = artifactsMemo?.get(key);
175
+ if (!pending) {
176
+ pending = declaredArtifacts(external);
177
+ artifactsMemo?.set(key, pending);
178
+ }
179
+ const artifacts = await pending.catch(() => undefined);
174
180
  return artifacts && goalFingerprint({ project, external: artifacts.entries.filter(([path]) => !excluded.some(root => !outside(root, path))) });
175
181
  }
176
182
  /** An existing operation may create its declared outputs. Compare its other inputs during execution;
@@ -204,6 +210,40 @@ export async function validationInputSnapshot(projectRoot, binding) {
204
210
  ? declaredScopeDigest(projectRoot, paths, hash, true, [], binding)
205
211
  : protectedScopeDigest(projectRoot, paths, hash, binding.source_policy, [], binding);
206
212
  }
213
+ /** Pin a non-generating check's inputs and concrete source outputs, not incidental
214
+ * files created later under a directory execution grant (for example result reports).
215
+ * Exact outputs and tracked source remain protected even when omitted from read. */
216
+ export async function validatedSourceSnapshot(projectRoot, binding) {
217
+ const current = await snapshotManifest(projectRoot, binding);
218
+ if (!current?.manifest.read.length)
219
+ return undefined; // An absent input set retains the full-source recipe.
220
+ const scopes = [...binding.source_paths.map(path => resolve(projectRoot, path)),
221
+ ...current.manifest.write.map(path => resolve(projectRoot, normalizeManifestScope(path).path))];
222
+ const protection = [...new Set([...(binding.freshness?.protected_paths ?? []),
223
+ ...await currentSnapshotProtection(projectRoot, current.manifest)])];
224
+ const reads = current.manifest.read.map(entry => resolve(projectRoot, normalizeManifestScope(entry).path));
225
+ const currentBinding = binding.freshness ? { ...binding, freshness: { ...binding.freshness, protected_paths: protection } } : binding;
226
+ // A populated directory grant without a concrete output/input inventory cannot safely
227
+ // acquire the report-only recipe. Keep the original full-candidate freshness instead.
228
+ for (const entry of binding.validation_policy === undefined ? current.manifest.write : []) {
229
+ const output = resolve(projectRoot, normalizeManifestScope(entry).path);
230
+ if (reads.some(input => !outside(input, output)) || snapshotScratchExcluded(currentBinding, output) ||
231
+ protection.some(path => path !== output && !outside(output, path) && !reads.includes(path)))
232
+ continue;
233
+ const stat = await lstat(output).catch((error) => {
234
+ if (error.code !== "ENOENT")
235
+ throw error;
236
+ return undefined;
237
+ });
238
+ if (stat?.isDirectory() && (await readdir(output)).length)
239
+ return undefined;
240
+ }
241
+ const paths = [...new Set([...reads,
242
+ ...protection.filter(path => scopes.some(scope => !outside(scope, path)))])];
243
+ return binding.source_policy === "declared-paths-v1"
244
+ ? declaredScopeDigest(projectRoot, paths, current.hash, true, [], currentBinding)
245
+ : protectedScopeDigest(projectRoot, paths, current.hash, binding.source_policy, [], currentBinding);
246
+ }
207
247
  export async function protectedSnapshot(authorization, options = {}) {
208
248
  const manifestSource = await readFile(authorization.manifestPath).catch(() => undefined);
209
249
  if (manifestSource === undefined)
@@ -256,6 +296,10 @@ export async function refreshProtectedSnapshot(projectRoot, binding) {
256
296
  const current = await snapshotManifest(projectRoot, binding);
257
297
  if (!current)
258
298
  return undefined;
299
+ if (binding.validation_policy === "inputs-and-concrete-outputs-v1") {
300
+ const source = await validatedSourceSnapshot(projectRoot, binding);
301
+ return source === undefined ? undefined : { source, candidate: source };
302
+ }
259
303
  const manifestHash = current.hash;
260
304
  const sourcePaths = binding.source_paths.map(path => resolve(projectRoot, path));
261
305
  const candidatePaths = binding.candidate_paths.map(path => resolve(projectRoot, path));
@@ -274,8 +318,11 @@ export async function refreshProtectedSnapshot(projectRoot, binding) {
274
318
  }
275
319
  }
276
320
  if (binding.source_policy === "declared-paths-v1") {
277
- const source = await declaredScopeDigest(projectRoot, sourcePaths, manifestHash, true, [], binding);
278
- const candidate = await declaredScopeDigest(projectRoot, candidatePaths, manifestHash, false, [], binding, binding.candidate_policy);
321
+ // Reuse identical external inventories only inside this read-only refresh.
322
+ // A later status/completion call always observes filesystem changes anew.
323
+ const artifactsMemo = new Map();
324
+ const source = await declaredScopeDigest(projectRoot, sourcePaths, manifestHash, true, [], binding, undefined, artifactsMemo);
325
+ const candidate = await declaredScopeDigest(projectRoot, candidatePaths, manifestHash, false, [], binding, binding.candidate_policy, artifactsMemo);
279
326
  return source === undefined || candidate === undefined ? undefined : { source, candidate };
280
327
  }
281
328
  // Legacy evidence retains its original recipe; a new policy cannot relabel stale proof as fresh.
@@ -45,9 +45,9 @@ The status token, its icon, TRUE_INTERRUPTION and TRUE_BLOCKER are protocol toke
45
45
  even when the surrounding conclusion is translated. Translate only the display labels and keep their order.
46
46
 
47
47
  DONE requires a succeeded ${profile.toolPrefix}complete_operator receipt only when a current active operator contract
48
- owns the turn; the host renders the measured return report from that receipt. Without it, return INTERRUPTED, BLOCKED, or NEED_DECISION naming
49
- the exact unresolved condition. An exhausted budget, an unapproved or failed proposal, a terminated child, a refused
50
- contract operation, or an unreachable acceptance is an INTERRUPTED return, never a silent stop. A cancelled or completed
48
+ owns the turn; the host renders the measured return report from that receipt. Without it, continue authorized recovery;
49
+ only a real stop, exhausted budget, unrecoverable internal failure or user-only decision permits a terminal return.
50
+ Failed proposals, child failures and refused control calls need local repair first. A cancelled or completed
51
51
  historical operator run does not gate a later ordinary turn, but an explicit cancel in the same turn still requires
52
52
  INTERRUPTED. A later ordinary turn with no active operator contract or accepted execution criteria may use DONE
53
53
  without that receipt; the host verifies that exact uncontracted turn boundary before preserving it.
@@ -57,8 +57,10 @@ A genuine interruption also requires the canonical machine line \`TRUE_INTERRUPT
57
57
  A refused control operation is a local process defect, not a terminal blocker. Read the returned status and
58
58
  next_action, apply that one correction, and continue in the same turn. Never reissue an unchanged refused
59
59
  request: an active contract returns ${profile.toolPrefix}operator_status and the existing next Task, and an
60
- unavailable contract-repair validation resume returns the preserved run state and its decision. When the same
61
- refusal repeats with unchanged state, stop retrying and return one INTERRUPTED checkpoint naming that refusal.
60
+ unavailable contract-repair validation resume returns the preserved run state and its decision. Stop repeating an
61
+ unchanged refused request, not the work: if Operator can Fix within existing permissions, requirements and budget,
62
+ repair or use another supported route in the same turn. Reuse fresh successful checks; show cause, correction and result.
63
+ Only when no authorized recovery remains, stop retrying and return one INTERRUPTED checkpoint naming that refusal.
62
64
 
63
65
  ${canonicalFixture("TERMINAL_STATUS_SEMANTICS_FIXTURE")}
64
66
 
@@ -162,8 +162,8 @@ quality threshold and explicit model/budget choice. Follow AGENTS.md and use the
162
162
  For a Coordinator ready candidate, use operator_status's acceptance_summary: verbatim original
163
163
  requests, anchored cumulative formal validation, current review disposition (author self-recheck
164
164
  is not independent approval) and recorded delivery state.
165
- Historical results are references, not current freshness PASS. Inspect source or evidence only for
166
- concrete unresolved gaps; do not routinely search run archives or reread every source/test file.
165
+ If current evidence covers the request and no concrete gap remains, proceed to acceptance.
166
+ Historical results are not current freshness PASS; read source or artifacts only to resolve a gap.
167
167
  Existing validation freshness and Review guards still apply; this summary does not accept the mission.
168
168
  One compact status check can lead directly to review_mission and its returned Reviewer Task; original
169
169
  requests, diff and checks are supplied automatically. Add traces only for concrete extra information.
@@ -206,8 +206,9 @@ Coordinator's internal Worker/Reviewer/Scout/Advisor tasks stay foreground. Unit
206
206
  New unrelated chat does not cancel, restart, replace, or extend an active Mission. Adopt steering only
207
207
  through start_mission intent=continue; frozen Worker contract changes use intent=replace/cancellation.
208
208
  Do not poll or re-run successful checks. Inspect operator_status only to recover missing durable state.
209
- Ordinary defects return to Coordinator, not the user. Ask through question only for a user-only choice,
210
- an extension beyond the original requirements, or a cumulative budget increase. Resume the same work after
209
+ Operator fixes ordinary path, environment, registration and Review-preparation defects directly when authorized;
210
+ otherwise resume the same author with concrete feedback, not a user turn. Retain mission, evidence and cumulative spend.
211
+ Ask through question only for a user-only choice, an extension beyond the original requirements, or a cumulative budget increase. Resume the same work after
211
212
  the answer. For an approved Mission Worker-unit increase, root calls ${profile.toolPrefix}extend_mission_budget
212
213
  with operator_status.mission_id and the new cumulative max_units (not the increment), then resumes the same
213
214
  Coordinator. This does not change a separate campaign dollar cap or dispatch a Worker. Do not reset spend,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",