sortie-dogs 0.3.14 → 0.3.16

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
@@ -12,15 +12,15 @@
12
12
  ![Sortie-dogs coordinating a bounded implementation workflow](https://raw.githubusercontent.com/zufall-upon/Sortie-dogs/main/docs/assets/sortie-workflow.gif)
13
13
 
14
14
  Sortie-dogs is an opt-in OpenCode orchestration plugin. It turns a task into a
15
- scoped plan, parallel investigation, dedicated implementation, canonical
15
+ scoped plan, parallel investigation, bounded independent implementation, canonical
16
16
  validation, and evidence-backed completion—while preserving standard OpenCode
17
17
  agents and settings.
18
18
 
19
19
  Requirements: Node.js 22.6 or newer, npm, and OpenCode.
20
20
 
21
- Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md)
21
+ Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
22
22
 
23
- Release: [v0.3.14](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.3.14)
23
+ Release: [v0.3.16](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.3.16)
24
24
 
25
25
  ## Quick start
26
26
 
@@ -207,8 +207,10 @@ global file for durable global settings.
207
207
  exactly three bounded scouts before implementation begins.
208
208
  - **Writes stay inside the assignment.** Exact source or operation manifests
209
209
  gate edits and handoffs.
210
- - **One accountable implementation path.** One dedicated worker handles
211
- implementation, remediation, and blocker resolution.
210
+ - **Safe implementation fan-out.** Independent units may use two or three workers
211
+ with non-overlapping write manifests; dependent or same-path work stays on one worker.
212
+ - **Runtime overlap protection.** Active equal or ancestor write scopes are rejected
213
+ before mutation, and full validation waits until every parallel unit joins.
212
214
  - **Evidence before completion.** Canonical validation, risk-based review, and
213
215
  terminal evidence gate coordinator-owned completion and commits.
214
216
  - **Long work can recover.** Restart recovery and bounded compaction continue
@@ -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.4-card36";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.4-card38";
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.4-card36";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.4-card38";
@@ -20,6 +20,7 @@ export declare const CONTINUATION_MARKER = "<!-- SORTIE_CONTINUE -->";
20
20
  export declare const ROLLOVER_MARKER = "<!-- SORTIE_COMPACT -->";
21
21
  /** First token of the synthetic resume prompt, so the coordinator can recognize its own resume. */
22
22
  export declare const AUTO_CONTINUE_PREFIX = "SORTIE_AUTO_CONTINUE";
23
+ export declare const STEP_CONTINUE_PREFIX = "SORTIE_STEP_CONTINUE";
23
24
  /** First line the rollover summary must emit, mirroring the batch target of three attempts. */
24
25
  export declare const ROLLOVER_TOKEN = "SORTIE_ROLLOVER_COMPACTED";
25
26
  export declare const DEFAULT_MAX_AUTO_CONTINUES = 2;
@@ -20,6 +20,7 @@ export const CONTINUATION_MARKER = "<!-- SORTIE_CONTINUE -->";
20
20
  export const ROLLOVER_MARKER = "<!-- SORTIE_COMPACT -->";
21
21
  /** First token of the synthetic resume prompt, so the coordinator can recognize its own resume. */
22
22
  export const AUTO_CONTINUE_PREFIX = "SORTIE_AUTO_CONTINUE";
23
+ export const STEP_CONTINUE_PREFIX = "SORTIE_STEP_CONTINUE";
23
24
  const TOOL_REQUESTED_REPORT = "Tool-requested Sortie rollover. Preserve task identity, both manifests, validation history, " +
24
25
  "batch counters, blocker state, and the exact next action from the latest messages.";
25
26
  /** First line the rollover summary must emit, mirroring the batch target of three attempts. */
@@ -32,6 +33,7 @@ export const DEFAULT_MAX_AUTO_CONTINUES = 2;
32
33
  */
33
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;
34
35
  const MAX_TRACKED_SESSIONS = 256;
36
+ const MAX_STEP_CONTINUES_PER_TURN = 2;
35
37
  const DEFAULT_TIMINGS = {
36
38
  /** Compaction is expensive; one rollover per minute per session is the canonical ceiling. */
37
39
  cooldownMilliseconds: 60_000,
@@ -198,6 +200,8 @@ export function createContinuationHooks(client, directory, policySource, timings
198
200
  ownsHostContinuation: false,
199
201
  textCompleting: false,
200
202
  directUsed: false,
203
+ stepContinueCount: 0,
204
+ stepContinueIssuing: false,
201
205
  touched: Date.now(),
202
206
  };
203
207
  sessions.set(sessionID, created);
@@ -270,6 +274,47 @@ export function createContinuationHooks(client, directory, policySource, timings
270
274
  if (!promptCallSucceeded(resumed))
271
275
  throw new Error("resume request rejected");
272
276
  }
277
+ function nonTerminalProgress(text) {
278
+ if (text.startsWith(STEP_CONTINUE_PREFIX) || text.startsWith(AUTO_CONTINUE_PREFIX))
279
+ return false;
280
+ if (/\bstatus:\s*(?:DONE|BLOCKED|NEED_DECISION)\b/iu.test(text))
281
+ return false;
282
+ return /➡️\s*(?:次action|next_action)\s*:\s*\S/iu.test(text);
283
+ }
284
+ async function issueStepContinue(sessionID, state, report, agent) {
285
+ const resume = client?.session?.promptAsync;
286
+ const active = policy();
287
+ if (resume === undefined || !active.enabled || active.agent !== agent || state.stepContinueIssuing ||
288
+ state.stepContinueCount >= MAX_STEP_CONTINUES_PER_TURN)
289
+ return;
290
+ state.stepContinueIssuing = true;
291
+ state.stepContinueCount += 1;
292
+ state.latestCoordinatorReport = undefined;
293
+ try {
294
+ const resumed = await resume.call(client.session, {
295
+ path: { id: sessionID },
296
+ query: { directory },
297
+ body: {
298
+ agent,
299
+ parts: [{
300
+ type: "text",
301
+ synthetic: true,
302
+ text: `${STEP_CONTINUE_PREFIX}\n直前出力は非terminal進捗。繰り返さず、同じcandidateのnext_actionを今すぐtoolで実行する。\n${report}`,
303
+ }],
304
+ },
305
+ });
306
+ if (!promptCallSucceeded(resumed))
307
+ throw new Error("step continuation request rejected");
308
+ }
309
+ catch (error) {
310
+ state.stepContinueCount -= 1;
311
+ state.latestCoordinatorReport = report;
312
+ console.error("[sortie-continuation] step resume failed", sessionID, error);
313
+ }
314
+ finally {
315
+ state.stepContinueIssuing = false;
316
+ }
317
+ }
273
318
  /**
274
319
  * Single resume arbiter. Host events and timers may arrive in any order, but none of them may call
275
320
  * promptAsync directly. The epoch lock makes concurrent compacted/text/idle signals idempotent.
@@ -680,6 +725,7 @@ export function createContinuationHooks(client, directory, policySource, timings
680
725
  state.model = { providerID: model.providerID, modelID: model.modelID };
681
726
  state.turnRevision += 1;
682
727
  if (!synthetic) {
728
+ state.stepContinueCount = 0;
683
729
  state.ownsHostContinuation = false;
684
730
  state.limitCompacted = false;
685
731
  }
@@ -704,6 +750,18 @@ export function createContinuationHooks(client, directory, policySource, timings
704
750
  }
705
751
  if (state?.pendingRollover === true) {
706
752
  await runRollover(sessionID);
753
+ return;
754
+ }
755
+ if (state !== undefined && !state.stepContinueIssuing &&
756
+ state.latestCoordinatorReport !== undefined &&
757
+ nonTerminalProgress(state.latestCoordinatorReport)) {
758
+ const report = state.latestCoordinatorReport;
759
+ const revision = state.turnRevision;
760
+ const identity = await readIdentity(sessionID);
761
+ const active = policy();
762
+ if (active.enabled && identity !== undefined && identity.agent === active.agent && !nonEmpty(identity.parentID) &&
763
+ state.turnRevision === revision && state.latestCoordinatorReport === report)
764
+ await issueStepContinue(sessionID, state, report, active.agent);
707
765
  }
708
766
  }
709
767
  }
@@ -1,3 +1,4 @@
1
+ import type { OperationManifest } from "../core/types.js";
1
2
  export interface ToolExecuteBeforeInput {
2
3
  tool: string;
3
4
  sessionID: string;
@@ -6,7 +7,7 @@ export interface ToolExecuteBeforeInput {
6
7
  export interface ToolExecuteBeforeOutput {
7
8
  args: unknown;
8
9
  }
9
- export type WriteDenialReason = "manifest-unavailable" | "session-expired" | "unclassified-command" | "path-required" | "project-boundary" | "manifest-scope" | "repeated-denial";
10
+ export type WriteDenialReason = "manifest-unavailable" | "session-expired" | "unclassified-command" | "path-required" | "project-boundary" | "manifest-scope" | "parallel-git-mutation" | "parallel-remote-mutation" | "parallel-validation" | "session-released" | "repeated-denial";
10
11
  export declare class WriteDeniedError extends Error {
11
12
  readonly reason: WriteDenialReason;
12
13
  constructor(reason: WriteDenialReason, path: string, options?: ErrorOptions);
@@ -27,6 +28,8 @@ interface Extraction {
27
28
  ambiguous: boolean;
28
29
  paths: string[];
29
30
  gitCommit?: boolean;
31
+ gitMutation?: boolean;
32
+ remoteMutation?: boolean;
30
33
  issue?: CommandIssue;
31
34
  }
32
35
  interface CommandIssue {
@@ -42,6 +45,8 @@ export declare function resolveProjectRoot(input: {
42
45
  export declare function describeUnclassifiedCommand(tool: string, args: unknown): string | undefined;
43
46
  /** Extract known write destinations; unknown shell executables fail closed as ambiguous. */
44
47
  export declare function extractWritePaths(tool: string, args: unknown): Extraction;
48
+ export declare function isGitMutation(tool: string, args: unknown): boolean;
49
+ export declare function isRemoteMutation(tool: string, args: unknown): boolean;
45
50
  /**
46
51
  * Quoted segments keep their contents; every unquoted whitespace run collapses to one space so the
47
52
  * same command written with different spacing compares equal.
@@ -49,6 +54,10 @@ export declare function extractWritePaths(tool: string, args: unknown): Extracti
49
54
  export declare function normalizeCommand(command: string): string;
50
55
  /** Unbound sessions may invoke only tools whose complete input is known to be read-only. */
51
56
  export declare function isKnownReadOnlyTool(tool: string, args: unknown, additionalReadOnlyTools?: ReadonlySet<string>): boolean;
57
+ /** Canonical scopes let sibling worker bindings reject equal or ancestor ownership. */
58
+ export declare function canonicalManifestWriteScopes(project: ProjectPaths, manifest: OperationManifest): Promise<readonly string[]>;
59
+ export declare function canonicalManifestReadScopes(project: ProjectPaths, manifest: OperationManifest): Promise<readonly string[]>;
60
+ export declare function writeScopesOverlap(left: readonly string[], right: readonly string[]): boolean;
52
61
  export declare function createProjectPaths(rootCandidate: string): Promise<ProjectPaths>;
53
62
  export declare function createWriteGate(project: ProjectPaths, value: unknown): Promise<WriteGate>;
54
63
  export {};
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { lstat, realpath } from "node:fs/promises";
3
- import { isAbsolute, relative, resolve } from "node:path";
3
+ import { basename, isAbsolute, relative, resolve } 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";
@@ -14,6 +14,10 @@ export class WriteDeniedError extends Error {
14
14
  "path-required": "write path must be explicit.",
15
15
  "project-boundary": "project-root-relative path required.",
16
16
  "manifest-scope": "operation manifest write scope.",
17
+ "parallel-git-mutation": "parallel implementation workers cannot mutate shared Git state.",
18
+ "parallel-remote-mutation": "parallel implementation workers cannot mutate shared remote state.",
19
+ "parallel-validation": "parallel implementation validation must run after the worker join.",
20
+ "session-released": "released session must re-read only its handoff and bind before further work.",
17
21
  "repeated-denial": "same command and denial reason already denied in this session; retry blocked.",
18
22
  };
19
23
  super(`Write denied for "${safePath(path)}": ${messages[reason]}`, options);
@@ -131,6 +135,14 @@ function isRemoteOnlyGitHubCommand(tokens) {
131
135
  const command = tokens[1]?.toLowerCase();
132
136
  return command === "project" || (command === "api" && tokens[2]?.toLowerCase() === "graphql");
133
137
  }
138
+ function isRemoteGitHubMutation(tokens) {
139
+ const command = tokens[1]?.toLowerCase();
140
+ if (command === "project") {
141
+ return !new Set(["field-list", "item-list", "list", "view"]).has(tokens[2]?.toLowerCase() ?? "");
142
+ }
143
+ // GraphQL transport can hide mutations in variables, files, or stdin; parallel workers fail closed.
144
+ return command === "api" && tokens[2]?.toLowerCase() === "graphql";
145
+ }
134
146
  function isReadOnlyGitCommand(tokens) {
135
147
  const command = tokens[1]?.toLowerCase() ?? "";
136
148
  return READ_ONLY_GIT_COMMANDS.has(command) ||
@@ -226,9 +238,14 @@ function shellSegments(command, dialect) {
226
238
  for (let index = 0; index < masked.length; index += 1) {
227
239
  const character = masked[index];
228
240
  const pair = masked.slice(index, index + 2);
241
+ const segmentBefore = masked.slice(start, index).trim();
242
+ const hasCommandBefore = segmentBefore.length > 0;
243
+ const powershellCallOperator = dialect === "powershell" &&
244
+ (segmentBefore.length === 0 || segmentBefore.endsWith("="));
229
245
  const separator = pair === "&&" || pair === "||" ? 2
230
246
  : character === ";" || character === "\n" || character === "\r" ||
231
- (character === "|" && command[index - 1] !== ">") ? 1 : 0;
247
+ (character === "|" && command[index - 1] !== ">") ||
248
+ (character === "&" && hasCommandBefore && command[index - 1] !== ">" && !powershellCallOperator) ? 1 : 0;
232
249
  if (separator === 0)
233
250
  continue;
234
251
  segments.push(command.slice(start, index));
@@ -252,6 +269,8 @@ function shellPaths(command, powershell, depth = 0) {
252
269
  let applies = false;
253
270
  let ambiguous = false;
254
271
  let gitCommit = false;
272
+ let gitMutation = false;
273
+ let remoteMutation = false;
255
274
  let issue;
256
275
  const redirection = /(?<![<>=!])(?:&|\d*)?(?:>>|>\||>)(?![=])/gu;
257
276
  const redirectionTarget = /^\s*("(?:\\.|[^"])*"|'[^']*'|&?\d+|[^\s;&|]+)/u;
@@ -344,6 +363,7 @@ function shellPaths(command, powershell, depth = 0) {
344
363
  }
345
364
  else if (executable === "git" && tokens[1]?.toLowerCase() === "add") {
346
365
  applies = true;
366
+ gitMutation = true;
347
367
  const selected = exactGitAddPaths(tokens);
348
368
  if (selected === undefined)
349
369
  ambiguous = true;
@@ -352,6 +372,7 @@ function shellPaths(command, powershell, depth = 0) {
352
372
  }
353
373
  else if (executable === "git" && tokens[1]?.toLowerCase() === "commit") {
354
374
  applies = true;
375
+ gitMutation = true;
355
376
  if (isSafeGitCommit(tokens))
356
377
  gitCommit = true;
357
378
  else
@@ -361,7 +382,8 @@ function shellPaths(command, powershell, depth = 0) {
361
382
  // Explicitly read-only git subcommands.
362
383
  }
363
384
  else if (/^gh(?:\.exe)?$/u.test(executable) && isRemoteOnlyGitHubCommand(tokens)) {
364
- // GitHub Project commands mutate remote state, not project files. Redirections remain gated above.
385
+ // Remote operations are pathless, but parallel workers must distinguish reads from mutations.
386
+ remoteMutation ||= isRemoteGitHubMutation(tokens);
365
387
  }
366
388
  else if (depth === 0 && /^(?:pwsh|powershell)(?:\.exe)?$/u.test(executable)) {
367
389
  const literal = depthOnePowerShellLiteral(source);
@@ -376,6 +398,8 @@ function shellPaths(command, powershell, depth = 0) {
376
398
  ambiguous ||= nested.ambiguous;
377
399
  paths.push(...nested.paths);
378
400
  gitCommit ||= nested.gitCommit === true;
401
+ gitMutation ||= nested.gitMutation === true;
402
+ remoteMutation ||= nested.remoteMutation === true;
379
403
  issue ??= nested.issue;
380
404
  }
381
405
  }
@@ -385,7 +409,15 @@ function shellPaths(command, powershell, depth = 0) {
385
409
  issue ??= commandIssue(source, "executable-not-allowlisted", "use a direct allowlisted read-only command");
386
410
  }
387
411
  }
388
- return { applies, ambiguous, paths, ...(gitCommit ? { gitCommit: true } : {}), ...(issue ? { issue } : {}) };
412
+ return {
413
+ applies,
414
+ ambiguous,
415
+ paths,
416
+ ...(gitCommit ? { gitCommit: true } : {}),
417
+ ...(gitMutation ? { gitMutation: true } : {}),
418
+ ...(remoteMutation ? { remoteMutation: true } : {}),
419
+ ...(issue ? { issue } : {}),
420
+ };
389
421
  }
390
422
  function issuePath(issue) {
391
423
  return `segment=${issue.segment}; cause=${issue.cause}; hint=${issue.hint}; retry=false; action=return-denial-to-coordinator`;
@@ -426,11 +458,19 @@ export function extractWritePaths(tool, args) {
426
458
  ambiguous: extracted.ambiguous,
427
459
  paths: [...paths, ...extracted.paths],
428
460
  ...(extracted.gitCommit ? { gitCommit: true } : {}),
461
+ ...(extracted.gitMutation ? { gitMutation: true } : {}),
462
+ ...(extracted.remoteMutation ? { remoteMutation: true } : {}),
429
463
  ...(extracted.issue ? { issue: extracted.issue } : {}),
430
464
  };
431
465
  }
432
466
  return { applies: false, ambiguous: false, paths: [] };
433
467
  }
468
+ export function isGitMutation(tool, args) {
469
+ return extractWritePaths(tool, args).gitMutation === true;
470
+ }
471
+ export function isRemoteMutation(tool, args) {
472
+ return extractWritePaths(tool, args).remoteMutation === true;
473
+ }
434
474
  /**
435
475
  * Quoted segments keep their contents; every unquoted whitespace run collapses to one space so the
436
476
  * same command written with different spacing compares equal.
@@ -492,6 +532,63 @@ async function nearestExistingRealPath(path) {
492
532
  return await realpath(candidate);
493
533
  }
494
534
  }
535
+ function toggledCaseProbe(path) {
536
+ let candidate = resolve(path);
537
+ while (true) {
538
+ const name = basename(candidate);
539
+ const index = [...name].findIndex((character) => /[A-Za-z]/u.test(character));
540
+ if (index >= 0) {
541
+ const characters = [...name];
542
+ const character = characters[index];
543
+ characters[index] = character === character.toLowerCase()
544
+ ? character.toUpperCase()
545
+ : character.toLowerCase();
546
+ return {
547
+ original: candidate,
548
+ alternate: resolve(candidate, "..", characters.join("")),
549
+ };
550
+ }
551
+ const parent = resolve(candidate, "..");
552
+ if (parent === candidate)
553
+ return undefined;
554
+ candidate = parent;
555
+ }
556
+ }
557
+ async function isCaseInsensitivePath(path) {
558
+ if (process.platform === "win32")
559
+ return true;
560
+ const probe = toggledCaseProbe(path);
561
+ if (probe === undefined || probe.alternate === probe.original)
562
+ return false;
563
+ try {
564
+ return resolve(await realpath(probe.alternate)) === resolve(await realpath(probe.original));
565
+ }
566
+ catch {
567
+ return false;
568
+ }
569
+ }
570
+ async function canonicalPotentialPath(path) {
571
+ let candidate = resolve(path);
572
+ const missing = [];
573
+ while (true) {
574
+ try {
575
+ const realCandidate = await realpath(candidate);
576
+ const canonical = resolve(realCandidate, ...missing);
577
+ return await isCaseInsensitivePath(candidate)
578
+ ? canonical.toLowerCase()
579
+ : canonical;
580
+ }
581
+ catch (error) {
582
+ if (!isRecord(error) || error.code !== "ENOENT")
583
+ throw error;
584
+ const parent = resolve(candidate, "..");
585
+ if (parent === candidate)
586
+ throw error;
587
+ missing.unshift(basename(candidate));
588
+ candidate = parent;
589
+ }
590
+ }
591
+ }
495
592
  async function isSymbolicLink(path) {
496
593
  try {
497
594
  return (await lstat(path)).isSymbolicLink();
@@ -506,6 +603,26 @@ function isWithin(root, target) {
506
603
  const path = relative(root, target);
507
604
  return path === "" || (!path.startsWith("..") && !isAbsolute(path));
508
605
  }
606
+ async function canonicalManifestScopes(project, entries) {
607
+ const scopes = await Promise.all(entries.map(async (entry) => {
608
+ const normalized = normalizeManifestPath(entry);
609
+ const absolute = normalized.kind === "relative"
610
+ ? project.absolute(normalized.path)
611
+ : resolve(normalized.path);
612
+ return await canonicalPotentialPath(absolute);
613
+ }));
614
+ return [...new Set(scopes)];
615
+ }
616
+ /** Canonical scopes let sibling worker bindings reject equal or ancestor ownership. */
617
+ export async function canonicalManifestWriteScopes(project, manifest) {
618
+ return await canonicalManifestScopes(project, manifest.write);
619
+ }
620
+ export async function canonicalManifestReadScopes(project, manifest) {
621
+ return await canonicalManifestScopes(project, manifest.read);
622
+ }
623
+ export function writeScopesOverlap(left, right) {
624
+ return left.some((leftPath) => right.some((rightPath) => isWithin(leftPath, rightPath) || isWithin(rightPath, leftPath)));
625
+ }
509
626
  export async function createProjectPaths(rootCandidate) {
510
627
  const root = resolve(rootCandidate);
511
628
  const realRoot = await realpath(root);