@mgiles/perk 3.1.0 → 3.2.0
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/extension/doors/address.ts +11 -0
- package/extension/doors/dreamWaveTools.ts +29 -15
- package/extension/doors/land.ts +6 -0
- package/extension/doors/learn.ts +16 -3
- package/extension/doors/lifecycleGates.ts +36 -1
- package/extension/doors/objectiveStack.ts +423 -23
- package/extension/doors/plannotatorHandoff.ts +80 -8
- package/extension/doors/prReview.ts +2 -1
- package/extension/doors/prReviewBrowser.ts +75 -27
- package/extension/doors/ready.ts +209 -17
- package/extension/doors/reviewWaveTools.ts +24 -3
- package/extension/doors/stackReviewBrowser.ts +573 -0
- package/extension/doors/submit.ts +36 -10
- package/extension/doors/submitPrReview.ts +116 -19
- package/extension/factories/objectivePlan.ts +12 -6
- package/extension/factories/objectiveSave.ts +5 -2
- package/extension/index.ts +26 -1
- package/extension/substrate/config.ts +4 -2
- package/extension/substrate/paths.ts +2 -7
- package/extension/substrate/resolverLease.ts +363 -0
- package/extension/substrate/toolGating.ts +16 -0
- package/extension/substrate/workflowState.ts +13 -3
- package/extension/waves/adversarialReviewWave.ts +16 -2
- package/package.json +1 -1
- package/prompts/_fixtures/live.yaml +63 -0
- package/prompts/contexts/adapters/tombell-plan.md +4 -0
- package/prompts/contexts/plan-authoring.md +6 -5
- package/prompts/stages/conflict-resolution-continuation.md +6 -0
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-author/seed.md +1 -1
- package/prompts/stages/objective-reconcile-ready.md +7 -0
- package/prompts/stages/objective-sync.md +1 -1
- package/prompts/stages/stack-review/cold.md +1 -0
- package/prompts/stages/stack-review-browser/stack.md +23 -0
- package/shared/README.md +0 -3
- package/shared/bindings.yaml +3 -0
- package/shared/contracts.md +2010 -1753
- package/shared/registry.yaml +16 -1
- package/shared/schemas/outputs/objective-stack-status.schema.json +172 -1
- package/shared/schemas/outputs/pr-ready.schema.json +110 -2
- package/shared/contracts-history.md +0 -605
|
@@ -11,15 +11,21 @@
|
|
|
11
11
|
// - `objective_stack_status` / `objective_stack_sync` / `objective_stack_adopt` /
|
|
12
12
|
// `objective_stack_recover` / `objective_stack_land` — separately-typed tools (no broad
|
|
13
13
|
// action enum), strict tri-state param decode (refuse the whole call on any malformed
|
|
14
|
-
// field), non-terminating. Warm consent: the plain sync/continue/abort calls pass
|
|
15
|
-
// (the human's gesture/driven approval is the
|
|
16
|
-
// recover-with-abandon, and the mutating land additionally
|
|
17
|
-
// Cold-envelope decodes are lenient/render-only.
|
|
14
|
+
// field), non-terminating. Warm consent: the plain sync/continue/abort/resolve calls pass
|
|
15
|
+
// `--yes` where they reach the cold door (the human's gesture/driven approval is the
|
|
16
|
+
// consent); adopt (mutating), recover-with-abandon, and the mutating land additionally
|
|
17
|
+
// require `confirm: true`. Cold-envelope decodes are lenient/render-only.
|
|
18
|
+
//
|
|
19
|
+
// Two warm drives live here: §8.56's reconcile drive (`driveStackReconcile`) and §8.51's sync
|
|
20
|
+
// conflict drive (`driveSyncConflictResolution` — a mutating sync/continue refusing
|
|
21
|
+
// `rebase_conflict` auto-dispatches the `perk.conflict-resolver` subagent into the retained
|
|
22
|
+
// continuation worktree; `objective_stack_sync { resolve: true }` is the explicit-request twin).
|
|
18
23
|
//
|
|
19
24
|
// Objective inference everywhere: explicit param/argument → workflow `active_objective` →
|
|
20
25
|
// plan-ref `objective_id` (the resolveReconcileObjective precedent); the warm layer always
|
|
21
26
|
// passes the resolved objective explicitly to the cold door.
|
|
22
27
|
|
|
28
|
+
import { basename, dirname } from "node:path";
|
|
23
29
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
30
|
import { reconcileGuidance } from "../factories/objectivePlan.ts";
|
|
25
31
|
import { bindingSuffix } from "../substrate/bindingDelivery.ts";
|
|
@@ -33,13 +39,15 @@ import {
|
|
|
33
39
|
stringField,
|
|
34
40
|
} from "../substrate/coldDoor.ts";
|
|
35
41
|
import { registerPerkCommand } from "../substrate/command.ts";
|
|
36
|
-
import { resolveIssueBackendId } from "../substrate/config.ts";
|
|
42
|
+
import { resolveIssueBackendId, subagentModel } from "../substrate/config.ts";
|
|
37
43
|
import { render } from "../substrate/prompts.ts";
|
|
44
|
+
import { acquireResolverLease, releaseResolverClaim } from "../substrate/resolverLease.ts";
|
|
38
45
|
import { failFor, ok, type Result } from "../substrate/result.ts";
|
|
39
46
|
import type { ToolGating } from "../substrate/toolGating.ts";
|
|
40
47
|
import { booleanParam, idParam, paramsOf, stringParam } from "../substrate/toolParams.ts";
|
|
41
|
-
import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
48
|
+
import { appendWorkflowState, branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
42
49
|
import { report } from "../surfaces/report.ts";
|
|
50
|
+
import { CONFLICT_RESOLUTION_ATTEMPT_CAP, resetConflictAttempts } from "./submit.ts";
|
|
43
51
|
|
|
44
52
|
/** Every stack tool returns the same slim ok-details: the resolved objective the cold door was
|
|
45
53
|
* driven with (the envelope itself is render-only — nothing persisted). */
|
|
@@ -133,6 +141,8 @@ export function renderStackStatus(payload: ColdJson): string {
|
|
|
133
141
|
const pr = numberField(layer, "pr_number");
|
|
134
142
|
if (pr !== undefined) parts.push(`pr #${pr}`);
|
|
135
143
|
parts.push(`[${stringField(layer, "publication") ?? "?"}]`);
|
|
144
|
+
const handoff = stringField(layer, "handoff");
|
|
145
|
+
if (handoff !== undefined && handoff !== "not_applicable") parts.push(`handoff ${handoff}`);
|
|
136
146
|
lines.push(` ${index + 1}. ${parts.join(" ")}`);
|
|
137
147
|
});
|
|
138
148
|
const readiness = objectField(train, "next_build_ready");
|
|
@@ -143,6 +153,30 @@ export function renderStackStatus(payload: ColdJson): string {
|
|
|
143
153
|
lines.push(` build blocked: ${stringField(readiness, "reason") ?? "?"}`);
|
|
144
154
|
}
|
|
145
155
|
}
|
|
156
|
+
// The additive planning_gate block (contracts §8.46): render the handoff rows from their
|
|
157
|
+
// pinned fields only — leniently (missing/mistyped fields degrade, never reject); the
|
|
158
|
+
// technical rows already ride the build-blocked line/findings.
|
|
159
|
+
const gate = objectField(train, "planning_gate");
|
|
160
|
+
if (gate !== undefined && booleanField(gate, "ready") !== true) {
|
|
161
|
+
const gatedNode = stringField(gate, "node_id") ?? "?";
|
|
162
|
+
for (const row of objectListField(gate, "blockers")) {
|
|
163
|
+
if (stringField(row, "kind") !== "handoff") continue;
|
|
164
|
+
const state = stringField(row, "handoff_state") ?? "?";
|
|
165
|
+
let detail =
|
|
166
|
+
`${stringField(row, "dependency_node_id") ?? "?"} ` +
|
|
167
|
+
`(plan #${stringField(row, "plan") ?? "?"}, PR #${numberField(row, "pr") ?? "?"}) — ` +
|
|
168
|
+
state;
|
|
169
|
+
const stamped = stringField(row, "stamped_head");
|
|
170
|
+
const current = stringField(row, "current_head");
|
|
171
|
+
if (state === "stale" && stamped !== undefined && current !== undefined) {
|
|
172
|
+
detail += `; stamped ${stamped.slice(0, 12)} ≠ head ${current.slice(0, 12)}`;
|
|
173
|
+
}
|
|
174
|
+
const remediation = stringField(row, "remediation") ?? "?";
|
|
175
|
+
lines.push(
|
|
176
|
+
` planning gated: ${gatedNode} waits on ${detail}; record the handoff: ${remediation}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
146
180
|
lines.push(...findingLines(train, "blockers"));
|
|
147
181
|
lines.push(...findingLines(train, "information"));
|
|
148
182
|
}
|
|
@@ -167,9 +201,16 @@ export function renderStackStatus(payload: ColdJson): string {
|
|
|
167
201
|
}`,
|
|
168
202
|
);
|
|
169
203
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
204
|
+
if (booleanField(continuation, "parseable") === true) {
|
|
205
|
+
lines.push(
|
|
206
|
+
" resume via objective_stack_sync { continue: true }, discard via { abort: true }, or " +
|
|
207
|
+
"dispatch automated resolution via { resolve: true } (on explicit human request)",
|
|
208
|
+
);
|
|
209
|
+
} else {
|
|
210
|
+
lines.push(
|
|
211
|
+
" resume via objective_stack_sync { continue: true }, or discard via { abort: true }",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
173
214
|
}
|
|
174
215
|
const orphans = objectField(payload, "orphaned_residue");
|
|
175
216
|
if (orphans !== undefined) {
|
|
@@ -442,9 +483,13 @@ interface SyncToolParams {
|
|
|
442
483
|
dryRun: boolean;
|
|
443
484
|
continue_: boolean;
|
|
444
485
|
abort: boolean;
|
|
486
|
+
/** The warm-only explicit resolver dispatch (§8.51) — never reaches the cold sync
|
|
487
|
+
* mutation worker (its only cold call is the corroborating status re-read). */
|
|
488
|
+
resolve: boolean;
|
|
445
489
|
}
|
|
446
490
|
|
|
447
|
-
/** Strict decode + the §8.49 mode matrix (same as the CLI's
|
|
491
|
+
/** Strict decode + the §8.49 mode matrix (same as the CLI's, plus the warm-only `resolve`,
|
|
492
|
+
* which composes with NOTHING): null = refuse the whole call. */
|
|
448
493
|
function decodeSyncParams(params: unknown): SyncToolParams | null {
|
|
449
494
|
const p = paramsOf(params);
|
|
450
495
|
if (p === null) return null;
|
|
@@ -453,22 +498,28 @@ function decodeSyncParams(params: unknown): SyncToolParams | null {
|
|
|
453
498
|
const dryRun = booleanParam(p, "dry_run");
|
|
454
499
|
const continue_ = booleanParam(p, "continue");
|
|
455
500
|
const abort = booleanParam(p, "abort");
|
|
501
|
+
const resolve = booleanParam(p, "resolve");
|
|
456
502
|
if (objective === null || base === null || dryRun === null || continue_ === null) return null;
|
|
457
|
-
if (abort === null) return null;
|
|
503
|
+
if (abort === null || resolve === null) return null;
|
|
458
504
|
const decoded: SyncToolParams = {
|
|
459
505
|
objective: objective ?? undefined,
|
|
460
506
|
base: base ?? false,
|
|
461
507
|
dryRun: dryRun ?? false,
|
|
462
508
|
continue_: continue_ ?? false,
|
|
463
509
|
abort: abort ?? false,
|
|
510
|
+
resolve: resolve ?? false,
|
|
464
511
|
};
|
|
512
|
+
if (decoded.resolve && (decoded.base || decoded.dryRun || decoded.continue_ || decoded.abort)) {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
465
515
|
if (decoded.continue_ && decoded.abort) return null;
|
|
466
516
|
if ((decoded.continue_ || decoded.abort) && (decoded.base || decoded.dryRun)) return null;
|
|
467
517
|
return decoded;
|
|
468
518
|
}
|
|
469
519
|
|
|
470
520
|
/** The sync argv by mode: continue/abort take no cascade flags; `--yes` rides every mutating
|
|
471
|
-
* path (warm consent — the human's gesture/driven approval); a dry run passes no `--yes`.
|
|
521
|
+
* path (warm consent — the human's gesture/driven approval); a dry run passes no `--yes`.
|
|
522
|
+
* Never reached with `resolve` — stackSync branches to the warm dispatcher first. */
|
|
472
523
|
export function buildStackSyncArgs(objective: string, p: SyncToolParams): string[] {
|
|
473
524
|
const args = ["objective", "stack", "sync", objective];
|
|
474
525
|
if (p.continue_) {
|
|
@@ -605,7 +656,7 @@ async function stackStatus(
|
|
|
605
656
|
return ok(renderStackStatus(r.data), { objective });
|
|
606
657
|
}
|
|
607
658
|
|
|
608
|
-
async function stackSync(
|
|
659
|
+
export async function stackSync(
|
|
609
660
|
pi: ExtensionAPI,
|
|
610
661
|
ctx: ExtensionContext,
|
|
611
662
|
p: SyncToolParams,
|
|
@@ -613,16 +664,34 @@ async function stackSync(
|
|
|
613
664
|
const fail = failFor(ctx, "objective-sync", "objective_stack_sync");
|
|
614
665
|
const objective = resolveStackObjective(p.objective, ctx);
|
|
615
666
|
if (objective === null) return fail(NO_OBJECTIVE_MESSAGE, "no_objective");
|
|
667
|
+
if (p.resolve) {
|
|
668
|
+
// The warm-only explicit dispatch (§8.51): never calls the cold sync worker — the shared
|
|
669
|
+
// dispatch core corroborates against the CURRENT status projection (no freshness token;
|
|
670
|
+
// the human's explicit request is the trigger) and injects the resolver dispatch.
|
|
671
|
+
const outcome = await dispatchSyncResolver(pi, ctx, objective, null);
|
|
672
|
+
if (outcome.dispatched) {
|
|
673
|
+
return ok(
|
|
674
|
+
`conflict-resolution dispatch injected (attempt ${outcome.attempt} of ` +
|
|
675
|
+
`${CONFLICT_RESOLUTION_ATTEMPT_CAP})`,
|
|
676
|
+
{ objective },
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
return fail(outcome.reason, outcome.errorType);
|
|
680
|
+
}
|
|
616
681
|
const mode: SyncMode = p.continue_ ? "continue" : p.abort ? "abort" : "sync";
|
|
617
682
|
const r = await runColdDoor<ColdJson>(pi, ctx, buildStackSyncArgs(objective, p), {
|
|
618
683
|
label: "perk objective stack sync",
|
|
619
684
|
decode: (payload) => payload,
|
|
620
685
|
});
|
|
621
686
|
if (!r.ok) return fail(r.message, r.errorType);
|
|
687
|
+
// Any clean, non-declined mutating completion re-opens the shared bounded conflict budget.
|
|
688
|
+
if (!p.dryRun && booleanField(r.data, "declined") !== true) {
|
|
689
|
+
resetConflictAttempts(pi, ctx, "objective-sync");
|
|
690
|
+
}
|
|
622
691
|
return ok(renderSyncOutcome(r.data, mode), { objective });
|
|
623
692
|
}
|
|
624
693
|
|
|
625
|
-
async function stackAdopt(
|
|
694
|
+
export async function stackAdopt(
|
|
626
695
|
pi: ExtensionAPI,
|
|
627
696
|
ctx: ExtensionContext,
|
|
628
697
|
p: AdoptToolParams,
|
|
@@ -643,6 +712,9 @@ async function stackAdopt(
|
|
|
643
712
|
decode: (payload) => payload,
|
|
644
713
|
});
|
|
645
714
|
if (!r.ok) return fail(r.message, r.errorType);
|
|
715
|
+
if (!p.dryRun && booleanField(r.data, "declined") !== true) {
|
|
716
|
+
resetConflictAttempts(pi, ctx, "objective-sync");
|
|
717
|
+
}
|
|
646
718
|
return ok(renderSyncOutcome(r.data, "sync"), { objective });
|
|
647
719
|
}
|
|
648
720
|
|
|
@@ -784,6 +856,305 @@ export function driveStackReconcile(
|
|
|
784
856
|
}
|
|
785
857
|
}
|
|
786
858
|
|
|
859
|
+
// --- the sync conflict drive (contracts.md §8.51 — the second warm drive) ------------------------
|
|
860
|
+
|
|
861
|
+
/** The ONE lineage predicate — the exact warm twin of the Python `_SAFE_LINEAGE_RE` vocabulary. */
|
|
862
|
+
const LINEAGE_RE = /^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$/;
|
|
863
|
+
/** A canonical 26-char Crockford ULID operation id (`validated_targets`' shape, warm side). */
|
|
864
|
+
const OPERATION_ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
865
|
+
/**
|
|
866
|
+
* The shell-inert absolute-path vocabulary: no space, no shell metacharacter — the dispatch
|
|
867
|
+
* template renders an UNQUOTED `cd {{ worktree }}`, so containment here is what keeps the
|
|
868
|
+
* interpolation from ever becoming shell syntax. A legitimate-but-exotic worktree root (e.g.
|
|
869
|
+
* containing spaces) degrades to report-only — an accepted, recorded degradation.
|
|
870
|
+
*/
|
|
871
|
+
const SHELL_INERT_ABS_PATH_RE = /^\/[A-Za-z0-9._/-]+$/;
|
|
872
|
+
const BRANCH_RE = /^[A-Za-z0-9._/-]{1,200}$/;
|
|
873
|
+
|
|
874
|
+
function hasDotDotSegment(path: string): boolean {
|
|
875
|
+
return path.split("/").includes("..");
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** The sanitized dispatch facts — every string is whitelist-validated before it gets here. */
|
|
879
|
+
export interface SyncConflictDispatch {
|
|
880
|
+
operationId: string;
|
|
881
|
+
manifestPath: string;
|
|
882
|
+
objective: string;
|
|
883
|
+
node: string;
|
|
884
|
+
branch: string;
|
|
885
|
+
pr: number;
|
|
886
|
+
worktree: string;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Corroborate a retained sync conflict against the fresh status projection (§8.51): the
|
|
891
|
+
* continuation facts, the refusal-message freshness token, lineage/worktree containment, the
|
|
892
|
+
* conflicting layer's identity, and the interpolation vocabularies. Fail-closed: any miss is
|
|
893
|
+
* ineligible with the specific reason. `refusalMessage === null` skips ONLY the freshness-token
|
|
894
|
+
* clause (the explicit `resolve` path — there is no refusal; the human's request against the
|
|
895
|
+
* CURRENT projection is the trigger).
|
|
896
|
+
*/
|
|
897
|
+
export function corroborateSyncConflict(
|
|
898
|
+
payload: ColdJson,
|
|
899
|
+
refusalMessage: string | null,
|
|
900
|
+
): { eligible: true; dispatch: SyncConflictDispatch } | { eligible: false; reason: string } {
|
|
901
|
+
const ineligible = (reason: string) => ({ eligible: false as const, reason });
|
|
902
|
+
const continuation = objectField(payload, "continuation");
|
|
903
|
+
if (continuation === undefined) {
|
|
904
|
+
return ineligible(
|
|
905
|
+
"the status projection reports no pending continuation — nothing was retained (a failed " +
|
|
906
|
+
"manifest write cleans its residue); fix the underlying issue and rerun the sync",
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
if (booleanField(continuation, "parseable") !== true) {
|
|
910
|
+
return ineligible(
|
|
911
|
+
"the pending continuation manifest is UNPARSEABLE — automated resolution cannot " +
|
|
912
|
+
"corroborate it; discard the retained continuation via objective_stack_sync " +
|
|
913
|
+
"{ abort: true } and rerun the sync",
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
const operationId = stringField(continuation, "operation_id");
|
|
917
|
+
const node = stringField(continuation, "conflict_node_id");
|
|
918
|
+
const worktree = stringField(continuation, "worktree_path");
|
|
919
|
+
const manifestPath = stringField(continuation, "manifest_path");
|
|
920
|
+
if (
|
|
921
|
+
operationId === undefined ||
|
|
922
|
+
node === undefined ||
|
|
923
|
+
worktree === undefined ||
|
|
924
|
+
manifestPath === undefined
|
|
925
|
+
) {
|
|
926
|
+
return ineligible(
|
|
927
|
+
"the pending continuation is missing operation/layer/path facts — resolve the rebase by " +
|
|
928
|
+
"hand in the retained worktree, or discard via objective_stack_sync { abort: true }",
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
if (!EVIDENCE_ID_RE.test(node)) {
|
|
932
|
+
return ineligible(
|
|
933
|
+
"the continuation's conflict node id falls outside the identifier vocabulary — refusing " +
|
|
934
|
+
"to dispatch; resolve by hand in the retained worktree",
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
// Freshness: every `rebase_conflict` arm names the layer whose rebase actually STOPPED as
|
|
938
|
+
// `for layer <node_id> ` (trailing space — `2.2` never matches `2.22`). On the continue-time
|
|
939
|
+
// failed-rewrite arm the PRESERVED manifest names the OLD layer while the message names the
|
|
940
|
+
// NEW one — the mismatch keeps the drive report-only over stale layer facts.
|
|
941
|
+
if (refusalMessage !== null && !refusalMessage.includes(`for layer ${node} `)) {
|
|
942
|
+
return ineligible(
|
|
943
|
+
`the refusal does not name the manifest's conflict layer ${node} — the retained ` +
|
|
944
|
+
"manifest may be a stale snapshot (a failed progress rewrite preserves the previous " +
|
|
945
|
+
"one); resolve the in-progress rebase by hand in the retained worktree, or discard via " +
|
|
946
|
+
"objective_stack_sync { abort: true }",
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
const train = objectField(payload, "train") ?? {};
|
|
950
|
+
const lineage = stringField(train, "delivery_lineage");
|
|
951
|
+
if (lineage === undefined || !LINEAGE_RE.test(lineage)) {
|
|
952
|
+
return ineligible(
|
|
953
|
+
"the train reports no vocabulary-valid delivery lineage — refusing to derive the claim " +
|
|
954
|
+
"path; dispatch the resolution by hand",
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
if (
|
|
958
|
+
basename(manifestPath) !== `${lineage}.json` ||
|
|
959
|
+
basename(dirname(manifestPath)) !== "sync-continuations"
|
|
960
|
+
) {
|
|
961
|
+
return ineligible(
|
|
962
|
+
"the continuation manifest path is not sync-continuations/<lineage>.json — refusing to " +
|
|
963
|
+
"claim it; dispatch the resolution by hand",
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
if (!OPERATION_ULID_RE.test(operationId)) {
|
|
967
|
+
return ineligible(
|
|
968
|
+
"the continuation's operation id is not a canonical ULID — refusing to dispatch; resolve " +
|
|
969
|
+
"by hand in the retained worktree",
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
if (
|
|
973
|
+
!SHELL_INERT_ABS_PATH_RE.test(worktree) ||
|
|
974
|
+
hasDotDotSegment(worktree) ||
|
|
975
|
+
basename(worktree) !== `sync-${operationId}`
|
|
976
|
+
) {
|
|
977
|
+
return ineligible(
|
|
978
|
+
"the retained worktree path falls outside the shell-inert containment vocabulary " +
|
|
979
|
+
"(absolute, sync-<operation-id>, no spaces or shell metacharacters) — dispatch the " +
|
|
980
|
+
"resolution by hand in the retained worktree the status names",
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
const layer = objectListField(train, "layers").find(
|
|
984
|
+
(row) => stringField(row, "node_id") === node,
|
|
985
|
+
);
|
|
986
|
+
if (layer === undefined) {
|
|
987
|
+
return ineligible(
|
|
988
|
+
"the conflicting layer is missing from the train projection — refusing to dispatch; " +
|
|
989
|
+
"inspect the train and resolve by hand",
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
const branch = stringField(layer, "branch");
|
|
993
|
+
const pr = numberField(layer, "pr_number");
|
|
994
|
+
if (branch === undefined || pr === undefined) {
|
|
995
|
+
return ineligible(
|
|
996
|
+
"the conflicting layer carries no branch/PR identity — the resolver's retained mode " +
|
|
997
|
+
"requires the PR; resolve by hand in the retained worktree",
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
if (!BRANCH_RE.test(branch) || branch.startsWith("/") || hasDotDotSegment(branch)) {
|
|
1001
|
+
return ineligible(
|
|
1002
|
+
"the conflicting layer's branch falls outside the interpolation vocabulary — refusing " +
|
|
1003
|
+
"to dispatch; resolve by hand in the retained worktree",
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
// The redirect-resolved ACTIVE objective id — never the requested one (the
|
|
1007
|
+
// driveStackReconcile rule): out-of-vocabulary → never drive.
|
|
1008
|
+
const objective = stringField(objectField(payload, "objective") ?? {}, "id");
|
|
1009
|
+
if (objective === undefined || !EVIDENCE_ID_RE.test(objective)) {
|
|
1010
|
+
return ineligible(
|
|
1011
|
+
"the projection's objective id falls outside the identifier vocabulary — refusing to " +
|
|
1012
|
+
"dispatch; resolve by hand in the retained worktree",
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
return {
|
|
1016
|
+
eligible: true,
|
|
1017
|
+
dispatch: { operationId, manifestPath, objective, node, branch, pr, worktree },
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** Render the resolver dispatch (§8.57: the template is the canonical carrier of the dispatch
|
|
1022
|
+
* procedure AND the completed-only outcome gate — no other surface re-carries them). */
|
|
1023
|
+
export function syncConflictResolutionGuidance(
|
|
1024
|
+
dispatch: SyncConflictDispatch,
|
|
1025
|
+
attempt: number,
|
|
1026
|
+
cap: number,
|
|
1027
|
+
model?: string,
|
|
1028
|
+
): string {
|
|
1029
|
+
return render("stages/conflict-resolution-continuation.md", {
|
|
1030
|
+
objective: dispatch.objective,
|
|
1031
|
+
node: dispatch.node,
|
|
1032
|
+
branch: dispatch.branch,
|
|
1033
|
+
pr: String(dispatch.pr),
|
|
1034
|
+
worktree: dispatch.worktree,
|
|
1035
|
+
attempt: String(attempt),
|
|
1036
|
+
cap: String(cap),
|
|
1037
|
+
model: model ?? "",
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
type DispatchOutcome =
|
|
1042
|
+
| { dispatched: true; attempt: number }
|
|
1043
|
+
| {
|
|
1044
|
+
dispatched: false;
|
|
1045
|
+
errorType: "no_continuation" | "attempt_cap" | "resolver_busy" | "state_error";
|
|
1046
|
+
reason: string;
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* The shared dispatch core (auto-drive AND the explicit `resolve` request): re-read the status
|
|
1051
|
+
* projection, corroborate, check the shared bounded cap, take the resolver claim, persist the
|
|
1052
|
+
* verified increment (a precondition for injection — an unverifiable counter must never bypass
|
|
1053
|
+
* the cap), then inject the rendered dispatch. Resolve-and-stop: nothing here publishes — the
|
|
1054
|
+
* injected template owns the outcome gate and the human's `continue` stays the only publication
|
|
1055
|
+
* gesture.
|
|
1056
|
+
*/
|
|
1057
|
+
export async function dispatchSyncResolver(
|
|
1058
|
+
pi: ExtensionAPI,
|
|
1059
|
+
ctx: ExtensionContext,
|
|
1060
|
+
objective: string,
|
|
1061
|
+
refusalMessage: string | null,
|
|
1062
|
+
): Promise<DispatchOutcome> {
|
|
1063
|
+
const r = await runColdDoor<ColdJson>(
|
|
1064
|
+
pi,
|
|
1065
|
+
ctx,
|
|
1066
|
+
["objective", "stack", "status", objective, "--json"],
|
|
1067
|
+
{ label: "perk objective stack status", decode: (payload) => payload },
|
|
1068
|
+
);
|
|
1069
|
+
if (!r.ok) {
|
|
1070
|
+
return {
|
|
1071
|
+
dispatched: false,
|
|
1072
|
+
errorType: "no_continuation",
|
|
1073
|
+
reason: `the corroborating status re-read failed — ${r.message}`,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
const corroborated = corroborateSyncConflict(r.data, refusalMessage);
|
|
1077
|
+
if (!corroborated.eligible) {
|
|
1078
|
+
return { dispatched: false, errorType: "no_continuation", reason: corroborated.reason };
|
|
1079
|
+
}
|
|
1080
|
+
const dispatch = corroborated.dispatch;
|
|
1081
|
+
const attempts = rebuildWorkflowState(branchOf(ctx)).conflict_resolution_attempts ?? 0;
|
|
1082
|
+
if (attempts >= CONFLICT_RESOLUTION_ATTEMPT_CAP) {
|
|
1083
|
+
return {
|
|
1084
|
+
dispatched: false,
|
|
1085
|
+
errorType: "attempt_cap",
|
|
1086
|
+
reason:
|
|
1087
|
+
`the rebase conflict persists after ${attempts} resolution attempt(s) — resolve ` +
|
|
1088
|
+
`manually in the retained worktree ${dispatch.worktree} (\`git rebase --continue\`), ` +
|
|
1089
|
+
"then resume via objective_stack_sync { continue: true } or discard via { abort: true }.",
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
const lease = acquireResolverLease(dispatch.manifestPath, dispatch.operationId);
|
|
1093
|
+
if (!lease.acquired) {
|
|
1094
|
+
return {
|
|
1095
|
+
dispatched: false,
|
|
1096
|
+
errorType: lease.kind === "busy" ? "resolver_busy" : "state_error",
|
|
1097
|
+
reason: lease.reason,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
const next = attempts + 1;
|
|
1101
|
+
const persisted = appendWorkflowState(pi, ctx, {
|
|
1102
|
+
data: { conflict_resolution_attempts: next },
|
|
1103
|
+
field: "conflict_resolution_attempts",
|
|
1104
|
+
expected: next,
|
|
1105
|
+
scope: "objective-sync",
|
|
1106
|
+
failure: `conflict_resolution_attempts read-back failed (expected ${next})`,
|
|
1107
|
+
});
|
|
1108
|
+
if (!persisted) {
|
|
1109
|
+
// The verified increment is a precondition for injection: without it the cap is
|
|
1110
|
+
// unenforceable. Release the claim acquired in THIS call so the withheld dispatch leaves
|
|
1111
|
+
// no phantom holder — token-fenced, so a successor's raced-in claim is never deleted.
|
|
1112
|
+
releaseResolverClaim(dispatch.manifestPath, lease.token);
|
|
1113
|
+
return {
|
|
1114
|
+
dispatched: false,
|
|
1115
|
+
errorType: "state_error",
|
|
1116
|
+
reason: "the attempt counter could not be persisted — dispatch withheld",
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
const model = subagentModel(ctx.cwd, "conflict-resolver");
|
|
1120
|
+
const message =
|
|
1121
|
+
syncConflictResolutionGuidance(dispatch, next, CONFLICT_RESOLUTION_ATTEMPT_CAP, model) +
|
|
1122
|
+
bindingSuffix(ctx.cwd, "command:objective-sync");
|
|
1123
|
+
if (ctx.isIdle()) {
|
|
1124
|
+
pi.sendUserMessage(message);
|
|
1125
|
+
} else {
|
|
1126
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
1127
|
+
}
|
|
1128
|
+
return { dispatched: true, attempt: next };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* The auto-fire wrapper: a MUTATING sync/continue — never dry-run, never abort, never adopt —
|
|
1133
|
+
* refusing `rebase_conflict` dispatches the resolver (the human's mutating gesture is the
|
|
1134
|
+
* approval). Failure arms only report: the tool result already carries the `rebase_conflict`
|
|
1135
|
+
* refusal, so a miss here must never mask it.
|
|
1136
|
+
*/
|
|
1137
|
+
export async function driveSyncConflictResolution(
|
|
1138
|
+
pi: ExtensionAPI,
|
|
1139
|
+
ctx: ExtensionContext,
|
|
1140
|
+
objective: string,
|
|
1141
|
+
mode: SyncMode,
|
|
1142
|
+
dryRun: boolean,
|
|
1143
|
+
details: StackResult["details"],
|
|
1144
|
+
): Promise<void> {
|
|
1145
|
+
if (details.ok) return;
|
|
1146
|
+
if (details.error_type !== "rebase_conflict") return;
|
|
1147
|
+
if (dryRun) return;
|
|
1148
|
+
if (mode !== "sync" && mode !== "continue") return;
|
|
1149
|
+
const outcome = await dispatchSyncResolver(pi, ctx, objective, details.error);
|
|
1150
|
+
if (outcome.dispatched) return;
|
|
1151
|
+
if (outcome.errorType === "attempt_cap" || outcome.errorType === "state_error") {
|
|
1152
|
+
report(ctx, "objective-sync", "error", outcome.reason, { alsoLog: true });
|
|
1153
|
+
} else {
|
|
1154
|
+
report(ctx, "objective-sync", "warning", outcome.reason);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
787
1158
|
// --- registration --------------------------------------------------------------------------------
|
|
788
1159
|
|
|
789
1160
|
const STATUS_TOOL_GUIDELINES = [
|
|
@@ -792,7 +1163,8 @@ const STATUS_TOOL_GUIDELINES = [
|
|
|
792
1163
|
|
|
793
1164
|
const SYNC_TOOL_GUIDELINES = [
|
|
794
1165
|
"Call objective_stack_sync only inside the /objective-sync flow: preview with dry_run: true, present the cascade to the human, and act (no dry_run) ONLY on explicit human approval.",
|
|
795
|
-
"The modes are mutually exclusive: continue resumes a
|
|
1166
|
+
"The modes are mutually exclusive: continue resumes a resolved conflict continuation, abort discards it, resolve dispatches the perk.conflict-resolver subagent into the retained worktree on explicit human request; none composes with base/dry_run.",
|
|
1167
|
+
"A mutating sync/continue that stops on a rebase conflict auto-dispatches the resolver (bounded attempts); follow the injected dispatch instructions — they own the resume gate.",
|
|
796
1168
|
];
|
|
797
1169
|
|
|
798
1170
|
const ADOPT_TOOL_GUIDELINES = [
|
|
@@ -851,10 +1223,12 @@ export function registerObjectiveStack(pi: ExtensionAPI, gating: ToolGating): vo
|
|
|
851
1223
|
label: "Objective stack sync",
|
|
852
1224
|
description:
|
|
853
1225
|
"Synchronize an objective's published stack after an amend or base advance: preview " +
|
|
854
|
-
"(dry_run), cascade, resume a
|
|
855
|
-
"
|
|
856
|
-
"
|
|
857
|
-
|
|
1226
|
+
"(dry_run), cascade, resume a resolved conflict continuation (continue), discard it " +
|
|
1227
|
+
"(abort), or dispatch the conflict-resolver subagent into the retained worktree " +
|
|
1228
|
+
"(resolve, on explicit human request). Modes are mutually exclusive. Delegates to the " +
|
|
1229
|
+
"perk cold door; call mutating modes only on explicit human approval.",
|
|
1230
|
+
promptSnippet:
|
|
1231
|
+
"Cascade-sync the objective's published stack (preview/continue/abort/resolve modes)",
|
|
858
1232
|
promptGuidelines: SYNC_TOOL_GUIDELINES,
|
|
859
1233
|
executionMode: "sequential",
|
|
860
1234
|
parameters: {
|
|
@@ -876,12 +1250,19 @@ export function registerObjectiveStack(pi: ExtensionAPI, gating: ToolGating): vo
|
|
|
876
1250
|
continue: {
|
|
877
1251
|
type: "boolean",
|
|
878
1252
|
description:
|
|
879
|
-
"Resume the retained conflict continuation (after the
|
|
1253
|
+
"Resume the retained conflict continuation (after the rebase was finished — by " +
|
|
1254
|
+
"the human or by the dispatched resolver; publication stays the human's call).",
|
|
880
1255
|
},
|
|
881
1256
|
abort: {
|
|
882
1257
|
type: "boolean",
|
|
883
1258
|
description: "Discard the retained conflict continuation (worktree + temp refs).",
|
|
884
1259
|
},
|
|
1260
|
+
resolve: {
|
|
1261
|
+
type: "boolean",
|
|
1262
|
+
description:
|
|
1263
|
+
"Dispatch the conflict-resolver subagent into the retained continuation worktree " +
|
|
1264
|
+
"(explicit human request; composes with no other mode).",
|
|
1265
|
+
},
|
|
885
1266
|
},
|
|
886
1267
|
},
|
|
887
1268
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -892,12 +1273,31 @@ export function registerObjectiveStack(pi: ExtensionAPI, gating: ToolGating): vo
|
|
|
892
1273
|
"objective-sync",
|
|
893
1274
|
"objective_stack_sync",
|
|
894
1275
|
)(
|
|
895
|
-
"objective_stack_sync takes { objective?, base?, dry_run?, continue?, abort
|
|
896
|
-
"continue/abort are mutually exclusive and take no other mode flag"
|
|
1276
|
+
"objective_stack_sync takes { objective?, base?, dry_run?, continue?, abort?, " +
|
|
1277
|
+
"resolve? } — continue/abort are mutually exclusive and take no other mode flag; " +
|
|
1278
|
+
"resolve composes with nothing",
|
|
897
1279
|
"bad_input",
|
|
898
1280
|
);
|
|
899
1281
|
}
|
|
900
|
-
|
|
1282
|
+
const result = await stackSync(pi, ctx, decoded);
|
|
1283
|
+
// The auto-fire drive (§8.51): after the tool result settles, a mutating sync/continue
|
|
1284
|
+
// that refused `rebase_conflict` dispatches the resolver. Skipped for `resolve` (that IS
|
|
1285
|
+
// the dispatch) and when no objective resolved (the fail was `no_objective`).
|
|
1286
|
+
if (!decoded.resolve) {
|
|
1287
|
+
const objective = resolveStackObjective(decoded.objective, ctx);
|
|
1288
|
+
if (objective !== null) {
|
|
1289
|
+
const mode: SyncMode = decoded.continue_ ? "continue" : decoded.abort ? "abort" : "sync";
|
|
1290
|
+
await driveSyncConflictResolution(
|
|
1291
|
+
pi,
|
|
1292
|
+
ctx,
|
|
1293
|
+
objective,
|
|
1294
|
+
mode,
|
|
1295
|
+
decoded.dryRun,
|
|
1296
|
+
result.details,
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
return result;
|
|
901
1301
|
},
|
|
902
1302
|
});
|
|
903
1303
|
|