sortie-dogs 0.3.15 → 0.3.17

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,7 +12,7 @@
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
 
@@ -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.3.15](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.3.15)
23
+ Release: [v0.3.17](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.3.17)
24
24
 
25
25
  ## Quick start
26
26
 
@@ -148,7 +148,7 @@ Optional settings in `.opencode/sortie-dogs.json`:
148
148
  "handoffPaths": ["handoff.json"],
149
149
  "readOnlyTools": ["my_mcp_search"],
150
150
  "dedicatedWorkerModel": { "model": "provider/model", "variant": "deep" },
151
- "continuation": { "enabled": true, "maxAutoContinues": 2 },
151
+ "continuation": { "enabled": true, "maxAutoContinues": 10 },
152
152
  "reflection": {
153
153
  "enabled": false,
154
154
  "layers": { "run": true, "project": true, "global": false }
@@ -182,8 +182,8 @@ global file for durable global settings.
182
182
  and resumes the same root session on the next independent unit. Only a root
183
183
  `dog-coordinator` session is ever resumed: a child session is never promoted and
184
184
  another coordinator is never adopted. Set `enabled` to `false` to keep every
185
- batch manual, raise or lower `maxAutoContinues` (default `2`, maximum `10`) to
186
- change the ceiling, and set `summarizeModel` to override the latest coordinator
185
+ batch manual, lower `maxAutoContinues` (default and maximum `10`) to change the
186
+ ceiling, and set `summarizeModel` to override the latest coordinator
187
187
  model used for compaction. Normal OpenCode auto-compaction keeps the
188
188
  host's auto-continue behavior; Sortie suppresses it only while its own
189
189
  explicitly queued rollover owns the resume.
@@ -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-card37";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.4-card39";
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-card37";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.4-card39";
@@ -23,7 +23,7 @@ export declare const AUTO_CONTINUE_PREFIX = "SORTIE_AUTO_CONTINUE";
23
23
  export declare const STEP_CONTINUE_PREFIX = "SORTIE_STEP_CONTINUE";
24
24
  /** First line the rollover summary must emit, mirroring the batch target of three attempts. */
25
25
  export declare const ROLLOVER_TOKEN = "SORTIE_ROLLOVER_COMPACTED";
26
- export declare const DEFAULT_MAX_AUTO_CONTINUES = 2;
26
+ export declare const DEFAULT_MAX_AUTO_CONTINUES = 10;
27
27
  /**
28
28
  * A coordinator that exhausted its step budget reports remaining work instead of continuing. That
29
29
  * report is a continuation request in every respect except the marker, so it is treated as one.
@@ -25,7 +25,7 @@ const TOOL_REQUESTED_REPORT = "Tool-requested Sortie rollover. Preserve task ide
25
25
  "batch counters, blocker state, and the exact next action from the latest messages.";
26
26
  /** First line the rollover summary must emit, mirroring the batch target of three attempts. */
27
27
  export const ROLLOVER_TOKEN = "SORTIE_ROLLOVER_COMPACTED";
28
- export const DEFAULT_MAX_AUTO_CONTINUES = 2;
28
+ export const DEFAULT_MAX_AUTO_CONTINUES = 10;
29
29
  /**
30
30
  * A coordinator that exhausted its step budget reports remaining work instead of continuing. That
31
31
  * report is a continuation request in every respect except the marker, so it is treated as one.
@@ -117,6 +117,39 @@ const ROLLOVER_PROMPT = [
117
117
  "## 次action",
118
118
  "- <次に着手する1手順>",
119
119
  ].join("\n");
120
+ const RECOVERY_ROLLOVER_PROMPT = ROLLOVER_PROMPT
121
+ .replace("The coordinator final report immediately before compaction is the newest source of truth. Its outcomes and next action override older context.", "The recovery report overrides only terminal outcomes and batch counters. Preserve unmet user requirements, ordered scope, no-stop constraints, and the exact next unit from the conversation.")
122
+ .replace("Preserve task identity, every accepted fact, both manifests, ordered validation history, batchTarget, batchAttempted, batchCommitted, batchReconciled, blocker state, and the exact next action. If a value is absent, write なし; never guess one.", "Preserve task identity, every accepted fact, both manifests, ordered validation history, batchTarget, batchAttempted, batchCommitted, batchReconciled, blocker state, unmet ordered scope, and the exact next unit. If a value is absent, write なし; never guess one.");
123
+ const ROLLOVER_HEADINGS = [
124
+ "## 未達のユーザー要求",
125
+ "## task identity と制約",
126
+ "## manifest",
127
+ "## validation履歴",
128
+ "## batch counters",
129
+ "## 未解決blocker",
130
+ "## 次action",
131
+ ];
132
+ function validRecoveryCompactionSummary(text) {
133
+ const lines = text.trim().split(/\r?\n/u);
134
+ if (lines[0] !== ROLLOVER_TOKEN)
135
+ return false;
136
+ let previous = 0;
137
+ const indices = [];
138
+ for (const heading of ROLLOVER_HEADINGS) {
139
+ const index = lines.indexOf(heading, previous + 1);
140
+ if (index < 0)
141
+ return false;
142
+ indices.push(index);
143
+ previous = index;
144
+ }
145
+ for (const headingIndex of [0, ROLLOVER_HEADINGS.length - 1]) {
146
+ const start = indices[headingIndex] + 1;
147
+ const end = indices[headingIndex + 1] ?? lines.length;
148
+ if (!lines.slice(start, end).some((line) => line.startsWith("- ") && line !== "- なし"))
149
+ return false;
150
+ }
151
+ return true;
152
+ }
120
153
  function unrefTimer(timer) {
121
154
  if (typeof timer?.unref === "function") {
122
155
  timer.unref();
@@ -187,6 +220,8 @@ export function createContinuationHooks(client, directory, policySource, timings
187
220
  const created = {
188
221
  attempts: 0,
189
222
  pendingRollover: false,
223
+ preserveCompactionScope: false,
224
+ recoverySummaryValidated: false,
190
225
  resetAttemptsAfterCompaction: false,
191
226
  limitCompacted: false,
192
227
  active: false,
@@ -253,7 +288,7 @@ export function createContinuationHooks(client, directory, policySource, timings
253
288
  return false;
254
289
  return true;
255
290
  }
256
- async function issueResume(sessionID, report) {
291
+ async function issueResume(sessionID, report, preserveCompactionScope) {
257
292
  const resume = client?.session?.promptAsync;
258
293
  if (resume === undefined)
259
294
  throw new Error("resume capability unavailable");
@@ -265,21 +300,53 @@ export function createContinuationHooks(client, directory, policySource, timings
265
300
  parts: [{
266
301
  type: "text",
267
302
  synthetic: true,
268
- text: `${AUTO_CONTINUE_PREFIX}\n直前のcompaction summaryは破棄する。矛盾時だけでなく全面的に参照禁止。\n` +
269
- `以下の直前最終報告だけをpost-compaction状態の正本として再構築する:\n${report}\n` +
270
- "batchAttempted/batchCommitted/batchReconciledを保持し、terminal unitを再実行せず次の独立unitから同rootで継続。",
303
+ text: preserveCompactionScope
304
+ ? `${AUTO_CONTINUE_PREFIX}\n直前compaction summaryの未達user要求・ordered scope・no-stop制約を保持する。\n` +
305
+ `以下の回復reportはterminal outcomeとbatch counterだけを上書きする:\n${report}\n` +
306
+ "terminal unitを再実行せず、summaryが保持した順序の次の独立unitから同rootで継続。"
307
+ : `${AUTO_CONTINUE_PREFIX}\n直前のcompaction summaryは破棄する。矛盾時だけでなく全面的に参照禁止。\n` +
308
+ `以下の直前最終報告だけをpost-compaction状態の正本として再構築する:\n${report}\n` +
309
+ "batchAttempted/batchCommitted/batchReconciledを保持し、terminal unitを再実行せず次の独立unitから同rootで継続。",
271
310
  }],
272
311
  },
273
312
  });
274
313
  if (!promptCallSucceeded(resumed))
275
314
  throw new Error("resume request rejected");
276
315
  }
316
+ function terminalCheckpoint(text) {
317
+ return /\bstatus:\s*(?:DONE|BLOCKED|NEED_DECISION)\b/iu.test(text);
318
+ }
319
+ function latestProgressLine(text) {
320
+ return text.split(/\r?\n/u).filter((line) => line.includes("📊")).at(-1);
321
+ }
322
+ function latestProgressPercent(text) {
323
+ const value = Number(/📊[^\n]*?(\d{1,3})\s*%/u.exec(latestProgressLine(text) ?? "")?.[1]);
324
+ return Number.isInteger(value) && value >= 0 && value <= 100 ? value : undefined;
325
+ }
326
+ function batchHasIndependentRemainder(text) {
327
+ const latest = /\battempted\s+(\d+)\s*\/\s*(\d+)/iu.exec(latestProgressLine(text) ?? "");
328
+ if (latest === null)
329
+ return false;
330
+ const attempted = Number(latest[1]);
331
+ const target = Number(latest[2]);
332
+ return Number.isInteger(attempted) && Number.isInteger(target) && attempted >= 0 && attempted < target;
333
+ }
334
+ function batchCheckpointNeedsContinuation(text) {
335
+ const line = latestProgressLine(text) ?? "";
336
+ const explicit = /\bcontinuation\s*:\s*(required|none)\b/iu.exec(line)?.[1]?.toLowerCase();
337
+ const legacy = explicit === undefined && /\(\s*Project checkpoint\s*\)/iu.test(line);
338
+ return !terminalCheckpoint(text) && latestProgressPercent(text) === 100 &&
339
+ batchHasIndependentRemainder(text) && (explicit === "required" || legacy);
340
+ }
277
341
  function nonTerminalProgress(text) {
278
342
  if (text.startsWith(STEP_CONTINUE_PREFIX) || text.startsWith(AUTO_CONTINUE_PREFIX))
279
343
  return false;
280
- if (/\bstatus:\s*(?:DONE|BLOCKED|NEED_DECISION)\b/iu.test(text))
344
+ if (terminalCheckpoint(text))
281
345
  return false;
282
- return /➡️\s*(?:次action|next_action)\s*:\s*\S/iu.test(text);
346
+ if (/➡️\s*(?:次action|next_action)\s*:\s*\S/iu.test(text))
347
+ return true;
348
+ const percent = latestProgressPercent(text);
349
+ return percent !== undefined && (percent < 100 || batchHasIndependentRemainder(text));
283
350
  }
284
351
  async function issueStepContinue(sessionID, state, report, agent) {
285
352
  const resume = client?.session?.promptAsync;
@@ -299,7 +366,7 @@ export function createContinuationHooks(client, directory, policySource, timings
299
366
  parts: [{
300
367
  type: "text",
301
368
  synthetic: true,
302
- text: `${STEP_CONTINUE_PREFIX}\n直前出力は非terminal進捗。繰り返さず、同じcandidateのnext_actionを今すぐtoolで実行する。\n${report}`,
369
+ text: `${STEP_CONTINUE_PREFIX}\n直前出力は非terminal進捗。進捗報告を繰り返さず、明示next_actionが欠落していれば最新の未達user要求とbatch counterから次の必要toolを特定して同じturnで実行する。\n${report}`,
303
370
  }],
304
371
  },
305
372
  });
@@ -322,6 +389,8 @@ export function createContinuationHooks(client, directory, policySource, timings
322
389
  async function arbitrateResume(sessionID, state) {
323
390
  if (!state.pendingRollover || !state.active || state.continueReport === undefined)
324
391
  return false;
392
+ if (state.preserveCompactionScope && !state.recoverySummaryValidated)
393
+ return false;
325
394
  const epoch = state.rolloverEpoch;
326
395
  if (state.resumeIssuedEpoch === epoch)
327
396
  return true;
@@ -333,7 +402,7 @@ export function createContinuationHooks(client, directory, policySource, timings
333
402
  state.resumeIssuingEpoch = epoch;
334
403
  state.resumeAttempts += 1;
335
404
  try {
336
- await issueResume(sessionID, report);
405
+ await issueResume(sessionID, report, state.preserveCompactionScope);
337
406
  if (sessions.get(sessionID) !== state || state.rolloverEpoch !== epoch)
338
407
  return false;
339
408
  state.resumeIssuedEpoch = epoch;
@@ -342,6 +411,8 @@ export function createContinuationHooks(client, directory, policySource, timings
342
411
  state.compactedRollover = false;
343
412
  state.promptPending = false;
344
413
  state.continueReport = undefined;
414
+ state.preserveCompactionScope = false;
415
+ state.recoverySummaryValidated = false;
345
416
  // The accepted prompt now belongs to the host loop, not this rollover request.
346
417
  state.active = false;
347
418
  return true;
@@ -431,6 +502,8 @@ export function createContinuationHooks(client, directory, policySource, timings
431
502
  state.pendingRollover = false;
432
503
  state.compactedRollover = false;
433
504
  state.ownsHostContinuation = false;
505
+ state.preserveCompactionScope = false;
506
+ state.recoverySummaryValidated = false;
434
507
  if (state.resetAttemptsAfterCompaction)
435
508
  state.attempts = 0;
436
509
  state.limitCompacted = !state.resetAttemptsAfterCompaction;
@@ -480,11 +553,13 @@ export function createContinuationHooks(client, directory, policySource, timings
480
553
  state.continueReport = undefined;
481
554
  state.latestReport = undefined;
482
555
  state.ownsHostContinuation = false;
556
+ state.preserveCompactionScope = false;
557
+ state.recoverySummaryValidated = false;
483
558
  warnRollover(sessionID, "retries-exhausted");
484
559
  }
485
560
  }, timings.scheduleMilliseconds * (attempt + 1)));
486
561
  }
487
- function queueRollover(sessionID, report, resume, resetAttemptsAfterCompaction = false) {
562
+ function queueRollover(sessionID, report, resume, resetAttemptsAfterCompaction = false, preserveCompactionScope = false) {
488
563
  const state = stateFor(sessionID);
489
564
  state.pendingRollover = true;
490
565
  state.compactedRollover = false;
@@ -496,6 +571,8 @@ export function createContinuationHooks(client, directory, policySource, timings
496
571
  state.compactingEpoch = undefined;
497
572
  state.latestReport = report;
498
573
  state.continueReport = resume ? report : undefined;
574
+ state.preserveCompactionScope = preserveCompactionScope;
575
+ state.recoverySummaryValidated = false;
499
576
  state.resetAttemptsAfterCompaction = resetAttemptsAfterCompaction;
500
577
  state.latestCoordinatorReport = undefined;
501
578
  if (resume)
@@ -514,7 +591,7 @@ export function createContinuationHooks(client, directory, policySource, timings
514
591
  }
515
592
  }, 0);
516
593
  }
517
- async function requestContinuation(sessionID, identity, report) {
594
+ async function requestContinuation(sessionID, identity, report, preserveCompactionScope = false) {
518
595
  const state = stateFor(sessionID);
519
596
  const active = policy();
520
597
  const resolution = resolveContinuation({
@@ -530,8 +607,9 @@ export function createContinuationHooks(client, directory, policySource, timings
530
607
  if (resolution.reason === "limit-reached" && state.limitCompacted) {
531
608
  return reject("limit-reached");
532
609
  }
533
- if (resolution.compact)
534
- queueRollover(sessionID, report, resolution.continue);
610
+ if (resolution.compact) {
611
+ queueRollover(sessionID, report, resolution.continue, false, preserveCompactionScope);
612
+ }
535
613
  return resolution;
536
614
  }
537
615
  function forgetSession(sessionID) {
@@ -597,8 +675,22 @@ export function createContinuationHooks(client, directory, policySource, timings
597
675
  (state.active || state.pendingRollover);
598
676
  if (ownedCompactionSummary)
599
677
  state.compactingEpoch = undefined;
600
- if (ownedCompactionSummary && !trimmed.startsWith(ROLLOVER_TOKEN)) {
678
+ const malformedRecoverySummary = ownedCompactionSummary && state.preserveCompactionScope &&
679
+ !validRecoveryCompactionSummary(trimmed);
680
+ if (ownedCompactionSummary && (!trimmed.startsWith(ROLLOVER_TOKEN) || malformedRecoverySummary)) {
601
681
  warnRollover(input.sessionID, "compaction-summary-malformed");
682
+ if (state.preserveCompactionScope) {
683
+ state.pendingRollover = false;
684
+ state.promptPending = false;
685
+ state.continueReport = undefined;
686
+ state.ownsHostContinuation = false;
687
+ state.preserveCompactionScope = false;
688
+ state.recoverySummaryValidated = false;
689
+ return;
690
+ }
691
+ }
692
+ if (ownedCompactionSummary && state.preserveCompactionScope) {
693
+ state.recoverySummaryValidated = true;
602
694
  }
603
695
  /*
604
696
  * One-shot CLI hosts can exit as soon as the compaction assistant finishes, before the
@@ -681,13 +773,19 @@ export function createContinuationHooks(client, directory, policySource, timings
681
773
  if (state !== undefined)
682
774
  state.promptPending = false;
683
775
  const report = state?.latestReport;
776
+ const recovery = state?.preserveCompactionScope === true;
684
777
  const authority = report === undefined
685
778
  ? "Sortie rollover policy is authoritative. Preserve only facts supported by the latest coordinator final report."
686
- : "Sortie authoritative latest coordinator final report follows. It overrides all older context; copy its terminal outcomes, counters, and next action exactly:\n" + report;
779
+ : state?.preserveCompactionScope === true
780
+ ? "Preserve unmet user requirements, ordered scope, and no-stop constraints from the conversation. The recovery report overrides only terminal outcomes and counters:\n" + report
781
+ : "Sortie authoritative latest coordinator final report follows. It overrides all older context; copy its terminal outcomes, counters, and next action exactly:\n" + report;
687
782
  output.context = [...(output.context ?? []), authority];
783
+ const rolloverPrompt = recovery ? RECOVERY_ROLLOVER_PROMPT : ROLLOVER_PROMPT;
688
784
  output.prompt = report === undefined
689
- ? ROLLOVER_PROMPT
690
- : `${ROLLOVER_PROMPT}\n\nExact latest coordinator final report (authoritative):\n${report}`;
785
+ ? rolloverPrompt
786
+ : recovery
787
+ ? `${rolloverPrompt}\n\nRecovery report (terminal outcomes and counters only):\n${report}`
788
+ : `${rolloverPrompt}\n\nExact latest coordinator final report (authoritative):\n${report}`;
691
789
  },
692
790
  async sessionCompacted(sessionID) {
693
791
  const state = sessions.get(sessionID);
@@ -760,8 +858,17 @@ export function createContinuationHooks(client, directory, policySource, timings
760
858
  const identity = await readIdentity(sessionID);
761
859
  const active = policy();
762
860
  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);
861
+ state.turnRevision === revision && state.latestCoordinatorReport === report) {
862
+ if (batchCheckpointNeedsContinuation(report)) {
863
+ const recoveredReport = /➡️\s*(?:次action|next_action)\s*:\s*\S/iu.test(report)
864
+ ? report
865
+ : `${report}\n➡️ 次action: 未達user要求の次の独立candidateへ進む`;
866
+ await requestContinuation(sessionID, identity, recoveredReport, true);
867
+ }
868
+ else {
869
+ await issueStepContinue(sessionID, state, report, active.agent);
870
+ }
871
+ }
765
872
  }
766
873
  }
767
874
  }
@@ -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);