sortie-dogs 0.5.8 → 0.5.12

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.8](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.8)
23
+ Release: [v0.5.12](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.12)
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.38-terra-high-coordinator-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.46-contract-directory-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.38-terra-high-coordinator-v1";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.46-contract-directory-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,8 +269,6 @@ 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,
277
273
  touched: Date.now(),
278
274
  };
@@ -383,18 +379,18 @@ export function createContinuationHooks(client, directory, policySource, timings
383
379
  if (/➡️\s*(?:次action|next_action)\s*:\s*\S/iu.test(text))
384
380
  return true;
385
381
  const percent = latestProgressPercent(text);
386
- return percent !== undefined && (percent < 100 || batchHasIndependentRemainder(text));
382
+ if (percent !== undefined)
383
+ return percent < 100 || batchHasIndependentRemainder(text);
384
+ // Coordinators sometimes omit the optional percentage while retaining a localized progress label.
385
+ return /📊\s*(?:進行中|处理中|in[ -]?progress)(?:\s|[::]|$)/iu.test(latestProgressLine(text) ?? "");
387
386
  }
388
387
  async function issueStepContinue(sessionID, state, report, agent) {
389
388
  const resume = client?.session?.promptAsync;
390
389
  const active = policy();
391
390
  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)
391
+ state.lastStepContinueReport === report)
394
392
  return;
395
393
  state.stepContinueIssuing = true;
396
- state.stepContinueCount += 1;
397
- state.stepContinueTotal += 1;
398
394
  state.latestCoordinatorReport = undefined;
399
395
  try {
400
396
  const resumed = await resume.call(client.session, {
@@ -411,10 +407,9 @@ export function createContinuationHooks(client, directory, policySource, timings
411
407
  });
412
408
  if (!promptCallSucceeded(resumed))
413
409
  throw new Error("step continuation request rejected");
410
+ state.lastStepContinueReport = report;
414
411
  }
415
412
  catch (error) {
416
- state.stepContinueCount -= 1;
417
- state.stepContinueTotal -= 1;
418
413
  state.latestCoordinatorReport = report;
419
414
  console.error("[sortie-continuation] step resume failed", sessionID, error);
420
415
  }
@@ -453,9 +448,6 @@ export function createContinuationHooks(client, directory, policySource, timings
453
448
  state.continueReport = undefined;
454
449
  state.preserveCompactionScope = false;
455
450
  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
451
  // The accepted prompt now belongs to the host loop, not this rollover request.
460
452
  state.active = false;
461
453
  return true;
@@ -892,14 +884,13 @@ export function createContinuationHooks(client, directory, policySource, timings
892
884
  state.attempts = 0;
893
885
  state.directUsed = false;
894
886
  state.latestCoordinatorReport = undefined;
887
+ if (!synthetic)
888
+ state.lastStepContinueReport = undefined;
895
889
  state.compactingEpoch = undefined;
896
890
  state.model = { providerID: model.providerID, modelID: model.modelID };
897
891
  state.turnRevision += 1;
898
- if (!synthetic) {
899
- state.stepContinueCount = 0;
900
- state.stepContinueTotal = 0;
892
+ if (!synthetic)
901
893
  state.limitCompacted = false;
902
- }
903
894
  },
904
895
  blocksTool(sessionID) {
905
896
  const state = sessions.get(sessionID);
@@ -121,8 +121,7 @@ export class FastLaneController {
121
121
  }
122
122
  manualCompactionForbidden(sessionID) {
123
123
  const state = this.sessions.get(sessionID);
124
- return state !== undefined &&
125
- (!state.backlogDrain || state.totalWorkerDispatches >= state.workerLimit);
124
+ return state !== undefined && !state.backlogDrain;
126
125
  }
127
126
  terminalInstructionRequired(sessionID) {
128
127
  const state = this.sessions.get(sessionID);
@@ -134,8 +133,8 @@ export class FastLaneController {
134
133
  }
135
134
  backlogContinuationAllowed(sessionID) {
136
135
  const state = this.sessions.get(sessionID);
137
- return state?.backlogDrain === true && state.workerDispatches === 1 &&
138
- state.totalWorkerDispatches < state.workerLimit && !state.continuationPending;
136
+ return state?.backlogDrain === true && state.workerDispatches >= 1 &&
137
+ !state.continuationPending;
139
138
  }
140
139
  enableBacklogDrain(sessionID, maxUnits) {
141
140
  const state = this.sessions.get(sessionID);
@@ -238,10 +237,6 @@ export class FastLaneController {
238
237
  throw new FastLaneDeniedError("WORKER_LIMIT");
239
238
  }
240
239
  }
241
- else if (state.backlogDrain &&
242
- (state.workerDispatches >= 1 || state.totalWorkerDispatches >= state.workerLimit)) {
243
- throw new FastLaneDeniedError("WORKER_LIMIT");
244
- }
245
240
  else if (state.workerInFlight) {
246
241
  throw new FastLaneDeniedError("WORKER_LIMIT");
247
242
  }
@@ -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 = [];
@@ -701,7 +791,10 @@ export function normalizeCommand(command) {
701
791
  normalized += character;
702
792
  }
703
793
  }
704
- return normalized;
794
+ const callOperator = /^& (["'])((?:[A-Za-z]:[\\/]|\\\\|\/)[^\s"']+)\1(?= |$)/u.exec(normalized);
795
+ return callOperator === null
796
+ ? normalized
797
+ : `${callOperator[2]}${normalized.slice(callOperator[0].length)}`;
705
798
  }
706
799
  /** Unbound sessions may invoke only tools whose complete input is known to be read-only. */
707
800
  export function isKnownReadOnlyTool(tool, args, additionalReadOnlyTools = new Set()) {