@dev-loops/core 1.0.2-pre.0 → 1.0.2

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.
@@ -54,6 +54,16 @@ const STRATEGY_DEFAULT_STOP_RULES = Object.freeze({
54
54
  ],
55
55
  });
56
56
 
57
+ const RECONCILIATION_ACCEPTANCE_TEMPLATE = deepFreeze({
58
+ criteria: [
59
+ { id: "reconcile", must: "Resolve the reported authoritative-state conflict before selecting or executing a strategy.", severity: "required" },
60
+ ],
61
+ evidence: ["commands-run", "validation-output"],
62
+ maxFinalizationTurns: 1,
63
+ needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
64
+ activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
65
+ });
66
+
57
67
  // ---------------------------------------------------------------------------
58
68
  // Acceptance template table
59
69
  // ---------------------------------------------------------------------------
@@ -131,7 +141,7 @@ register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "default", {
131
141
  activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
132
142
  });
133
143
 
134
- // local_implementation · spike run (SPIKE-RELAXED-GATE-PROFILE, #1628): a
144
+ // local_implementation · spike run (SPIKE-RELAXED-GATE-PROFILE): a
135
145
  // spike-mode spin resolves the relaxed `spike` gate profile instead of the
136
146
  // default local-implementation gate. Kept as its own acceptance key so the
137
147
  // generic default can stay approach-agnostic.
@@ -305,7 +315,7 @@ function deriveRequiredReads(bundle, resolverOutput) {
305
315
  }
306
316
 
307
317
  // ---------------------------------------------------------------------------
308
- // specSource derivation (issue #1025 — lightweight PR-body-as-spec)
318
+ // specSource derivation — lightweight PR-body-as-spec
309
319
  // ---------------------------------------------------------------------------
310
320
 
311
321
  /**
@@ -440,7 +450,7 @@ function deriveCwd(bundle, options = {}) {
440
450
  }
441
451
 
442
452
  /** Repo-relative root for loop-owned worktrees. The `dev-loops/` namespace */
443
- /** marks them so cleanup can only ever remove its own (issue #909). */
453
+ /** marks them so cleanup can only ever remove its own. */
444
454
  export const WORKTREE_NAMESPACE = "tmp/worktrees/dev-loops";
445
455
 
446
456
  /**
@@ -470,7 +480,7 @@ function flattenSlugSegment(s) {
470
480
  }
471
481
 
472
482
  function buildWorktreeSlug(artifact, kind) {
473
- // Canonical naming is namespaced + no branch suffix (issue #909) so the path
483
+ // Canonical naming is namespaced + no branch suffix so the path
474
484
  // is recomputable from the issue/PR number alone (cleanup can find it).
475
485
  if (kind === DEV_LOOP_TARGET_KIND.ISSUE && Number.isInteger(artifact.issue) && artifact.issue > 0) {
476
486
  return `issue-${artifact.issue}`;
@@ -511,7 +521,7 @@ function normalizeGateState(gateState) {
511
521
 
512
522
 
513
523
  /**
514
- * Normalize the structured retrospective findings (issue #1077, Reading B).
524
+ * Normalize the structured retrospective findings.
515
525
  *
516
526
  * The retrospective is advisory: it never blocks merge or any lifecycle
517
527
  * transition. Its findings travel in the handoff envelope (the conductor's
@@ -553,7 +563,7 @@ function resolveSubGate(strategy, gateState) {
553
563
  return "default";
554
564
  }
555
565
 
556
- /** True when the resolver output identifies a spike-mode run (#1628). */
566
+ /** True when the resolver output identifies a spike-mode run. */
557
567
  function isSpikeRun(resolverOutput) {
558
568
  return Boolean(resolverOutput && resolverOutput.spikeIntakeState);
559
569
  }
@@ -584,8 +594,45 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
584
594
  throw new Error("handoff-envelope: resolverOutput is required and must be an object");
585
595
  }
586
596
 
587
- const bundle = resolverOutput.bundle ?? resolverOutput;
588
- const strategy = requireString(bundle.selectedStrategy, "resolverOutput.selectedStrategy");
597
+ const isWrapped = Object.hasOwn(resolverOutput, "bundle");
598
+ if (isWrapped && (!resolverOutput.bundle || typeof resolverOutput.bundle !== "object")) {
599
+ throw new Error("handoff-envelope: resolverOutput.bundle must be an object when present");
600
+ }
601
+ const bundle = isWrapped ? resolverOutput.bundle : resolverOutput;
602
+ const strategy = bundle.selectedStrategy === null
603
+ ? null
604
+ : requireString(bundle.selectedStrategy, "resolverOutput.selectedStrategy");
605
+ const routeKind = strategy === null
606
+ ? requireString(bundle.routeKind, "resolverOutput.routeKind")
607
+ : (trimmedOrNull(bundle.routeKind) ?? "route");
608
+ const selectedGate = trimmedOrNull(bundle.selectedGate);
609
+ const outerBundleKind = isWrapped ? trimmedOrNull(resolverOutput.bundleKind) : null;
610
+ const nestedBundleKind = trimmedOrNull(bundle.bundleKind);
611
+ if (outerBundleKind && nestedBundleKind && outerBundleKind !== nestedBundleKind) {
612
+ throw new Error(`handoff-envelope: outer bundleKind (${outerBundleKind}) and inner bundleKind (${nestedBundleKind}) must agree`);
613
+ }
614
+ const bundleKind = outerBundleKind ?? nestedBundleKind;
615
+ const isReconciliation = routeKind === "needs_reconcile"
616
+ && strategy === null
617
+ && selectedGate === "fail_closed_reconcile"
618
+ && bundleKind === "needs_reconcile";
619
+ if (isWrapped && isReconciliation && (
620
+ resolverOutput.bundleKind !== "needs_reconcile"
621
+ || bundle.bundleKind !== "needs_reconcile"
622
+ || resolverOutput.selectedStrategy !== "none"
623
+ || bundle.selectedStrategy !== null
624
+ )) {
625
+ throw new Error("handoff-envelope: wrapped needs_reconcile output requires exact outer and inner bundleKind needs_reconcile markers, outer selectedStrategy none, and inner selectedStrategy null");
626
+ }
627
+ if (bundleKind === "needs_reconcile" && !isReconciliation) {
628
+ throw new Error("handoff-envelope: outer/inner bundleKind needs_reconcile requires inner routeKind needs_reconcile, selectedGate fail_closed_reconcile, and selectedStrategy null");
629
+ }
630
+ if (routeKind === "needs_reconcile" && !isReconciliation) {
631
+ throw new Error("handoff-envelope: routeKind needs_reconcile requires outer bundleKind needs_reconcile, selectedGate fail_closed_reconcile, and selectedStrategy null");
632
+ }
633
+ if (strategy === null && !isReconciliation) {
634
+ throw new Error("handoff-envelope: a null resolverOutput.selectedStrategy is allowed only for the canonical needs_reconcile/fail_closed_reconcile tuple");
635
+ }
589
636
  const executionMode = requireString(bundle.executionMode, "resolverOutput.executionMode");
590
637
  const nextAction = requireString(bundle.nextAction, "resolverOutput.nextAction");
591
638
 
@@ -593,27 +640,33 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
593
640
  if (!repo) throw new Error("handoff-envelope: repo slug is required (owner/name)");
594
641
 
595
642
  const gs = normalizeGateState(gateState);
596
- // SPIKE-RELAXED-GATE-PROFILE (#1628): a spike-mode spin (startup resolver
643
+ // SPIKE-RELAXED-GATE-PROFILE: a spike-mode spin (startup resolver
597
644
  // result carrying `spikeIntakeState`) resolves the relaxed `spike` gate
598
645
  // profile instead of the default local-implementation gate. The spike
599
646
  // marker lives at the TOP level of the resolver output (the bundle does not
600
647
  // carry it), so it is read off `resolverOutput` directly.
601
- const subGate = (strategy === INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION && isSpikeRun(resolverOutput))
648
+ const subGate = isReconciliation
649
+ ? selectedGate
650
+ : (strategy === INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION && isSpikeRun(resolverOutput))
602
651
  ? "spike"
603
652
  : resolveSubGate(strategy, gs);
604
653
  // Normalize each source independently, then fall back on the normalized result
605
654
  // (not the raw value): a present-but-invalid gateState value must NOT shadow a
606
- // valid options.retrospectiveFindings fallback (issue #1077 review finding).
655
+ // valid options.retrospectiveFindings fallback.
607
656
  const retrospectiveFindings = normalizeRetrospectiveFindings(gateState?.retrospectiveFindings)
608
657
  ?? normalizeRetrospectiveFindings(options.retrospectiveFindings);
609
658
 
610
659
  const target = deriveTarget(bundle, repo);
611
660
  const requiredReads = deriveRequiredReads(bundle, resolverOutput);
612
- const stopRules = deriveStopRules(settings, strategy);
661
+ const stopRules = isReconciliation
662
+ ? ["reconcile", ...(resolveHumanMergeOnly(settings) ? ["merge"] : [])]
663
+ : deriveStopRules(settings, strategy);
613
664
  const gateConfig = deriveGateConfig(settings, subGate);
614
665
  const derivedCwd = deriveCwd(bundle, { repoRoot: options.repoRoot, worktreeCwd: options.worktreeCwd });
615
- const template = lookupAcceptanceTemplate(strategy, subGate);
616
- // Lightweight PR-body-as-spec (issue #1025): retarget the phase-doc criterion
666
+ const template = isReconciliation
667
+ ? RECONCILIATION_ACCEPTANCE_TEMPLATE
668
+ : lookupAcceptanceTemplate(strategy, subGate);
669
+ // Lightweight PR-body-as-spec: retarget the phase-doc criterion
617
670
  // text to the PR description. Null/phase_doc leaves the criteria untouched, so
618
671
  // the non-lightweight path stays byte-identical.
619
672
  const specSource = deriveSpecSource(bundle, resolverOutput);
@@ -623,7 +676,7 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
623
676
  ? { ...options.overrides }
624
677
  : undefined;
625
678
 
626
- // Sanctioned operation → wrapper command map (issue #1081). Core is
679
+ // Sanctioned operation → wrapper command map. Core is
627
680
  // consumer-agnostic: it carries whatever map the consumer supplies (the
628
681
  // `loop build-envelope` CLI injects this repo's scripts/... paths) so every
629
682
  // spawned subagent receives it by DEFAULT. Core defines the SHAPE only —
@@ -632,7 +685,7 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
632
685
  ? options.sanctionedCommands
633
686
  : undefined;
634
687
 
635
- // Surface the *effective* async-start posture alongside the *configured* one (#834). The
688
+ // Surface the *effective* async-start posture alongside the *configured* one. The
636
689
  // configured `asyncStartMode` is echoed verbatim from settings (back-compat), but the contract
637
690
  // is relaxed at validation time under the Claude harness (resolveEffectiveAsyncStartMode →
638
691
  // "allowed" when CLAUDECODE=1). Without surfacing the effective value, a `required` envelope
@@ -650,6 +703,8 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
650
703
  maxCopilotRounds: settings?.refinement?.maxCopilotRounds ?? 5,
651
704
  executionMode,
652
705
 
706
+ ...(isReconciliation ? { routeKind, selectedStrategy: null } : {}),
707
+
653
708
  nextAction,
654
709
  requiredReads,
655
710
 
@@ -660,7 +715,7 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
660
715
  requireDraftFirst: settings?.workflow?.requireDraftFirst ?? false,
661
716
 
662
717
  cwd: derivedCwd,
663
- worktreeRequired: true,
718
+ worktreeRequired: !isReconciliation,
664
719
 
665
720
  acceptance: {
666
721
  criteria: acceptanceCriteria,
@@ -686,21 +741,21 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
686
741
  envelope.sanctionedCommands = sanctionedCommands;
687
742
  }
688
743
 
689
- // Advisory retrospective findings (issue #1077, Reading B). Optional structured
744
+ // Advisory retrospective findings. Optional structured
690
745
  // field carrying the check-retro-tooling.mjs JSON output to the conductor. Never a
691
746
  // gate — the conductor surfaces these as an advisory PR comment, not a block.
692
747
  if (retrospectiveFindings) {
693
748
  envelope.retrospectiveFindings = retrospectiveFindings;
694
749
  }
695
750
 
696
- // Canonical spec source (issue #1025). Optional: only set when the resolver
751
+ // Canonical spec source. Optional: only set when the resolver
697
752
  // marks a lightweight PR-body-as-spec session, so the default (phase-doc) path
698
753
  // carries no specSource field and its envelope stays byte-identical.
699
754
  if (specSource) {
700
755
  envelope.specSource = specSource;
701
756
  }
702
757
 
703
- // #1462: the ONLY per-round-varying block, kept LAST. Every field here changes
758
+ // The ONLY per-round-varying block, kept LAST. Every field here changes
704
759
  // between builds/rounds (the timestamp, the head SHA, CI status, thread/round
705
760
  // counts); isolating them as the envelope's tail keeps everything above a
706
761
  // byte-stable prefix that a fresh reviewer spawn can cache-READ instead of
@@ -832,6 +887,58 @@ export function validateHandoffEnvelope(envelope) {
832
887
  });
833
888
  }
834
889
 
890
+ // ----- terminal reconciliation tuple -----
891
+ // routeKind/selectedStrategy are omitted for ordinary routed envelopes to
892
+ // preserve the v1 shape. If either is present, both must identify the one
893
+ // supported no-strategy terminal envelope exactly; serialized/tampered
894
+ // envelopes receive the same fail-closed enforcement as the builder.
895
+ const hasRouteKind = Object.hasOwn(envelope, "routeKind");
896
+ const hasSelectedStrategy = Object.hasOwn(envelope, "selectedStrategy");
897
+ if (hasRouteKind || hasSelectedStrategy || envelope.currentGate === "fail_closed_reconcile") {
898
+ const reconciliationStopRulesAreCanonical = Array.isArray(envelope.stopRules)
899
+ && envelope.stopRules[0] === "reconcile"
900
+ && (envelope.stopRules.length === 1
901
+ || (envelope.stopRules.length === 2 && envelope.stopRules[1] === "merge"));
902
+ // Routing invariants only: acceptance prose remains the resolver's
903
+ // business, while nextAction must begin with one of the known fail-closed
904
+ // directives rather than arbitrary non-empty text.
905
+ const reconciliationCriterion = envelope.acceptance?.criteria;
906
+ const reconciliationAcceptanceIsCanonical = Array.isArray(reconciliationCriterion)
907
+ && reconciliationCriterion.some((criterion) => criterion?.id === "reconcile" && criterion?.severity === "required");
908
+ const normalizedNextAction = typeof envelope.nextAction === "string" ? envelope.nextAction.trim() : "";
909
+ const reconciliationNextActionIsActionable = [
910
+ "Reconcile ",
911
+ "Stop and reconcile ",
912
+ "Complete or explicitly skip ",
913
+ "Local implementation requires worktree isolation",
914
+ ].some((directive) => normalizedNextAction.startsWith(directive));
915
+ if (
916
+ envelope.routeKind !== "needs_reconcile"
917
+ || envelope.selectedStrategy !== null
918
+ || envelope.currentGate !== "fail_closed_reconcile"
919
+ || envelope.worktreeRequired !== false
920
+ || !reconciliationStopRulesAreCanonical
921
+ || !reconciliationAcceptanceIsCanonical
922
+ || !reconciliationNextActionIsActionable
923
+ ) {
924
+ errors.push({
925
+ field: "routeKind/selectedStrategy/currentGate/worktreeRequired/stopRules/acceptance.criteria",
926
+ reason: "terminal reconciliation must use the exact needs_reconcile / null / fail_closed_reconcile tuple, require no worktree, instruct reconciliation, stop at reconcile (plus optional merge), and carry a required reconcile acceptance criterion",
927
+ got: {
928
+ routeKind: envelope.routeKind,
929
+ selectedStrategy: envelope.selectedStrategy,
930
+ currentGate: envelope.currentGate,
931
+ worktreeRequired: envelope.worktreeRequired,
932
+ stopRules: envelope.stopRules,
933
+ nextAction: envelope.nextAction,
934
+ acceptanceCriteria: envelope.acceptance?.criteria,
935
+ acceptanceEvidence: envelope.acceptance?.evidence,
936
+ maxFinalizationTurns: envelope.acceptance?.maxFinalizationTurns,
937
+ },
938
+ });
939
+ }
940
+ }
941
+
835
942
  // ----- requiredReads -----
836
943
  if (!Array.isArray(envelope.requiredReads)) {
837
944
  errors.push({ field: "requiredReads", reason: "must be an array", got: envelope.requiredReads });
@@ -931,7 +1038,7 @@ export function validateHandoffEnvelope(envelope) {
931
1038
  });
932
1039
  }
933
1040
 
934
- // ----- asyncStartEffective (required field; the harness-resolved posture, #834) -----
1041
+ // ----- asyncStartEffective (required field; the harness-resolved posture) -----
935
1042
  if (envelope.asyncStartEffective === undefined || envelope.asyncStartEffective === null) {
936
1043
  errors.push({
937
1044
  field: "asyncStartEffective",
@@ -946,7 +1053,7 @@ export function validateHandoffEnvelope(envelope) {
946
1053
  });
947
1054
  }
948
1055
 
949
- // ----- retrospectiveFindings (optional, advisory — issue #1077) -----
1056
+ // ----- retrospectiveFindings (optional, advisory) -----
950
1057
  if (envelope.retrospectiveFindings !== undefined && envelope.retrospectiveFindings !== null) {
951
1058
  const rf = envelope.retrospectiveFindings;
952
1059
  if (typeof rf !== "object" || Array.isArray(rf)) {
@@ -968,7 +1075,7 @@ export function validateHandoffEnvelope(envelope) {
968
1075
  }
969
1076
  }
970
1077
 
971
- // ----- specSource (optional — issue #1025, lightweight PR-body-as-spec) -----
1078
+ // ----- specSource (optional — lightweight PR-body-as-spec) -----
972
1079
  if (envelope.specSource !== undefined && envelope.specSource !== null) {
973
1080
  const validSources = [CANONICAL_SPEC_SOURCE.PHASE_DOC, CANONICAL_SPEC_SOURCE.PR_BODY];
974
1081
  if (typeof envelope.specSource !== "string" || !validSources.includes(envelope.specSource)) {
@@ -980,8 +1087,8 @@ export function validateHandoffEnvelope(envelope) {
980
1087
  }
981
1088
  }
982
1089
 
983
- // ----- gateState.derivedAt (informational, warn on missing) — #1462 moved the
984
- // volatile timestamp into the gateState tail so the rest stays byte-stable -----
1090
+ // ----- gateState.derivedAt (informational, warn on missing) — the
1091
+ // volatile timestamp lives in the gateState tail so the rest stays byte-stable -----
985
1092
  if (typeof envelope.gateState?.derivedAt !== "string" || !envelope.gateState.derivedAt.trim()) {
986
1093
  warnings.push({ field: "gateState.derivedAt", reason: "should be an ISO 8601 timestamp" });
987
1094
  }