sortie-dogs 0.16.0 → 0.16.1

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.
@@ -3,6 +3,6 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.16.0-purpose-aligned-v2";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.16.1-review-git-boundary-v1";
7
7
  export declare const CODEX_SKILL_ASSET_VERSION = "0.14.6-codex-skill-v1";
8
8
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION | typeof CODEX_SKILL_ASSET_VERSION;
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.16.0-purpose-aligned-v2";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.16.1-review-git-boundary-v1";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.14.6-codex-skill-v1";
@@ -683,12 +683,66 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
683
683
  local.push(scoped || ".");
684
684
  }
685
685
  const toolInput = scope.read.some(path => normalizeManifestScope(path).path.startsWith(TOOL_ENVIRONMENT));
686
- const gitScopes = [...local, ...(toolInput ? [] : [`:(exclude)${TOOL_ENVIRONMENT}`])];
686
+ // A Location may inherit an ancestor repository while its actual outputs live in a
687
+ // nested repository/worktree. Resolve that boundary before asking the outer Git:
688
+ // ls-files can return the whole ignored repository (or fail internally) for a narrow output.
689
+ const rootLocal = [], nested = new Map();
690
+ for (const path of local) {
691
+ let current = resolve(directory), repository;
692
+ for (const part of path.split("/")) {
693
+ current = resolve(current, part);
694
+ const info = await lstat(current).catch((error) => {
695
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR")
696
+ throw error;
697
+ return undefined;
698
+ });
699
+ if (!info?.isDirectory())
700
+ break; // Missing files and symlink targets are not traversed.
701
+ if (current === resolve(directory))
702
+ continue;
703
+ const metadata = await lstat(join(current, ".git")).catch((error) => {
704
+ if (error.code !== "ENOENT")
705
+ throw error;
706
+ return undefined;
707
+ });
708
+ if (metadata)
709
+ repository = current; // Includes .git files and broken worktrees: Git must diagnose them.
710
+ }
711
+ if (repository)
712
+ nested.set(repository, [...(nested.get(repository) ?? []), relative(repository, resolve(directory, path)).replaceAll("\\", "/") || "."]);
713
+ else
714
+ rootLocal.push(path);
715
+ }
716
+ const gitScopes = [...rootLocal, ...(toolInput ? [] : [`:(exclude)${TOOL_ENVIRONMENT}`])];
687
717
  const git = async (args) => (await exec("git", args, { cwd: directory, maxBuffer: 8 * 1024 * 1024 })).stdout;
688
718
  let names = "", ignored = "", repository = true, scopedDiff = "";
689
719
  const untracked = new Set(), currentFiles = new Set();
720
+ const collected = new Set();
721
+ const collectRepository = async (absolute, path, pathspec) => {
722
+ const scopedGit = async (args) => (await exec("git", args, { cwd: absolute, maxBuffer: 8 * 1024 * 1024 })).stdout;
723
+ const tracked = await scopedGit(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspec]);
724
+ const prefix = (name) => join(path, name).replaceAll("\\", "/");
725
+ for (const name of tracked.split("\0").filter(Boolean)) {
726
+ collected.add(prefix(name));
727
+ currentFiles.add(prefix(name));
728
+ }
729
+ for (const name of (await scopedGit(["ls-files", "-z", "--others", "--exclude-standard", "--", ...pathspec])).split("\0").filter(Boolean))
730
+ untracked.add(prefix(name));
731
+ for (const name of (await scopedGit(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", ...pathspec])).split("\0").filter(Boolean)) {
732
+ collected.add(prefix(name));
733
+ untracked.add(prefix(name));
734
+ }
735
+ // The Mission baseline belongs to its root, not these different repositories.
736
+ // Without a baseline here, committed outputs still appear as current content.
737
+ const delta = await currentGitDiff(scopedGit, pathspec);
738
+ if (delta)
739
+ scopedDiff += `\n--- repository output: ${path} ---\n${delta}`;
740
+ };
741
+ for (const [absolute, paths] of nested) {
742
+ await collectRepository(absolute, relative(directory, absolute).replaceAll("\\", "/"), paths);
743
+ }
690
744
  try {
691
- if (local.length) {
745
+ if (rootLocal.length) {
692
746
  names = await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...gitScopes]);
693
747
  for (const path of (await git(["ls-files", "-z", "--others", "--exclude-standard", "--", ...gitScopes])).split("\0").filter(Boolean))
694
748
  untracked.add(path);
@@ -701,7 +755,7 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
701
755
  repository = false;
702
756
  // Mission storage can be outside Git while its declared outputs are repositories,
703
757
  // worktrees or plain files. Inventory those outputs in place, not the storage root.
704
- const collected = new Set(), walked = new Set();
758
+ const walked = new Set();
705
759
  const collect = async (path) => {
706
760
  if (walked.has(path) || excluded(path))
707
761
  return;
@@ -719,11 +773,9 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
719
773
  untracked.add(path);
720
774
  return;
721
775
  }
722
- const scopedGit = async (args) => (await exec("git", args, { cwd: absolute, maxBuffer: 8 * 1024 * 1024 })).stdout;
723
- const pathspec = ["."]; // Shared tooling is rooted at the Mission, not each output repository.
724
- let tracked;
776
+ // Shared tooling is rooted at the Mission, not each output repository.
725
777
  try {
726
- tracked = await scopedGit(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspec]);
778
+ await collectRepository(absolute, path, ["."]);
727
779
  }
728
780
  catch (error) {
729
781
  if (!notRepository(error))
@@ -733,27 +785,11 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
733
785
  }
734
786
  return;
735
787
  }
736
- const prefix = (name) => join(path, name).replaceAll("\\", "/");
737
- for (const name of tracked.split("\0").filter(Boolean)) {
738
- collected.add(prefix(name));
739
- currentFiles.add(prefix(name));
740
- }
741
- for (const name of (await scopedGit(["ls-files", "-z", "--others", "--exclude-standard", "--", ...pathspec])).split("\0").filter(Boolean))
742
- untracked.add(prefix(name));
743
- for (const name of (await scopedGit(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", ...pathspec])).split("\0").filter(Boolean)) {
744
- collected.add(prefix(name));
745
- untracked.add(prefix(name));
746
- }
747
- // The mission's baseline belongs to its root, not to these different repositories.
748
- // With no baseline here, committed outputs still appear as current file content.
749
- const delta = await currentGitDiff(scopedGit, pathspec);
750
- if (delta)
751
- scopedDiff += `\n--- repository output: ${path} ---\n${delta}`;
752
788
  };
753
- for (const path of local)
789
+ for (const path of rootLocal)
754
790
  await collect(path);
755
- names = [...collected].sort().join("\0");
756
791
  }
792
+ names = [...new Set([...names.split("\0").filter(Boolean), ...collected])].sort().join("\0");
757
793
  // Go's ignored in-project caches may be writable during validation but are not candidate
758
794
  // output. Keep every other ignored declared output visible and fingerprinted, including
759
795
  // directories of generated artifacts; focused references can still pin a cache file.
@@ -763,7 +799,7 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
763
799
  const unreadable = [];
764
800
  const outsideLinks = [];
765
801
  let diff = scopedDiff;
766
- if (repository && local.length && displayBaseline) {
802
+ if (repository && rootLocal.length && displayBaseline) {
767
803
  // HEAD-only diffs are empty once the author commits. Display the original mission delta
768
804
  // or the focused correction delta without changing the full fingerprint basis.
769
805
  const changed = (await git(["diff", "--name-only", "-z", "--no-ext-diff", displayBaseline, "--", ...gitScopes]))
@@ -774,7 +810,7 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
774
810
  const available = Math.max(0, 24_000 - Buffer.byteLength(selected + heading) -
775
811
  headers.reduce((size, title) => size + Buffer.byteLength(title), 0) - 500);
776
812
  const allowance = shown.length ? Math.min(3_000, Math.floor(available / shown.length)) : 0;
777
- diff = heading;
813
+ diff += heading;
778
814
  for (const [index, path] of shown.entries()) {
779
815
  const patch = await git(["diff", "--no-ext-diff", "--no-textconv", displayBaseline, "--", path]);
780
816
  const bytes = Buffer.from(patch);
@@ -788,8 +824,8 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
788
824
  }
789
825
  omitted.push(...changed.slice(shown.length));
790
826
  }
791
- else if (repository && local.length) {
792
- diff = await currentGitDiff(git, gitScopes);
827
+ else if (repository && rootLocal.length) {
828
+ diff += await currentGitDiff(git, gitScopes);
793
829
  }
794
830
  const unchanged = diff.length === 0;
795
831
  let excerpt = selected + diff;
@@ -25,6 +25,7 @@ export declare class NativeBackgroundLifecycle {
25
25
  private readonly recoverResult?;
26
26
  private readonly operations;
27
27
  private readonly restored;
28
+ private readonly reconciliationErrors;
28
29
  constructor(context: OpenCodeV2Context, settle: (dispatch: NativeBackgroundDispatch, text: string) => Promise<string | void>, owner?: ((callID: string, restore?: ObjectValue) => ObjectValue | undefined) | undefined, recoverResult?: ((parent: string, callID: string, child: string, promptID?: string) => Promise<string | undefined>) | undefined);
29
30
  private serial;
30
31
  private read;
@@ -33,6 +34,7 @@ export declare class NativeBackgroundLifecycle {
33
34
  bind(parent: string, child: string, agent: string, prompt: string, promptID?: string): Promise<void>;
34
35
  launched(parent: string, callID: string, child: string | undefined, running: boolean): Promise<boolean>;
35
36
  awaiting(sessionID: string): Promise<boolean>;
37
+ reconciliationNotice(parent: string): string | undefined;
36
38
  reconcile(parent: string): Promise<void>;
37
39
  /** Add provenance beside its existing native notification, without editing history or waking the parent again. */
38
40
  project(parent: string, messages: readonly unknown[]): Promise<unknown[]>;
@@ -9,6 +9,7 @@ export class NativeBackgroundLifecycle {
9
9
  recoverResult;
10
10
  operations = new Map();
11
11
  restored = new Set();
12
+ reconciliationErrors = new Map();
12
13
  constructor(context, settle, owner, recoverResult) {
13
14
  this.context = context;
14
15
  this.settle = settle;
@@ -85,8 +86,22 @@ export class NativeBackgroundLifecycle {
85
86
  return object(info) && typeof info.parentID === "string" &&
86
87
  (await this.read(info.parentID)).some(item => !item.settled && item.child === sessionID);
87
88
  }
89
+ reconciliationNotice(parent) { return this.reconciliationErrors.get(parent); }
88
90
  async reconcile(parent) {
89
- await this.serial(parent, async () => this.reconcileInside(parent, await this.read(parent)));
91
+ try {
92
+ await this.serial(parent, async () => this.reconcileInside(parent, await this.read(parent)));
93
+ this.reconciliationErrors.delete(parent);
94
+ }
95
+ catch (error) {
96
+ // This is recovery of existing native work, not admission or acceptance. A failed
97
+ // inspection must retain its terminal/reservation for idempotent retry without
98
+ // making the next user prompt, model request or compaction fail before it starts.
99
+ const reason = error instanceof Error ? error.message : String(error);
100
+ console.warn("[sortie-dogs-v010] Background reconciliation unavailable; saved work retained", reason);
101
+ this.reconciliationErrors.set(parent, `SORTIE_BACKGROUND_RECONCILIATION_UNAVAILABLE\n${JSON.stringify(reason.slice(0, 2_000))}\n` +
102
+ "Native work/results and cumulative spend are retained; formal validation and acceptance are not proven by this recovery failure. " +
103
+ "Diagnose the failed inspection and reconcile this same invocation after repair. Do not relaunch or repeat an already completed external operation just to repair accounting.");
104
+ }
90
105
  }
91
106
  /** Add provenance beside its existing native notification, without editing history or waking the parent again. */
92
107
  async project(parent, messages) {
package/dist/plugin/v2.js CHANGED
@@ -847,6 +847,9 @@ async function registerV2Hooks(context, hooks, background, liveState) {
847
847
  event.messages = await background.project(String(event.sessionID ?? ""), event.messages);
848
848
  const output = { system: [] };
849
849
  await hooks["experimental.chat.system.transform"]({ sessionID: String(event.sessionID ?? "") }, output);
850
+ const reconciliation = background.reconciliationNotice(String(event.sessionID ?? ""));
851
+ if (reconciliation)
852
+ (output.system ??= []).push(reconciliation);
850
853
  await reconcileCorrectionPermissions(String(event.sessionID));
851
854
  if (record(event.tools)) {
852
855
  const correcting = correctionAgent(event.agent) && !!await hooks.reviewerCorrectionScope?.(String(event.sessionID));
@@ -913,6 +916,9 @@ async function registerV2Hooks(context, hooks, background, liveState) {
913
916
  }
914
917
  const output = { context: [] };
915
918
  await hooks["experimental.session.compacting"]({ sessionID: String(event.sessionID ?? "") }, output);
919
+ const reconciliation = background.reconciliationNotice(String(event.sessionID ?? ""));
920
+ if (reconciliation)
921
+ (output.context ??= []).push(reconciliation);
916
922
  if (Array.isArray(event.system))
917
923
  event.system.push(...output.context.map(text => ({ type: "text", text })));
918
924
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Adaptive execution harness for OpenCode and Codex: goal retention, validation, review, and evidence-backed handoff",
5
5
  "keywords": [
6
6
  "opencode",