sortie-dogs 0.13.6 → 0.13.8

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.6](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.6)**
39
- ([release notes](docs/release-v0.13.6.md)). The default Mission runtime retains the `v010`
38
+ **Current release: [v0.13.8](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.8)**
39
+ ([release notes](docs/release-v0.13.8.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.6-acceptance-reuse-v1`.
41
+ The current asset marker is `0.13.8-native-binding-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.6. SWE-bench is a separate,
51
+ Historical scores below belong to their fixed candidates, not v0.13.8. 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,31 @@ 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.6 runtime updates
96
+ ## v0.13.8 runtime updates
97
+
98
+ PR #161 captures native validation bindings through the readable resolved target of a Windows
99
+ directory junction instead of enumerating its failing logical alias. Logical evidence labels,
100
+ link identity and complete target bytes remain bound; unchanged readable junctions retain their
101
+ evidence identity. External-link/cycle handling, actual source/output freshness and Review remain
102
+ unchanged. No new approval, permissions or model changes. The PR's isolated native Windows fixture
103
+ reached independent Review PASS and accepted completion after resuming the same Mission, without
104
+ rerunning its successful check; this does not claim original product recovery or uninterrupted
105
+ single-turn completion. Release receipts: `_testenv/releases/0.13.8/`;
106
+ see [release notes](docs/release-v0.13.8.md).
107
+
108
+ ## v0.13.7 runtime updates (retained)
109
+
110
+ PRs #158/#159 prevent late write-only reports from falsely invalidating bound non-generating
111
+ native checks, while preserving actual source/output freshness, failed checks and legacy recipes.
112
+ Whole-root grants without a concrete inventory keep full-candidate freshness. Non-Git Mission roots
113
+ can prepare independent Review from declared nested repositories/worktrees/plain outputs, including
114
+ committed content; corrupt Git/HEAD errors remain visible. Operator performs authorized local recovery
115
+ in the same turn without another approval, changed requirements or reset spending. Ubuntu's Git
116
+ filesystem-boundary diagnostic is recognized alongside the standard non-repository error.
117
+ The PR native observation reached Reviewer startup, not final acceptance. Release receipts:
118
+ `_testenv/releases/0.13.7/`; see [release notes](docs/release-v0.13.7.md).
119
+
120
+ ## v0.13.6 runtime updates (retained)
97
121
 
98
122
  PR #156 references recorded formal PASS evidence in acceptance summaries instead of fetching
99
123
  the same successful unit's native history again for display. Missing/failed proof retains history
@@ -424,7 +448,7 @@ register stable and `v010` from the same package installation path in one host.
424
448
  Project-local installation is recommended. To expose the current Mission assets globally:
425
449
 
426
450
  ```sh
427
- npm install --global sortie-dogs@0.13.6
451
+ npm install --global sortie-dogs@0.13.8
428
452
  sortie-dogs init --global --profile v010
429
453
  ```
430
454
 
@@ -436,7 +460,7 @@ can resolve a **separate dependency** under that config root. Updating npm-globa
436
460
  it. For that layout, also install the same release at the actual config root, then rerun global init:
437
461
 
438
462
  ```sh
439
- npm install --prefix "$HOME/.config/opencode" sortie-dogs@0.13.6
463
+ npm install --prefix "$HOME/.config/opencode" sortie-dogs@0.13.8
440
464
  sortie-dogs init --global --profile v010
441
465
  ```
442
466
 
@@ -459,7 +483,7 @@ version, preserves user configuration, and stops safely on unknown ownership or
459
483
  conflicting files.
460
484
 
461
485
  Align any exact version pin or separate bridge dependency with the intended release too. An installed
462
- marker of `0.13.6-acceptance-reuse-v1` identifies the assets; it does not prove an already-running
486
+ marker of `0.13.8-native-binding-v1` identifies the assets; it does not prove an already-running
463
487
  OpenCode process has reloaded the plugin.
464
488
 
465
489
  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.6-acceptance-reuse-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.13.8-native-binding-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.6-acceptance-reuse-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.13.8-native-binding-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) &&
@@ -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;
@@ -92,7 +92,7 @@ async function protectedScopeDigest(projectRoot, paths, manifestHash, sourcePoli
92
92
  const entries = [];
93
93
  const canonicalRoot = await realpath(projectRoot);
94
94
  const scratchExcluded = binding ? snapshotScratchExclusion(binding) : () => false;
95
- const visit = async (absolute, ancestors = new Set(), projectArtifacts = false) => {
95
+ const visit = async (absolute, ancestors = new Set(), projectArtifacts = false, physical = absolute) => {
96
96
  if (excluded.some(root => !outside(root, absolute)))
97
97
  return true;
98
98
  if (scratchExcluded(absolute))
@@ -102,7 +102,7 @@ async function protectedScopeDigest(projectRoot, paths, manifestHash, sourcePoli
102
102
  return false;
103
103
  if ((sourcePolicy === "project-files-v1" || projectArtifacts) && isRuntimeControlPath(scoped))
104
104
  return true;
105
- const metadata = await lstat(absolute).catch((error) => {
105
+ const metadata = await lstat(physical).catch((error) => {
106
106
  if (error.code === "ENOENT")
107
107
  return undefined;
108
108
  throw error;
@@ -112,7 +112,7 @@ async function protectedScopeDigest(projectRoot, paths, manifestHash, sourcePoli
112
112
  return true;
113
113
  }
114
114
  if (metadata.isSymbolicLink()) {
115
- const target = await realpath(absolute).catch(() => undefined);
115
+ const target = await realpath(physical).catch(() => undefined);
116
116
  if (target === undefined)
117
117
  return false;
118
118
  const relativeTarget = relative(canonicalRoot, target);
@@ -120,13 +120,16 @@ async function protectedScopeDigest(projectRoot, paths, manifestHash, sourcePoli
120
120
  ancestors.has(target))
121
121
  return false;
122
122
  const targetMetadata = await stat(target);
123
- const link = await readlink(absolute);
123
+ const link = await readlink(physical);
124
124
  if (targetMetadata.isDirectory()) {
125
125
  // Keep the logical scope path and link target in the digest; do not follow external links or cycles.
126
126
  entries.push([scoped, `symlink:${link}`]);
127
127
  const next = new Set([...ancestors, target]);
128
- for (const child of (await readdir(absolute)).sort())
129
- if (!await visit(join(absolute, child), next, projectArtifacts))
128
+ // Windows compatibility junctions can deny enumeration through the alias while
129
+ // their resolved directory remains readable. Traverse the already resolved target.
130
+ // Retain logical entry labels so unchanged existing evidence keeps its identity.
131
+ for (const child of (await readdir(target)).sort())
132
+ if (!await visit(join(absolute, child), next, projectArtifacts, join(target, child)))
130
133
  return false;
131
134
  return true;
132
135
  }
@@ -136,19 +139,19 @@ async function protectedScopeDigest(projectRoot, paths, manifestHash, sourcePoli
136
139
  return true;
137
140
  }
138
141
  if (metadata.isDirectory()) {
139
- const real = await realpath(absolute);
142
+ const real = await realpath(physical);
140
143
  if (ancestors.has(real))
141
144
  return false;
142
145
  entries.push([scoped, "directory"]);
143
146
  const next = new Set([...ancestors, real]);
144
- for (const child of (await readdir(absolute)).sort())
145
- if (!await visit(join(absolute, child), next, projectArtifacts))
147
+ for (const child of (await readdir(physical)).sort())
148
+ if (!await visit(join(absolute, child), next, projectArtifacts, join(physical, child)))
146
149
  return false;
147
150
  return true;
148
151
  }
149
152
  if (!metadata.isFile())
150
153
  return false;
151
- entries.push([scoped, "file", createHash("sha256").update(await readFile(absolute)).digest("hex")]);
154
+ entries.push([scoped, "file", createHash("sha256").update(await readFile(physical)).digest("hex")]);
152
155
  return true;
153
156
  };
154
157
  for (const path of [...new Set(paths)].sort()) {
@@ -210,6 +213,40 @@ export async function validationInputSnapshot(projectRoot, binding) {
210
213
  ? declaredScopeDigest(projectRoot, paths, hash, true, [], binding)
211
214
  : protectedScopeDigest(projectRoot, paths, hash, binding.source_policy, [], binding);
212
215
  }
216
+ /** Pin a non-generating check's inputs and concrete source outputs, not incidental
217
+ * files created later under a directory execution grant (for example result reports).
218
+ * Exact outputs and tracked source remain protected even when omitted from read. */
219
+ export async function validatedSourceSnapshot(projectRoot, binding) {
220
+ const current = await snapshotManifest(projectRoot, binding);
221
+ if (!current?.manifest.read.length)
222
+ return undefined; // An absent input set retains the full-source recipe.
223
+ const scopes = [...binding.source_paths.map(path => resolve(projectRoot, path)),
224
+ ...current.manifest.write.map(path => resolve(projectRoot, normalizeManifestScope(path).path))];
225
+ const protection = [...new Set([...(binding.freshness?.protected_paths ?? []),
226
+ ...await currentSnapshotProtection(projectRoot, current.manifest)])];
227
+ const reads = current.manifest.read.map(entry => resolve(projectRoot, normalizeManifestScope(entry).path));
228
+ const currentBinding = binding.freshness ? { ...binding, freshness: { ...binding.freshness, protected_paths: protection } } : binding;
229
+ // A populated directory grant without a concrete output/input inventory cannot safely
230
+ // acquire the report-only recipe. Keep the original full-candidate freshness instead.
231
+ for (const entry of binding.validation_policy === undefined ? current.manifest.write : []) {
232
+ const output = resolve(projectRoot, normalizeManifestScope(entry).path);
233
+ if (reads.some(input => !outside(input, output)) || snapshotScratchExcluded(currentBinding, output) ||
234
+ protection.some(path => path !== output && !outside(output, path) && !reads.includes(path)))
235
+ continue;
236
+ const stat = await lstat(output).catch((error) => {
237
+ if (error.code !== "ENOENT")
238
+ throw error;
239
+ return undefined;
240
+ });
241
+ if (stat?.isDirectory() && (await readdir(output)).length)
242
+ return undefined;
243
+ }
244
+ const paths = [...new Set([...reads,
245
+ ...protection.filter(path => scopes.some(scope => !outside(scope, path)))])];
246
+ return binding.source_policy === "declared-paths-v1"
247
+ ? declaredScopeDigest(projectRoot, paths, current.hash, true, [], currentBinding)
248
+ : protectedScopeDigest(projectRoot, paths, current.hash, binding.source_policy, [], currentBinding);
249
+ }
213
250
  export async function protectedSnapshot(authorization, options = {}) {
214
251
  const manifestSource = await readFile(authorization.manifestPath).catch(() => undefined);
215
252
  if (manifestSource === undefined)
@@ -262,6 +299,10 @@ export async function refreshProtectedSnapshot(projectRoot, binding) {
262
299
  const current = await snapshotManifest(projectRoot, binding);
263
300
  if (!current)
264
301
  return undefined;
302
+ if (binding.validation_policy === "inputs-and-concrete-outputs-v1") {
303
+ const source = await validatedSourceSnapshot(projectRoot, binding);
304
+ return source === undefined ? undefined : { source, candidate: source };
305
+ }
265
306
  const manifestHash = current.hash;
266
307
  const sourcePaths = binding.source_paths.map(path => resolve(projectRoot, path));
267
308
  const candidatePaths = binding.candidate_paths.map(path => resolve(projectRoot, path));
@@ -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
 
@@ -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.6",
3
+ "version": "0.13.8",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",