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 +9 -7
- package/dist/asset-version.d.ts +1 -1
- package/dist/asset-version.js +1 -1
- package/dist/plugin/continuation.d.ts +1 -1
- package/dist/plugin/continuation.js +126 -19
- package/dist/plugin/gate.d.ts +10 -1
- package/dist/plugin/gate.js +121 -4
- package/dist/plugin/index.js +178 -26
- package/dist/runtime-assets.d.ts +8 -8
- package/dist/runtime-assets.js +103 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|

|
|
13
13
|
|
|
14
14
|
Sortie-dogs is an opt-in OpenCode orchestration plugin. It turns a task into a
|
|
15
|
-
scoped plan, parallel investigation,
|
|
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.
|
|
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":
|
|
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,
|
|
186
|
-
|
|
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
|
-
- **
|
|
211
|
-
|
|
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
|
package/dist/asset-version.d.ts
CHANGED
|
@@ -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-
|
|
5
|
+
export declare const RUNTIME_ASSET_VERSION = "0.3.4-card39";
|
|
6
6
|
export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
|
package/dist/asset-version.js
CHANGED
|
@@ -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-
|
|
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 =
|
|
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 =
|
|
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:
|
|
269
|
-
|
|
270
|
-
|
|
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 (
|
|
344
|
+
if (terminalCheckpoint(text))
|
|
281
345
|
return false;
|
|
282
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
?
|
|
690
|
-
:
|
|
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
|
-
|
|
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
|
}
|
package/dist/plugin/gate.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/plugin/gate.js
CHANGED
|
@@ -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] !== ">")
|
|
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
|
-
//
|
|
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 {
|
|
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);
|