sortie-dogs 0.5.2 → 0.5.4

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
@@ -20,7 +20,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
20
20
 
21
21
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
22
22
 
23
- Release: [v0.5.2](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.2)
23
+ Release: [v0.5.4](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.4)
24
24
 
25
25
  ## Quick start
26
26
 
@@ -2,5 +2,5 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export declare const RUNTIME_ASSET_VERSION = "0.3.33-readable-terminal-report-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.34-parallel-host-contract-v1";
6
6
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
@@ -2,4 +2,4 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export const RUNTIME_ASSET_VERSION = "0.3.33-readable-terminal-report-v1";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.34-parallel-host-contract-v1";
@@ -4,6 +4,7 @@ export declare class WorktreeCommitArtifactError extends Error {
4
4
  readonly code: WorktreeCommitArtifactErrorCode;
5
5
  constructor(code: WorktreeCommitArtifactErrorCode, message: string);
6
6
  }
7
+ export declare function resolveValidationExecutable(executable: string): Promise<string | undefined>;
7
8
  /** Runs one bounded command without exposing output or performing Git mutations. */
8
9
  export declare function runContainedValidation(request: ContainedValidationRequest): Promise<ContainedValidationResult>;
9
10
  export declare function produceWorktreeCommitArtifact(request: WorktreeCommitProduceRequest): Promise<WorktreeCommitArtifact>;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { lstat, realpath } from "node:fs/promises";
4
- import { isAbsolute, join, resolve, sep } from "node:path";
4
+ import { delimiter, extname, isAbsolute, join, resolve, sep } from "node:path";
5
5
  import { normalizeWorktreeScopePath } from "./worktree-scope.js";
6
6
  const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u;
7
7
  const HASH = /^[0-9a-f]{64}$/u;
@@ -368,6 +368,26 @@ function exactKeys(value, keys) {
368
368
  function validText(value, max = 256) {
369
369
  return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/u.test(value);
370
370
  }
371
+ export async function resolveValidationExecutable(executable) {
372
+ if (isAbsolute(executable))
373
+ return realpath(executable).catch(() => undefined);
374
+ if (!validText(executable, MAX_COMMAND_TEXT) || executable.startsWith("-") || /[\\/]/u.test(executable))
375
+ return undefined;
376
+ const path = process.env.PATH ?? process.env.Path;
377
+ if (path === undefined)
378
+ return undefined;
379
+ const extensions = process.platform === "win32" && extname(executable).length === 0
380
+ ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((value) => value.length > 0)
381
+ : [""];
382
+ for (const directory of path.split(delimiter)) {
383
+ for (const extension of extensions) {
384
+ const candidate = await realpath(resolve(directory || ".", `${executable}${extension}`)).catch(() => undefined);
385
+ if (candidate !== undefined && isAbsolute(candidate))
386
+ return candidate;
387
+ }
388
+ }
389
+ return undefined;
390
+ }
371
391
  function pathIdentity(value) {
372
392
  const normalized = resolve(value).split(sep).join("/");
373
393
  return process.platform === "win32" ? normalized.toLowerCase() : normalized;
@@ -550,6 +570,10 @@ async function runBounded(executable, args, cwd, timeout, kind) {
550
570
  clearTimeout(timer);
551
571
  }
552
572
  }
573
+ function boundedCommandKind(executable, args) {
574
+ return /(?:^|[\\/])git(?:\.exe)?$/iu.test(executable) && args.length === 2 &&
575
+ args[0] === "diff" && args[1] === "--check" ? "git" : "validation";
576
+ }
553
577
  /** Runs one bounded command without exposing output or performing Git mutations. */
554
578
  export async function runContainedValidation(request) {
555
579
  const suppliedArgs = isRecord(request) && Array.isArray(request.args) ? request.args : [];
@@ -560,21 +584,23 @@ export async function runContainedValidation(request) {
560
584
  const failed = (command, exitCode, error) => Object.freeze({ ok: false, command, exit_code: exitCode,
561
585
  fingerprint: createHash("sha256").update(JSON.stringify([command, exitCode, error])).digest("hex"), error });
562
586
  if (!isRecord(request) || !exactKeys(request, ["cwd", "executable", "timeout_ms", ...(request.args === undefined ? [] : ["args"])]) ||
563
- !validText(request.executable, MAX_COMMAND_TEXT) || !isAbsolute(request.executable) ||
587
+ !validText(request.executable, MAX_COMMAND_TEXT) ||
588
+ (!isAbsolute(request.executable) && (request.executable.startsWith("-") || /[\\/]/u.test(request.executable))) ||
564
589
  !validText(request.cwd, MAX_PATH) || !isAbsolute(request.cwd) ||
565
590
  !Number.isInteger(request.timeout_ms) || request.timeout_ms < 1 || request.timeout_ms > MAX_TIMEOUT ||
566
591
  !Array.isArray(suppliedArgs) || suppliedArgs.length > MAX_ARGUMENTS ||
567
592
  !suppliedArgs.every((arg) => validText(arg, MAX_COMMAND_TEXT)))
568
593
  return failed(fallbackCommand, null, "invalid-request");
569
594
  const [executable, cwd] = await Promise.all([
570
- realpath(request.executable).catch(() => undefined), realpath(request.cwd).catch(() => undefined),
595
+ resolveValidationExecutable(request.executable),
596
+ realpath(request.cwd).catch(() => undefined),
571
597
  ]);
572
- if (executable === undefined || cwd === undefined || !isAbsolute(executable) || !isAbsolute(cwd)) {
598
+ if (executable === undefined || cwd === undefined || !isAbsolute(cwd)) {
573
599
  return failed(fallbackCommand, null, "invalid-request");
574
600
  }
575
601
  const command = Object.freeze([executable, ...suppliedArgs]);
576
602
  try {
577
- const result = await runBounded(executable, suppliedArgs, cwd, request.timeout_ms, "validation");
603
+ const result = await runBounded(executable, suppliedArgs, cwd, request.timeout_ms, boundedCommandKind(executable, suppliedArgs));
578
604
  const fingerprint = createHash("sha256").update(JSON.stringify([command, result.code])).digest("hex");
579
605
  return result.code === 0
580
606
  ? Object.freeze({ ok: true, command, exit_code: 0, fingerprint, error: null })
@@ -679,12 +705,15 @@ function parseStatus(source, staged) {
679
705
  throw new WorktreeCommitArtifactError("invalid-state", "Git status is ambiguous.");
680
706
  const x = field[0];
681
707
  const y = field[1];
682
- const untracked = staged === "forbid" && x === "?" && y === "?";
708
+ const untracked = x === "?" && y === "?";
683
709
  if (!untracked && (x === "?" || y === "?" || x === "!" || y === "!" || "RCUT".includes(x) || "RCUT".includes(y))) {
684
710
  throw new WorktreeCommitArtifactError("invalid-state", "Untracked, renamed, copied, or unsupported changes are forbidden.");
685
711
  }
686
- if ((!untracked && staged === "forbid" && (x !== " " || y !== "M" && y !== "D")) ||
687
- (staged === "require" && ((x !== "A" && x !== "M" && x !== "D") || y !== " "))) {
712
+ const unstagedEdit = x === " " && (y === "M" || y === "D");
713
+ const stagedEdit = (x === "A" || x === "M" || x === "D") && y === " ";
714
+ if ((!untracked && staged === "forbid" && !unstagedEdit) ||
715
+ (!untracked && staged === "require" && !stagedEdit) ||
716
+ (!untracked && staged === "either" && !unstagedEdit && !stagedEdit)) {
688
717
  throw new WorktreeCommitArtifactError("invalid-state", "Index or worktree status is not an accepted implementation edit.");
689
718
  }
690
719
  const path = field.slice(3);
@@ -695,7 +724,7 @@ function parseStatus(source, staged) {
695
724
  catch {
696
725
  throw new WorktreeCommitArtifactError("invalid-state", "Changed path is not canonical.");
697
726
  }
698
- entries.push({ code: untracked ? "A" : staged === "forbid" ? y : x, path });
727
+ entries.push({ code: untracked ? "A" : stagedEdit ? x : y, path });
699
728
  if (folded.length === 0)
700
729
  throw new WorktreeCommitArtifactError("invalid-state", "Changed path is invalid.");
701
730
  }
@@ -714,9 +743,34 @@ function assertScope(entries, scope) {
714
743
  }
715
744
  }
716
745
  async function status(context, staged) {
717
- return parseStatus(await context.git([
746
+ const entries = parseStatus(await context.git([
718
747
  "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none",
719
748
  ]), staged);
749
+ const controls = new Set([
750
+ `handoff.${context.descriptor.task_id}.json`,
751
+ `${context.descriptor.task_id}.operation-manifest.json`,
752
+ ]);
753
+ return entries.filter(({ code, path }) => code !== "A" || !controls.has(path));
754
+ }
755
+ async function stagedPaths(context) {
756
+ if ((await context.git(["ls-files", "-u", "-z"])).length !== 0) {
757
+ throw new WorktreeCommitArtifactError("invalid-state", "An unmerged index is forbidden.");
758
+ }
759
+ const fields = decodeGitOutput(await context.git([
760
+ "diff", "--cached", "--name-only", "--no-renames", "-z", "--",
761
+ ]), "Staged path encoding is invalid.").split("\0");
762
+ if (fields.at(-1) !== "")
763
+ throw new WorktreeCommitArtifactError("invalid-state", "Staged paths are ambiguous.");
764
+ fields.pop();
765
+ for (const path of fields) {
766
+ if (normalizeWorktreeScopePath(path) !== path) {
767
+ throw new WorktreeCommitArtifactError("invalid-state", "A staged path is not canonical.");
768
+ }
769
+ }
770
+ if (new Set(fields.map((path) => path.toLowerCase())).size !== fields.length) {
771
+ throw new WorktreeCommitArtifactError("invalid-state", "Staged path identities are ambiguous.");
772
+ }
773
+ return new Set(fields);
720
774
  }
721
775
  async function assertNoSubmodules(context, entries) {
722
776
  for (const entry of entries) {
@@ -884,9 +938,7 @@ async function committedEntries(context, base, commit) {
884
938
  }
885
939
  async function validateCommit(context, artifact) {
886
940
  await assertBaseState(context, artifact.commit_sha);
887
- if ((await context.git([
888
- "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none",
889
- ])).length !== 0) {
941
+ if ((await status(context, "forbid")).length !== 0) {
890
942
  throw new WorktreeCommitArtifactError("verification-failed", "Committed checkout is not clean.");
891
943
  }
892
944
  const parentLine = (await context.git(["rev-list", "--parents", "-n", "1", artifact.commit_sha])).toString("utf8").trim().split(" ");
@@ -934,7 +986,9 @@ export async function produceWorktreeCommitArtifact(request) {
934
986
  if (!isRecord(request) || !exactKeys(request, ["descriptor", "managed_path", "validation", ...(request.git_path === undefined ? [] : ["git_path"])]) ||
935
987
  !isRecord(request.validation) || !exactKeys(request.validation, ["executable", ...(request.validation.args === undefined ? [] : ["args"]),
936
988
  ...(request.validation.timeout_ms === undefined ? [] : ["timeout_ms"])]) ||
937
- !validText(request.validation.executable, MAX_COMMAND_TEXT) || !isAbsolute(request.validation.executable) ||
989
+ !validText(request.validation.executable, MAX_COMMAND_TEXT) ||
990
+ (!isAbsolute(request.validation.executable) &&
991
+ (request.validation.executable.startsWith("-") || /[\\/]/u.test(request.validation.executable))) ||
938
992
  (request.validation.args !== undefined && (!Array.isArray(request.validation.args) || request.validation.args.length > MAX_ARGUMENTS ||
939
993
  !request.validation.args.every((arg) => validText(arg, MAX_COMMAND_TEXT)))) ||
940
994
  (request.validation.timeout_ms !== undefined && (!Number.isInteger(request.validation.timeout_ms) ||
@@ -942,26 +996,38 @@ export async function produceWorktreeCommitArtifact(request) {
942
996
  throw new WorktreeCommitArtifactError("invalid-request", "Commit producer request is invalid.");
943
997
  }
944
998
  const context = await makeContext(request);
945
- const executable = await realpath(request.validation.executable).catch(() => undefined);
999
+ const executable = await resolveValidationExecutable(request.validation.executable);
946
1000
  if (executable === undefined || !isAbsolute(executable)) {
947
1001
  throw new WorktreeCommitArtifactError("invalid-request", "Validation executable does not exist.");
948
1002
  }
949
1003
  const command = Object.freeze([executable, ...(request.validation.args ?? [])]);
950
1004
  await assertBaseState(context, context.descriptor.base_sha);
951
- const before = await status(context, "forbid");
1005
+ const before = await status(context, "either");
952
1006
  if (before.length === 0)
953
1007
  throw new WorktreeCommitArtifactError("invalid-state", "No implementation changes exist.");
954
1008
  assertScope(before, context.descriptor.scope_write);
955
1009
  await assertNoSubmodules(context, before);
1010
+ const staged = await stagedPaths(context);
1011
+ const hostStaged = staged.size > 0;
1012
+ if (hostStaged && (boundedCommandKind(executable, request.validation.args ?? []) !== "git" ||
1013
+ staged.size !== before.length || !before.every(({ path }) => staged.has(path)))) {
1014
+ throw new WorktreeCommitArtifactError("invalid-state", "Staged changes are not an exact host snapshot of the implementation.");
1015
+ }
956
1016
  const beforeFingerprint = await changeFingerprint(context, before, "worktree");
957
- const validation = await runBounded(executable, request.validation.args ?? [], context.managedPath, request.validation.timeout_ms ?? GIT_TIMEOUT, "validation");
1017
+ const validation = await runBounded(executable, request.validation.args ?? [], context.managedPath, request.validation.timeout_ms ?? GIT_TIMEOUT, boundedCommandKind(executable, request.validation.args ?? []));
958
1018
  if (validation.code !== 0)
959
1019
  throw new WorktreeCommitArtifactError("validation-failed", validation.code === 240 ? "Validation containment setup failed."
960
1020
  : validation.code === 241 ? "Validation left a descendant process."
961
1021
  : validation.code === 238 ? "Validation exceeded its resource bound."
962
1022
  : "Validation exited unsuccessfully.");
1023
+ if (hostStaged) {
1024
+ const stagedValidation = await runBounded(executable, ["diff", "--cached", "--check"], context.managedPath, request.validation.timeout_ms ?? GIT_TIMEOUT, "git");
1025
+ if (stagedValidation.code !== 0) {
1026
+ throw new WorktreeCommitArtifactError("validation-failed", "Staged implementation validation exited unsuccessfully.");
1027
+ }
1028
+ }
963
1029
  await assertBaseState(context, context.descriptor.base_sha);
964
- const after = await status(context, "forbid");
1030
+ const after = await status(context, "either");
965
1031
  assertScope(after, context.descriptor.scope_write);
966
1032
  await assertNoSubmodules(context, after);
967
1033
  if (JSON.stringify(after) !== JSON.stringify(before) || await changeFingerprint(context, after, "worktree") !== beforeFingerprint) {
@@ -1000,7 +1066,9 @@ export async function recoverWorktreeCommitArtifact(request) {
1000
1066
  if (!isRecord(request) || !exactKeys(request, ["descriptor", "managed_path", "validation", ...(request.git_path === undefined ? [] : ["git_path"])]) ||
1001
1067
  !isRecord(request.validation) || !exactKeys(request.validation, ["executable", ...(request.validation.args === undefined ? [] : ["args"]),
1002
1068
  ...(request.validation.timeout_ms === undefined ? [] : ["timeout_ms"])]) ||
1003
- !validText(request.validation.executable, MAX_COMMAND_TEXT) || !isAbsolute(request.validation.executable) ||
1069
+ !validText(request.validation.executable, MAX_COMMAND_TEXT) ||
1070
+ (!isAbsolute(request.validation.executable) &&
1071
+ (request.validation.executable.startsWith("-") || /[\\/]/u.test(request.validation.executable))) ||
1004
1072
  (request.validation.args !== undefined && (!Array.isArray(request.validation.args) || request.validation.args.length > MAX_ARGUMENTS ||
1005
1073
  !request.validation.args.every((arg) => validText(arg, MAX_COMMAND_TEXT)))) ||
1006
1074
  (request.validation.timeout_ms !== undefined && (!Number.isInteger(request.validation.timeout_ms) ||
@@ -1008,7 +1076,7 @@ export async function recoverWorktreeCommitArtifact(request) {
1008
1076
  throw new WorktreeCommitArtifactError("invalid-request", "Commit recovery request is invalid.");
1009
1077
  }
1010
1078
  const context = await makeContext(request);
1011
- const executable = await realpath(request.validation.executable).catch(() => undefined);
1079
+ const executable = await resolveValidationExecutable(request.validation.executable);
1012
1080
  if (executable === undefined || !isAbsolute(executable)) {
1013
1081
  throw new WorktreeCommitArtifactError("invalid-request", "Validation executable does not exist.");
1014
1082
  }
@@ -1019,9 +1087,7 @@ export async function recoverWorktreeCommitArtifact(request) {
1019
1087
  }
1020
1088
  if (state.head === context.descriptor.base_sha)
1021
1089
  return undefined;
1022
- if ((await context.git([
1023
- "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none",
1024
- ])).length !== 0) {
1090
+ if ((await status(context, "forbid")).length !== 0) {
1025
1091
  throw new WorktreeCommitArtifactError("invalid-state", "Managed checkout is not clean.");
1026
1092
  }
1027
1093
  const parentLine = (await context.git(["rev-list", "--parents", "-n", "1", state.head])).toString("utf8").trim().split(" ");
@@ -20,7 +20,7 @@ const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u;
20
20
  const HASH = /^[0-9a-f]{64}$/u;
21
21
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
22
22
  const DEVICE_ID = /^[1-9][0-9]*$/u;
23
- const MAX_SAFE_FILE_ID = BigInt(Number.MAX_SAFE_INTEGER);
23
+ const MAX_FILE_ID = (1n << 64n) - 1n;
24
24
  const ACTIVE_PATH = /^wt-([0-9a-f]{16})-([0-9a-f]{32})$/u;
25
25
  const QUARANTINE_PATH = /^rm-([0-9a-f]{16})-([0-9a-f]{32})$/u;
26
26
  const PHASES = new Set(["creating", "setting-up", "ready", "removing", "orphaned"]);
@@ -53,10 +53,10 @@ function pathWithin(path, parent) {
53
53
  return childID === parentID || childID.startsWith(`${parentID}/`);
54
54
  }
55
55
  function safeStoredFileID(value) {
56
- return typeof value === "string" && DEVICE_ID.test(value) && BigInt(value) <= MAX_SAFE_FILE_ID;
56
+ return typeof value === "string" && DEVICE_ID.test(value) && BigInt(value) <= MAX_FILE_ID;
57
57
  }
58
58
  function fileID(path, value, kind) {
59
- const native = value > 0n && value <= MAX_SAFE_FILE_ID ? value.toString() : undefined;
59
+ const native = value > 0n && value <= MAX_FILE_ID ? value.toString() : undefined;
60
60
  if (native !== undefined)
61
61
  return native;
62
62
  if (process.platform !== "win32" || (kind === "inode" && value === 0n))
@@ -885,7 +885,7 @@ export function createContinuationHooks(client, directory, policySource, timings
885
885
  identity.agent !== policy().agent)
886
886
  return;
887
887
  }
888
- if (pending || state?.ownsHostContinuation === true)
888
+ if (pending || (state?.ownsHostContinuation === true && input.overflow !== true))
889
889
  output.enabled = false;
890
890
  },
891
891
  observeModel(sessionID, model, synthetic = false) {
@@ -6,6 +6,7 @@ export declare class FastLaneDeniedError extends Error {
6
6
  }
7
7
  export interface FastLaneToolOptions {
8
8
  readonly consultationFallbackAuthorized?: boolean;
9
+ readonly parallelWorkerAlreadyBound?: boolean;
9
10
  readonly parallelWorkerAuthorized?: boolean;
10
11
  }
11
12
  export declare class FastLaneController {
@@ -165,7 +165,7 @@ export class FastLaneController {
165
165
  state.parallelMode = true;
166
166
  state.workerLimit = totalTasks;
167
167
  state.workerDispatches = running;
168
- state.totalWorkerDispatches = Math.max(state.totalWorkerDispatches, dispatched);
168
+ state.totalWorkerDispatches = dispatched;
169
169
  }
170
170
  continuationQueued(sessionID) {
171
171
  const state = this.sessions.get(sessionID);
@@ -224,9 +224,19 @@ export class FastLaneController {
224
224
  throw new FastLaneDeniedError("WORKER_RESUME_INVALID");
225
225
  }
226
226
  if (state.parallelMode) {
227
- if (options.parallelWorkerAuthorized !== true || state.workerDispatches >= state.parallelWorkerLimit ||
228
- state.totalWorkerDispatches >= state.workerLimit)
227
+ if (options.parallelWorkerAuthorized !== true)
229
228
  throw new FastLaneDeniedError("WORKER_LIMIT");
229
+ if (options.parallelWorkerAlreadyBound === true) {
230
+ if (state.workerDispatches < 1 || state.workerDispatches > state.parallelWorkerLimit ||
231
+ state.totalWorkerDispatches < 1 || state.totalWorkerDispatches > state.workerLimit) {
232
+ throw new FastLaneDeniedError("WORKER_LIMIT");
233
+ }
234
+ state.workerInFlight = true;
235
+ return;
236
+ }
237
+ if (state.workerDispatches >= state.parallelWorkerLimit || state.totalWorkerDispatches >= state.workerLimit) {
238
+ throw new FastLaneDeniedError("WORKER_LIMIT");
239
+ }
230
240
  }
231
241
  else if (state.backlogDrain &&
232
242
  (state.workerDispatches >= 1 || state.totalWorkerDispatches >= state.workerLimit)) {