@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.
Files changed (43) hide show
  1. package/extension/doors/address.ts +11 -0
  2. package/extension/doors/dreamWaveTools.ts +29 -15
  3. package/extension/doors/land.ts +6 -0
  4. package/extension/doors/learn.ts +16 -3
  5. package/extension/doors/lifecycleGates.ts +36 -1
  6. package/extension/doors/objectiveStack.ts +423 -23
  7. package/extension/doors/plannotatorHandoff.ts +80 -8
  8. package/extension/doors/prReview.ts +2 -1
  9. package/extension/doors/prReviewBrowser.ts +75 -27
  10. package/extension/doors/ready.ts +209 -17
  11. package/extension/doors/reviewWaveTools.ts +24 -3
  12. package/extension/doors/stackReviewBrowser.ts +573 -0
  13. package/extension/doors/submit.ts +36 -10
  14. package/extension/doors/submitPrReview.ts +116 -19
  15. package/extension/factories/objectivePlan.ts +12 -6
  16. package/extension/factories/objectiveSave.ts +5 -2
  17. package/extension/index.ts +26 -1
  18. package/extension/substrate/config.ts +4 -2
  19. package/extension/substrate/paths.ts +2 -7
  20. package/extension/substrate/resolverLease.ts +363 -0
  21. package/extension/substrate/toolGating.ts +16 -0
  22. package/extension/substrate/workflowState.ts +13 -3
  23. package/extension/waves/adversarialReviewWave.ts +16 -2
  24. package/package.json +1 -1
  25. package/prompts/_fixtures/live.yaml +63 -0
  26. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  27. package/prompts/contexts/plan-authoring.md +6 -5
  28. package/prompts/stages/conflict-resolution-continuation.md +6 -0
  29. package/prompts/stages/conflict-resolution.md +1 -1
  30. package/prompts/stages/objective-author/adopt.md +1 -1
  31. package/prompts/stages/objective-author/file.md +1 -1
  32. package/prompts/stages/objective-author/seed.md +1 -1
  33. package/prompts/stages/objective-reconcile-ready.md +7 -0
  34. package/prompts/stages/objective-sync.md +1 -1
  35. package/prompts/stages/stack-review/cold.md +1 -0
  36. package/prompts/stages/stack-review-browser/stack.md +23 -0
  37. package/shared/README.md +0 -3
  38. package/shared/bindings.yaml +3 -0
  39. package/shared/contracts.md +2010 -1753
  40. package/shared/registry.yaml +16 -1
  41. package/shared/schemas/outputs/objective-stack-status.schema.json +172 -1
  42. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  43. 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 `--yes`
15
- // (the human's gesture/driven approval is the consent); adopt (mutating),
16
- // recover-with-abandon, and the mutating land additionally require `confirm: true`.
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
- lines.push(
171
- " resume via objective_stack_sync { continue: true }, or discard via { abort: true }",
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): null = refuse the whole call. */
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 human-resolved conflict continuation, abort discards it; neither composes with base/dry_run. perk never drives conflict resolution the human finishes the rebase in the retained worktree first.",
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 human-resolved conflict continuation (continue), or " +
855
- "discard it (abort). Modes are mutually exclusive. Delegates to the perk cold door; " +
856
- "call mutating modes only on explicit human approval.",
857
- promptSnippet: "Cascade-sync the objective's published stack (preview/continue/abort modes)",
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 human finished the rebase).",
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
- return stackSync(pi, ctx, decoded);
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