sortie-dogs 0.5.10 → 0.5.13

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.10](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.10)
23
+ Release: [v0.5.13](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.13)
24
24
 
25
25
  ## Quick start
26
26
 
@@ -164,13 +164,19 @@ options. OpenCode plugin normalization may omit factory options, so use the
164
164
  global file for durable global settings.
165
165
 
166
166
  - `operationManifestPath` moves the manifest; the path is project-relative.
167
- - `handoffPaths` lists the handoff files the plugin inspects. A worker can only
167
+ - `handoffPaths` lists the handoff files the plugin inspects. A worker can only
168
168
  bind after one of these files passes inspection, so an empty list disables
169
169
  binding entirely. Relative entries are also candidate-relative in a nested
170
170
  repository: a child candidate may use its own `handoff.json` while OpenCode is
171
171
  opened at the parent workspace. For operational work the coordinator creates
172
172
  that valid handoff before dispatch and sends its exact absolute path; the
173
- binding child must use the built-in Read tool on it immediately before bind.
173
+ binding child must use the built-in Read tool on it immediately before bind.
174
+ New coordinator contracts are emitted under the candidate-relative
175
+ `.sortie-dogs/contracts/` directory as `handoff.<id>.json` and
176
+ `<id>.operation-manifest.json`. The directory is ignored by this repository's
177
+ `.gitignore`; legacy root/scoped paths and configured custom paths remain
178
+ readable and preflight-compatible, but are never moved or deleted.
179
+ Remove the directory only when no Sortie run is active.
174
180
  - `readOnlyTools` adds host-specific tool names that never change files, such as
175
181
  MCP tools. Unknown tools are denied for a bound session by default.
176
182
  - `dedicatedWorkerModel` selects the single model every worker role resolves to.
@@ -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.44-command-reauthorization-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.47-autonomous-recovery-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.44-command-reauthorization-v1";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.47-autonomous-recovery-v1";
@@ -749,6 +749,8 @@ async function status(context, staged) {
749
749
  const controls = new Set([
750
750
  `handoff.${context.descriptor.task_id}.json`,
751
751
  `${context.descriptor.task_id}.operation-manifest.json`,
752
+ `.sortie-dogs/contracts/handoff.${context.descriptor.task_id}.json`,
753
+ `.sortie-dogs/contracts/${context.descriptor.task_id}.operation-manifest.json`,
752
754
  ]);
753
755
  return entries.filter(({ code, path }) => code !== "A" || !controls.has(path));
754
756
  }
@@ -33,8 +33,6 @@ export const DEFAULT_MAX_AUTO_CONTINUES = 10;
33
33
  */
34
34
  export const STEP_EXHAUSTED_PATTERN = /(?:最大step(?:s|数)?(?:に)?到達|step(?:s)?\s*(?:limit|budget)\s*(?:reached|exhausted)|maximum\s+steps?\s+reached)[\s\S]{0,2500}(?:残作業|未完了|未完|次action|next\s+action|remaining\s+work)/iu;
35
35
  const MAX_TRACKED_SESSIONS = 256;
36
- const MAX_STEP_CONTINUES_PER_SEGMENT = 2;
37
- const MAX_STEP_CONTINUES_PER_TURN = 4;
38
36
  const DEFAULT_TIMINGS = {
39
37
  /** Compaction is expensive; one rollover per minute per session is the canonical ceiling. */
40
38
  cooldownMilliseconds: 60_000,
@@ -271,9 +269,8 @@ export function createContinuationHooks(client, directory, policySource, timings
271
269
  resumeAttempts: 0,
272
270
  textCompleting: false,
273
271
  directUsed: false,
274
- stepContinueCount: 0,
275
- stepContinueTotal: 0,
276
272
  stepContinueIssuing: false,
273
+ stepRecoveryActive: false,
277
274
  touched: Date.now(),
278
275
  };
279
276
  sessions.set(sessionID, created);
@@ -350,8 +347,53 @@ export function createContinuationHooks(client, directory, policySource, timings
350
347
  if (!promptCallSucceeded(resumed))
351
348
  throw new Error("resume request rejected");
352
349
  }
350
+ function topLevelProtocolLines(text) {
351
+ const lines = [];
352
+ let fence;
353
+ for (const [index, line] of text.split(/\r?\n/u).entries()) {
354
+ if (fence === undefined) {
355
+ const opener = /^[ \t]*(`{3,}|~{3,})/u.exec(line)?.[1];
356
+ if (opener === undefined) {
357
+ if (!/^[ \t]*>/u.test(line))
358
+ lines.push({ index, line });
359
+ continue;
360
+ }
361
+ fence = { character: opener[0], length: opener.length };
362
+ continue;
363
+ }
364
+ const closer = /^[ \t]*(`{3,}|~{3,})[ \t]*$/u.exec(line)?.[1];
365
+ if (closer?.[0] === fence.character && closer.length >= fence.length)
366
+ fence = undefined;
367
+ }
368
+ return lines;
369
+ }
370
+ function checkpointEntries(text) {
371
+ const entries = [];
372
+ for (const { index, line } of topLevelProtocolLines(text)) {
373
+ const explicit = /^status:\s*(DONE|BLOCKED|NEED_DECISION)\b/iu.exec(line)?.[1]?.toUpperCase();
374
+ const status = explicit ??
375
+ (/^✅[ \t]+\*\*DONE\*\*/u.test(line) ? "DONE" :
376
+ /^⛔[ \t]+\*\*BLOCKED\*\*/u.test(line) ? "BLOCKED" :
377
+ /^❓[ \t]+\*\*NEED_DECISION\*\*/u.test(line) ? "NEED_DECISION" : undefined);
378
+ if (status === "DONE" || status === "BLOCKED" || status === "NEED_DECISION") {
379
+ entries.push({ index, status });
380
+ }
381
+ }
382
+ return entries;
383
+ }
384
+ function checkpointStatus(text) {
385
+ return checkpointEntries(text).at(-1)?.status;
386
+ }
353
387
  function terminalCheckpoint(text) {
354
- return /\bstatus:\s*(?:DONE|BLOCKED|NEED_DECISION)\b/iu.test(text);
388
+ const status = checkpointStatus(text);
389
+ return status === "DONE" || status === "NEED_DECISION" || (status === "BLOCKED" && trueBlockerReport(text));
390
+ }
391
+ function trueBlockerReport(text) {
392
+ const checkpoint = checkpointEntries(text).at(-1);
393
+ if (checkpoint?.status !== "BLOCKED")
394
+ return false;
395
+ return topLevelProtocolLines(text).some(({ line, index }) => index > checkpoint.index &&
396
+ /^TRUE_BLOCKER\s*:\s*(?:external|user-decision)\s*:\s*\S.*$/u.test(line));
355
397
  }
356
398
  function latestProgressLine(text) {
357
399
  return text.split(/\r?\n/u).filter((line) => line.includes("📊")).at(-1);
@@ -382,19 +424,23 @@ export function createContinuationHooks(client, directory, policySource, timings
382
424
  return false;
383
425
  if (/➡️\s*(?:次action|next_action)\s*:\s*\S/iu.test(text))
384
426
  return true;
427
+ if (checkpointStatus(text) === "BLOCKED" && !trueBlockerReport(text))
428
+ return true;
385
429
  const percent = latestProgressPercent(text);
386
- return percent !== undefined && (percent < 100 || batchHasIndependentRemainder(text));
430
+ if (percent !== undefined)
431
+ return percent < 100 || batchHasIndependentRemainder(text);
432
+ // Coordinators sometimes omit the optional percentage while retaining a localized progress label.
433
+ return /📊\s*(?:進行中|处理中|in[ -]?progress)(?:\s|[::]|$)/iu.test(latestProgressLine(text) ?? "");
387
434
  }
388
435
  async function issueStepContinue(sessionID, state, report, agent) {
389
436
  const resume = client?.session?.promptAsync;
390
437
  const active = policy();
391
438
  if (resume === undefined || !active.enabled || active.agent !== agent || state.stepContinueIssuing ||
392
- state.stepContinueCount >= MAX_STEP_CONTINUES_PER_SEGMENT ||
393
- state.stepContinueTotal >= MAX_STEP_CONTINUES_PER_TURN)
439
+ (state.lastStepContinueRevision === state.turnRevision && state.lastStepContinueReport === report))
394
440
  return;
441
+ const revision = state.turnRevision;
395
442
  state.stepContinueIssuing = true;
396
- state.stepContinueCount += 1;
397
- state.stepContinueTotal += 1;
443
+ state.stepRecoveryActive = true;
398
444
  state.latestCoordinatorReport = undefined;
399
445
  try {
400
446
  const resumed = await resume.call(client.session, {
@@ -405,21 +451,26 @@ export function createContinuationHooks(client, directory, policySource, timings
405
451
  parts: [{
406
452
  type: "text",
407
453
  synthetic: true,
408
- text: `${STEP_CONTINUE_PREFIX}\n直前出力は非terminal進捗。進捗報告を繰り返さず、明示next_actionが欠落していれば最新の未達user要求とbatch counterから次の必要toolを特定して同じturnで実行する。\n${report}`,
454
+ text: `${STEP_CONTINUE_PREFIX}\n直前出力は非terminal進捗またはlocal/process blocker。未達のuser order、sequential execution、no-stop制約、SOL/advisor consultation指示を保持し、interactionは真にuser-controlledな決定だけに限定する。local gate/routing/handoff/scope/retry/time/step defectはterminal reportにせず、自律修復・redispatchして継続する。進捗報告を繰り返さず、明示next_actionが欠落していれば最新の未達user要求とbatch counterから次の必要toolを特定して同じturnで実行する。\n${report}`,
409
455
  }],
410
456
  },
411
457
  });
412
458
  if (!promptCallSucceeded(resumed))
413
459
  throw new Error("step continuation request rejected");
460
+ state.lastStepContinueReport = report;
461
+ state.lastStepContinueRevision = revision;
414
462
  }
415
463
  catch (error) {
416
- state.stepContinueCount -= 1;
417
- state.stepContinueTotal -= 1;
464
+ state.stepRecoveryActive = false;
418
465
  state.latestCoordinatorReport = report;
419
466
  console.error("[sortie-continuation] step resume failed", sessionID, error);
420
467
  }
421
468
  finally {
422
469
  state.stepContinueIssuing = false;
470
+ if (sessions.get(sessionID) === state && state.stepRecoveryActive &&
471
+ state.latestCoordinatorReport !== undefined && !terminalCheckpoint(state.latestCoordinatorReport)) {
472
+ queueMicrotask(() => { void handleSessionIdle(sessionID); });
473
+ }
423
474
  }
424
475
  }
425
476
  /**
@@ -453,9 +504,6 @@ export function createContinuationHooks(client, directory, policySource, timings
453
504
  state.continueReport = undefined;
454
505
  state.preserveCompactionScope = false;
455
506
  state.recoverySummaryValidated = false;
456
- // A successful rollover starts a new bounded context segment. Keep ordinary synthetic turns
457
- // from resetting this guard, but allow two fresh progress recoveries after compaction.
458
- state.stepContinueCount = 0;
459
507
  // The accepted prompt now belongs to the host loop, not this rollover request.
460
508
  state.active = false;
461
509
  return true;
@@ -615,6 +663,7 @@ export function createContinuationHooks(client, directory, policySource, timings
615
663
  state.recoverySummaryValidated = false;
616
664
  state.resetAttemptsAfterCompaction = resetAttemptsAfterCompaction;
617
665
  state.latestCoordinatorReport = undefined;
666
+ state.stepRecoveryActive = false;
618
667
  if (resume)
619
668
  state.attempts += 1;
620
669
  const epoch = state.rolloverEpoch;
@@ -763,6 +812,11 @@ export function createContinuationHooks(client, directory, policySource, timings
763
812
  trimmed.length > 0 && !trimmed.startsWith(ROLLOVER_TOKEN) &&
764
813
  !trimmed.startsWith(AUTO_CONTINUE_PREFIX)) {
765
814
  state.latestCoordinatorReport = output.text.trim();
815
+ if (terminalCheckpoint(state.latestCoordinatorReport)) {
816
+ state.stepRecoveryActive = false;
817
+ clearTimer(state.stepRecoveryTimer);
818
+ state.stepRecoveryTimer = undefined;
819
+ }
766
820
  if (batchCheckpointNeedsContinuation(state.latestCoordinatorReport)) {
767
821
  if (input.allowCheckpointContinuation === false) {
768
822
  state.latestCoordinatorReport = undefined;
@@ -775,7 +829,8 @@ export function createContinuationHooks(client, directory, policySource, timings
775
829
  await requestContinuation(input.sessionID, identity, report, true);
776
830
  }
777
831
  }
778
- else if (nonTerminalProgress(state.latestCoordinatorReport)) {
832
+ else if (nonTerminalProgress(state.latestCoordinatorReport) ||
833
+ (state.stepRecoveryActive && !terminalCheckpoint(state.latestCoordinatorReport))) {
779
834
  scheduleStepRecovery(input.sessionID, state, state.latestCoordinatorReport);
780
835
  }
781
836
  // The resumed coordinator completed a turn, so no late event from its prior compaction can
@@ -892,14 +947,16 @@ export function createContinuationHooks(client, directory, policySource, timings
892
947
  state.attempts = 0;
893
948
  state.directUsed = false;
894
949
  state.latestCoordinatorReport = undefined;
950
+ if (!synthetic) {
951
+ state.lastStepContinueReport = undefined;
952
+ state.lastStepContinueRevision = undefined;
953
+ state.stepRecoveryActive = false;
954
+ }
895
955
  state.compactingEpoch = undefined;
896
956
  state.model = { providerID: model.providerID, modelID: model.modelID };
897
957
  state.turnRevision += 1;
898
- if (!synthetic) {
899
- state.stepContinueCount = 0;
900
- state.stepContinueTotal = 0;
958
+ if (!synthetic)
901
959
  state.limitCompacted = false;
902
- }
903
960
  },
904
961
  blocksTool(sessionID) {
905
962
  const state = sessions.get(sessionID);
@@ -925,7 +982,8 @@ export function createContinuationHooks(client, directory, policySource, timings
925
982
  }
926
983
  if (state !== undefined && !state.stepContinueIssuing &&
927
984
  state.latestCoordinatorReport !== undefined &&
928
- nonTerminalProgress(state.latestCoordinatorReport)) {
985
+ (nonTerminalProgress(state.latestCoordinatorReport) ||
986
+ (state.stepRecoveryActive && !terminalCheckpoint(state.latestCoordinatorReport)))) {
929
987
  const report = state.latestCoordinatorReport;
930
988
  const revision = state.turnRevision;
931
989
  const identity = await readIdentity(sessionID);
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { lstat, realpath } from "node:fs/promises";
3
- import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
+ import { basename, dirname, isAbsolute, relative, resolve, win32 } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { RelativePathError, normalizeManifestPath, normalizeRelativePath } from "../core/path.js";
6
6
  import { validateOperationManifestSchema } from "../core/validate-schema.js";
@@ -377,6 +377,22 @@ function depthOnePowerShellLiteral(source) {
377
377
  function commandIssue(segment, cause, hint) {
378
378
  return { segment: segment.trim().slice(0, 240), cause, hint };
379
379
  }
380
+ function isLiteralPowerShellPath(value) {
381
+ return value.length > 0 && !/[\u0000-\u001f\u007f$*?\[\]@(){},;|&`<>]/u.test(value);
382
+ }
383
+ function robocopyOutput(tokens) {
384
+ if (tokens.length !== 4)
385
+ return undefined;
386
+ const source = tokens[1];
387
+ const destination = tokens[2];
388
+ const file = tokens[3];
389
+ const literal = (value) => value.length > 0 && !/[\u0000-\u001f\u007f$*?]/u.test(value);
390
+ if (!literal(source) || !literal(destination) || !literal(file) ||
391
+ file === "." || file === ".." || /[\\/]/u.test(file) ||
392
+ source.startsWith("/") || destination.startsWith("/") || file.startsWith("/"))
393
+ return undefined;
394
+ return win32.join(destination, file);
395
+ }
380
396
  function shellPaths(command, powershell, depth = 0) {
381
397
  const paths = [];
382
398
  let applies = false;
@@ -486,6 +502,18 @@ function shellPaths(command, powershell, depth = 0) {
486
502
  if (destination !== undefined)
487
503
  requiredDirectories.push(dirname(destination));
488
504
  }
505
+ else if (/^robocopy(?:\.exe)?$/u.test(executable)) {
506
+ applies = true;
507
+ const destination = robocopyOutput(tokens);
508
+ if (destination === undefined) {
509
+ ambiguous = true;
510
+ issue ??= commandIssue(source, "unsupported-robocopy-form", "use literal Robocopy.exe <source-dir> <destination-dir> <single-file>");
511
+ }
512
+ else {
513
+ paths.push(destination);
514
+ requiredDirectories.push(dirname(destination));
515
+ }
516
+ }
489
517
  else if (executable === "tar") {
490
518
  const mode = tarMode(tokens);
491
519
  if (mode === undefined) {
@@ -502,6 +530,68 @@ function shellPaths(command, powershell, depth = 0) {
502
530
  else if (executable === "find" && isDirectFindHash(tokens)) {
503
531
  // Exact member lookup plus sha256sum is read-only; a following tee owns any output path.
504
532
  }
533
+ else if (executable === "copy-item") {
534
+ applies = true;
535
+ const destinations = [];
536
+ const sources = [];
537
+ const positional = [];
538
+ let unsupported = false;
539
+ let force = false;
540
+ for (let index = 1; index < tokens.length; index += 1) {
541
+ const token = tokens[index];
542
+ if (/^-(?:path|literalpath)$/iu.test(token)) {
543
+ const candidate = tokens[index + 1];
544
+ if (candidate !== undefined && !candidate.startsWith("-"))
545
+ sources.push(candidate);
546
+ else
547
+ unsupported = true;
548
+ index += 1;
549
+ }
550
+ else if (/^-destination$/iu.test(token)) {
551
+ const candidate = tokens[index + 1];
552
+ if (candidate !== undefined && !candidate.startsWith("-"))
553
+ destinations.push(candidate);
554
+ else
555
+ unsupported = true;
556
+ index += 1;
557
+ }
558
+ else if (/^-force$/iu.test(token)) {
559
+ if (force)
560
+ unsupported = true;
561
+ force = true;
562
+ }
563
+ else if (token.startsWith("-")) {
564
+ unsupported = true;
565
+ }
566
+ else if (!token.startsWith("-")) {
567
+ positional.push(token);
568
+ }
569
+ }
570
+ const namedForm = sources.length === 1 && destinations.length === 1 && positional.length === 0;
571
+ const namedSourceForm = sources.length === 1 && destinations.length === 0 && positional.length === 1;
572
+ const namedDestinationForm = sources.length === 0 && destinations.length === 1 && positional.length === 1;
573
+ const positionalForm = sources.length === 0 && destinations.length === 0 && positional.length === 2;
574
+ const copySource = namedForm || namedSourceForm
575
+ ? sources[0]
576
+ : namedDestinationForm || positionalForm
577
+ ? positional[0]
578
+ : undefined;
579
+ const destination = namedForm || namedDestinationForm
580
+ ? destinations[0]
581
+ : namedSourceForm || positionalForm
582
+ ? positional.at(-1)
583
+ : undefined;
584
+ if (unsupported
585
+ || copySource === undefined
586
+ || destination === undefined
587
+ || !isLiteralPowerShellPath(copySource)
588
+ || !isLiteralPowerShellPath(destination)) {
589
+ ambiguous = true;
590
+ issue ??= commandIssue(source, "unsupported-copy-item-form", "use one literal source, one literal destination, and optional -Force");
591
+ }
592
+ else
593
+ paths.push(destination);
594
+ }
505
595
  else if (POWERSHELL_WRITE_COMMANDS.has(executable)) {
506
596
  applies = true;
507
597
  const named = [];
@@ -885,6 +975,9 @@ export async function createWriteGate(project, value) {
885
975
  catch (error) {
886
976
  throw new WriteDeniedError("manifest-unavailable", "<unknown>", { cause: error });
887
977
  }
978
+ // A missing relative descendant is a directory scope when its parent is also declared.
979
+ // This authorizes files below the bounded child without widening the parent declaration.
980
+ const inferredDirectories = new Set([...writable].filter((candidate) => [...writable].some((other) => other !== candidate && candidate.startsWith(`${other}/`))));
888
981
  const writableDirectories = [];
889
982
  for (const path of writable) {
890
983
  try {
@@ -894,7 +987,8 @@ export async function createWriteGate(project, value) {
894
987
  }
895
988
  }
896
989
  catch (error) {
897
- if (declaredDirectories.has(path) && isRecord(error) && error.code === "ENOENT") {
990
+ if ((declaredDirectories.has(path) || inferredDirectories.has(path)) &&
991
+ isRecord(error) && error.code === "ENOENT") {
898
992
  writableDirectories.push({ path, realPath: await nearestExistingRealPath(project.absolute(path)) });
899
993
  }
900
994
  // Other missing, inaccessible, and concurrently changed paths remain exact-only scopes.