loopforge 3.6.0 → 3.7.1

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 (109) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +28 -13
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +3 -8
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +23 -4
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +148 -47
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +8 -16
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts.map +1 -1
  14. package/dist/cognitive-facts.js +2 -3
  15. package/dist/cognitive-facts.js.map +1 -1
  16. package/dist/cognitive-governance.d.ts +30 -7
  17. package/dist/cognitive-governance.d.ts.map +1 -1
  18. package/dist/cognitive-governance.js +75 -22
  19. package/dist/cognitive-governance.js.map +1 -1
  20. package/dist/committed-round.d.ts +48 -4
  21. package/dist/committed-round.d.ts.map +1 -1
  22. package/dist/committed-round.js +177 -10
  23. package/dist/committed-round.js.map +1 -1
  24. package/dist/enforcement-gate.d.ts +24 -19
  25. package/dist/enforcement-gate.d.ts.map +1 -1
  26. package/dist/enforcement-gate.js +216 -146
  27. package/dist/enforcement-gate.js.map +1 -1
  28. package/dist/engine.d.ts +10 -5
  29. package/dist/engine.d.ts.map +1 -1
  30. package/dist/engine.js +52 -31
  31. package/dist/engine.js.map +1 -1
  32. package/dist/evidence-claims.d.ts.map +1 -1
  33. package/dist/evidence-claims.js +3 -9
  34. package/dist/evidence-claims.js.map +1 -1
  35. package/dist/evidence-provider.d.ts +13 -19
  36. package/dist/evidence-provider.d.ts.map +1 -1
  37. package/dist/evidence-provider.js +65 -100
  38. package/dist/evidence-provider.js.map +1 -1
  39. package/dist/generate-schema.js +104 -43
  40. package/dist/generate-schema.js.map +1 -1
  41. package/dist/index.d.ts +3 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +2 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/loop-compiler.d.ts +18 -10
  46. package/dist/loop-compiler.d.ts.map +1 -1
  47. package/dist/loop-compiler.js +205 -343
  48. package/dist/loop-compiler.js.map +1 -1
  49. package/dist/loop-store.d.ts +11 -16
  50. package/dist/loop-store.d.ts.map +1 -1
  51. package/dist/loop-store.js +21 -63
  52. package/dist/loop-store.js.map +1 -1
  53. package/dist/mcp/round-lifecycle.d.ts +18 -3
  54. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  55. package/dist/mcp/round-lifecycle.js +162 -35
  56. package/dist/mcp/round-lifecycle.js.map +1 -1
  57. package/dist/mcp/server.d.ts.map +1 -1
  58. package/dist/mcp/server.js +12 -3
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/mcp/session.d.ts +35 -4
  61. package/dist/mcp/session.d.ts.map +1 -1
  62. package/dist/mcp/session.js +169 -52
  63. package/dist/mcp/session.js.map +1 -1
  64. package/dist/mcp/tools.d.ts.map +1 -1
  65. package/dist/mcp/tools.js +170 -39
  66. package/dist/mcp/tools.js.map +1 -1
  67. package/dist/policy.d.ts +10 -13
  68. package/dist/policy.d.ts.map +1 -1
  69. package/dist/policy.js +3 -6
  70. package/dist/policy.js.map +1 -1
  71. package/dist/prompt-assembler.d.ts +6 -4
  72. package/dist/prompt-assembler.d.ts.map +1 -1
  73. package/dist/prompt-assembler.js +46 -100
  74. package/dist/prompt-assembler.js.map +1 -1
  75. package/dist/protocol.d.ts +52 -51
  76. package/dist/protocol.d.ts.map +1 -1
  77. package/dist/protocol.js +5 -28
  78. package/dist/protocol.js.map +1 -1
  79. package/dist/round-coordinator.d.ts +19 -0
  80. package/dist/round-coordinator.d.ts.map +1 -1
  81. package/dist/round-coordinator.js +63 -4
  82. package/dist/round-coordinator.js.map +1 -1
  83. package/dist/round-driver.d.ts +4 -3
  84. package/dist/round-driver.d.ts.map +1 -1
  85. package/dist/round-driver.js +3 -10
  86. package/dist/round-driver.js.map +1 -1
  87. package/dist/round-transaction.d.ts +4 -0
  88. package/dist/round-transaction.d.ts.map +1 -1
  89. package/dist/round-transaction.js +9 -0
  90. package/dist/round-transaction.js.map +1 -1
  91. package/dist/self-eval.d.ts +41 -3
  92. package/dist/self-eval.d.ts.map +1 -1
  93. package/dist/self-eval.js +125 -13
  94. package/dist/self-eval.js.map +1 -1
  95. package/dist/token-utils.d.ts +7 -3
  96. package/dist/token-utils.d.ts.map +1 -1
  97. package/dist/token-utils.js +18 -3
  98. package/dist/token-utils.js.map +1 -1
  99. package/dist/verification-gate.d.ts +32 -24
  100. package/dist/verification-gate.d.ts.map +1 -1
  101. package/dist/verification-gate.js +262 -178
  102. package/dist/verification-gate.js.map +1 -1
  103. package/dist/version.d.ts +5 -0
  104. package/dist/version.d.ts.map +1 -0
  105. package/dist/version.js +5 -0
  106. package/dist/version.js.map +1 -0
  107. package/loop_policy.json +19 -7
  108. package/package.json +6 -6
  109. package/skills/perception/SKILL.md +56 -16
@@ -7,7 +7,7 @@ import { createHash } from "node:crypto";
7
7
  import { getPolicy } from "./policy.js";
8
8
  import { AgentStatus, makeLoopCompileResponse, makeLoopHealth, makeLoopObjective, makeConstraintMeta, makeMilestoneSummary, makeRollingSummary, makeSubGoal, makeTaskAlignment, } from "./protocol.js";
9
9
  import { createCanonicalLoopState, renderCanonicalStateMarkdown, } from "./canonical-state.js";
10
- import { decodeCommittedRound, decodeMergedRound, machineGitMotionSeries, mergedRoundsFromEntries, } from "./committed-round.js";
10
+ import { decodeRound, entryLineage, entryCriteriaMet, entryCriteriaRemaining, entryActiveConstraints, entryRetractedConstraints, entryProgressEstimate, entryEmergedSubtasks, entrySubGoalUpdates, machineGitMotionSeries, mergedRoundsFromEntries, } from "./committed-round.js";
11
11
  import { contractRoundEvaluations, deriveActiveRoundContract, } from "./round-contract.js";
12
12
  import { assemblePromptArtifact } from "./prompt-assembler.js";
13
13
  import { decidePromptLevel, } from "./prompt-policy.js";
@@ -17,15 +17,9 @@ function contextEntries(context) {
17
17
  return [];
18
18
  return context.results.filter((value) => value !== null && typeof value === "object" && !Array.isArray(value));
19
19
  }
20
- function lineage(entry) {
21
- const value = entry.loop_lineage ?? entry.lineage;
22
- return value !== null && typeof value === "object" && !Array.isArray(value)
23
- ? value
24
- : {};
25
- }
26
20
  function loopEntries(loopId, context) {
27
21
  return contextEntries(context)
28
- .filter((entry) => entry.loop_id === loopId || lineage(entry).loop_id === loopId)
22
+ .filter((entry) => entry.loop_id === loopId || entryLineage(entry).loop_id === loopId)
29
23
  .sort((a, b) => entryRound(a) - entryRound(b));
30
24
  }
31
25
  /** v3.3.1: The canonical entry for a round — its compile-time lineage entry
@@ -51,12 +45,6 @@ function roundCanonicalEntry(entries, round) {
51
45
  }) ??
52
46
  null);
53
47
  }
54
- /** Return the canonical read model when an entry is a committed round.
55
- * Uncommitted compiler lineage has no durable decision yet and deliberately
56
- * remains on the local compile path; it cannot be treated as history. */
57
- function committedView(entry) {
58
- return decodeMergedRound(entry) ?? decodeCommittedRound(entry);
59
- }
60
48
  export function computeGoalTextHash(text) {
61
49
  const normalized = text.trim().replace(/\s+/g, " ").toLowerCase();
62
50
  return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
@@ -75,7 +63,7 @@ export function readPresentedBaseline(loopId, round, context) {
75
63
  const entry = roundCanonicalEntry(loopEntries(loopId, context), round - 1);
76
64
  if (!entry)
77
65
  return null;
78
- const data = lineage(entry);
66
+ const data = entryLineage(entry);
79
67
  const constraintIds = data.presented_constraint_ids;
80
68
  const subGoals = data.presented_subgoals;
81
69
  const milestoneRanges = data.presented_milestone_ranges;
@@ -97,7 +85,7 @@ export function getPreviousRound(loopId, round, context) {
97
85
  const entry = roundCanonicalEntry(loopEntries(loopId, context), round);
98
86
  if (!entry)
99
87
  return null;
100
- const data = lineage(entry);
88
+ const data = entryLineage(entry);
101
89
  return {
102
90
  round,
103
91
  goal_id: typeof data.goal_id === "string" ? data.goal_id : "",
@@ -156,9 +144,16 @@ function evolveObjective(request, context) {
156
144
  let version = current.version ?? 1;
157
145
  if (last?.objective_refinement?.trim()) {
158
146
  const refinement = last.objective_refinement.trim();
159
- history.push(refinement);
160
- objective = `${objective}\nRefinement: ${refinement}`;
161
- version++;
147
+ // M5: fold each refinement exactly once per persisted objective state.
148
+ // A retry recompiles the same round against the same committed
149
+ // last_round_result; without this guard every retry attempt re-appends
150
+ // the refinement and inflates the version — the objective text and the
151
+ // state file grew "Refinement: X" once per attempt.
152
+ if (!history.includes(refinement)) {
153
+ history.push(refinement);
154
+ objective = `${objective}\nRefinement: ${refinement}`;
155
+ version++;
156
+ }
162
157
  }
163
158
  const revisions = new Map((last?.revised_success_criteria ?? []).map((item) => [item.old, item.new]));
164
159
  return makeLoopObjective({
@@ -188,7 +183,7 @@ function evolveConstraints(request, objective, previous, context) {
188
183
  const lastRetractedRound = new Map();
189
184
  if (window > 0) {
190
185
  for (const entry of loopEntries(request.loop_id, context)) {
191
- const retractedList = lineage(entry).retracted_constraints;
186
+ const retractedList = entryLineage(entry).retracted_constraints;
192
187
  if (!Array.isArray(retractedList))
193
188
  continue;
194
189
  const rnd = entryRound(entry);
@@ -221,47 +216,6 @@ function evolveConstraints(request, objective, previous, context) {
221
216
  // ═══════════════════════════════════════════════════════════════════════════
222
217
  // Hierarchical Summary (v2.1)
223
218
  // ═══════════════════════════════════════════════════════════════════════════
224
- /** Extract execution_evidence from a vault entry, handling both direct-field
225
- * and lineage-nested shapes. Returns null when absent. */
226
- function entryExecutionEvidence(entry) {
227
- const view = committedView(entry);
228
- if (view)
229
- return view.executionEvidence;
230
- const direct = entry.execution_evidence;
231
- if (direct && typeof direct === "object" && !Array.isArray(direct)) {
232
- return direct;
233
- }
234
- const lin = lineage(entry);
235
- const nested = lin.execution_evidence;
236
- if (nested && typeof nested === "object" && !Array.isArray(nested)) {
237
- return nested;
238
- }
239
- return null;
240
- }
241
- /** Read success_criteria_met from an entry's execution_evidence. */
242
- function entryCriteriaMet(entry) {
243
- const view = committedView(entry);
244
- if (view)
245
- return view.executionEvidence?.success_criteria_met
246
- ?.filter((value) => typeof value === "string") ?? [];
247
- const ev = entryExecutionEvidence(entry);
248
- if (!ev)
249
- return [];
250
- const arr = ev.success_criteria_met;
251
- return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
252
- }
253
- /** Read success_criteria_remaining from an entry's execution_evidence. */
254
- function entryCriteriaRemaining(entry) {
255
- const view = committedView(entry);
256
- if (view)
257
- return view.executionEvidence?.success_criteria_remaining
258
- ?.filter((value) => typeof value === "string") ?? [];
259
- const ev = entryExecutionEvidence(entry);
260
- if (!ev)
261
- return [];
262
- const arr = ev.success_criteria_remaining;
263
- return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
264
- }
265
219
  /** v3.2: Deterministic lessons learned — constraints violated repeatedly or
266
220
  * verification checks failing repeatedly across rounds (full history, unlike
267
221
  * the enforcement gate's R2 3-round window). Presentation only: the output
@@ -273,7 +227,7 @@ export function deriveLessons(loopId, context, currentRound) {
273
227
  const warns = new Map();
274
228
  for (const entry of entries) {
275
229
  const rnd = entryRound(entry);
276
- const view = committedView(entry);
230
+ const view = decodeRound(entry);
277
231
  const viols = view
278
232
  ? view.constraintViolations ?? []
279
233
  : Array.isArray(entry.constraint_violations)
@@ -454,41 +408,10 @@ function detectNewCriteria(current, previous) {
454
408
  return currMet;
455
409
  return currMet.filter((curr) => prevMet.every((prev) => !criteriaMatch(curr, prev)));
456
410
  }
457
- /** Read constraints_active from an entry's lineage. */
458
- function entryActiveConstraints(entry) {
459
- const view = committedView(entry);
460
- if (view)
461
- return view.activeConstraints ?? [];
462
- const lin = lineage(entry);
463
- const arr = lin.constraints_active;
464
- return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
465
- }
466
- /** Read retracted_constraints from an entry. */
467
- function entryRetractedConstraints(entry) {
468
- const view = committedView(entry);
469
- if (view)
470
- return view.retractedConstraints ?? [];
471
- const direct = entry.retracted_constraints;
472
- if (Array.isArray(direct))
473
- return direct.filter((v) => typeof v === "string");
474
- const lin = lineage(entry);
475
- const nested = lin.retracted_constraints;
476
- if (Array.isArray(nested))
477
- return nested.filter((v) => typeof v === "string");
478
- return [];
479
- }
480
- /** Read progress_estimate from an entry's execution_evidence. */
481
- function entryProgressEstimate(entry) {
482
- const ev = entryExecutionEvidence(entry);
483
- if (!ev)
484
- return 0;
485
- const pe = ev.progress_estimate;
486
- return typeof pe === "number" ? pe : 0;
487
- }
488
411
  /** Build a single MilestoneSummary from a range of completed round entries. */
489
412
  function buildMilestoneFromEntries(phaseEntries, startRound, endRound, label, kind) {
490
413
  const last = phaseEntries.at(-1);
491
- const lastView = last ? committedView(last) : null;
414
+ const lastView = last ? decodeRound(last) : null;
492
415
  const outcomeRaw = lastView
493
416
  ? (lastView.outputSummary ?? "")
494
417
  : last && typeof last.output_summary === "string" ? last.output_summary : "";
@@ -572,8 +495,8 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
572
495
  const outcomes = [];
573
496
  const issues = [];
574
497
  for (const entry of windowEntries) {
575
- const view = committedView(entry);
576
- const data = lineage(entry);
498
+ const view = decodeRound(entry);
499
+ const data = entryLineage(entry);
577
500
  const round = entryRound(entry);
578
501
  const success = view ? view.success : entry.success ?? data.success;
579
502
  const summary = view
@@ -606,7 +529,7 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
606
529
  for (let i = 0; i < completedEntries.length; i++) {
607
530
  const entry = completedEntries[i];
608
531
  const rnd = entryRound(entry);
609
- const lin = lineage(entry);
532
+ const lin = entryLineage(entry);
610
533
  // Signal 1: Agent-declared checkpoint
611
534
  const isAgentCheckpoint = lin.compression_checkpoint === true;
612
535
  // Signal 2: New criteria met (compare with previous vault entry)
@@ -685,66 +608,18 @@ export function deriveConstraintId(text) {
685
608
  export function deriveCriterionId(text) {
686
609
  return "cr-" + deriveItemId(text);
687
610
  }
688
- /** v2.11: Check whether a user-provided reference looks like a constraint ID.
689
- * Matches the pattern c-XXXXXXXX where X is a hex digit. */
690
- export function isConstraintId(ref) {
691
- return /^c-[a-f0-9]{8}$/.test(ref);
692
- }
693
611
  /** v2.11: Check whether a user-provided reference looks like a criterion ID.
694
- * Matches the pattern cr-XXXXXXXX where X is a hex digit. */
695
- export function isCriterionId(ref) {
612
+ * Matches the pattern cr-XXXXXXXX where X is a hex digit. Module-local:
613
+ * consumers with the same need (round-contract.ts) keep their own copy to
614
+ * avoid an import cycle. */
615
+ function isCriterionId(ref) {
696
616
  return /^cr-[a-f0-9]{8}$/.test(ref);
697
617
  }
698
- /** Read emerged_subtasks from a vault entry (handles direct + lineage nesting). */
699
- function entryEmergedSubtasks(entry) {
700
- const view = committedView(entry);
701
- if (view)
702
- return view.emergedSubtasks ?? [];
703
- const direct = entry.emerged_subtasks;
704
- if (Array.isArray(direct))
705
- return direct.filter((v) => typeof v === "string");
706
- const lin = lineage(entry);
707
- const nested = lin.emerged_subtasks;
708
- if (Array.isArray(nested))
709
- return nested.filter((v) => typeof v === "string");
710
- return [];
711
- }
712
- /** Read completed_subtasks from an entry. */
713
- function entryCompletedSubtasks(entry) {
714
- const view = committedView(entry);
715
- if (view)
716
- return view.completedSubtasks ?? [];
717
- const direct = entry.completed_subtasks;
718
- if (Array.isArray(direct))
719
- return direct.filter((v) => typeof v === "string");
720
- return [];
721
- }
722
- /** Read blocked_subtasks from an entry. */
723
- function entryBlockedSubtasks(entry) {
724
- const view = committedView(entry);
725
- if (view)
726
- return view.blockedSubtasks ?? [];
727
- const direct = entry.blocked_subtasks;
728
- if (Array.isArray(direct))
729
- return direct.filter((v) => typeof v === "string");
730
- return [];
731
- }
732
- /** Read canceled_subtasks from an entry. */
733
- function entryCanceledSubtasks(entry) {
734
- const view = committedView(entry);
735
- if (view)
736
- return view.canceledSubtasks ?? [];
737
- const direct = entry.canceled_subtasks;
738
- if (Array.isArray(direct))
739
- return direct.filter((v) => typeof v === "string");
740
- return [];
741
- }
742
618
  /**
743
- * Match a user-provided sub-task reference against existing SubGoals.
744
- *
745
- * Tries exact SubGoal ID match first (e.g. "sg-a3f2b1c0"), then falls
746
- * back to Jaccard token similarity on descriptions. Returns the index
747
- * of the best-matching SubGoal, or -1 when no match reaches threshold.
619
+ * Match a user-provided description against existing SubGoals for DEDUP
620
+ * (emerged_subtasks creation). Exact ID first, then Jaccard similarity on
621
+ * descriptions. v3.7.1: status transitions no longer use this — they are
622
+ * strict ID references validated before the round advances.
748
623
  */
749
624
  function matchSubGoal(target, subGoals, threshold) {
750
625
  const trimmed = target.trim();
@@ -765,34 +640,90 @@ function matchSubGoal(target, subGoals, threshold) {
765
640
  }
766
641
  return bestScore >= threshold ? bestIdx : -1;
767
642
  }
643
+ // ═══════════════════════════════════════════════════════════════════════════
644
+ // Sub-goal transition matrix (v3.7.1 — single source)
645
+ // ═══════════════════════════════════════════════════════════════════════════
646
+ /** Closed migration matrix. done/canceled are terminal (no out-edges); the
647
+ * matrix rejects re-opening. blocked → in_progress is the recovery path. */
648
+ export const SUBGOAL_TRANSITIONS = {
649
+ in_progress: ["pending", "blocked"],
650
+ done: ["pending", "in_progress", "blocked"],
651
+ blocked: ["pending", "in_progress"],
652
+ canceled: ["pending", "in_progress", "blocked"],
653
+ };
654
+ /** Whether a transition is legal. Same-status is a legal no-op (used by
655
+ * replay idempotency). References to terminal sub-goals are rejected by
656
+ * validateSubGoalUpdates before this is consulted. */
657
+ export function canTransitionSubGoal(from, to) {
658
+ if (from === to)
659
+ return true;
660
+ return SUBGOAL_TRANSITIONS[to].includes(from);
661
+ }
662
+ /** Referential validation of a payload's subgoal_updates against the
663
+ * derived sub-goal set. Returns one error per invalid entry:
664
+ * unknown_id | terminal_reference | illegal_transition. */
665
+ export function validateSubGoalUpdates(subGoals, updates) {
666
+ const byId = new Map(subGoals.map((sg) => [sg.id, sg]));
667
+ const errors = [];
668
+ for (const update of updates) {
669
+ const target = byId.get(update.id);
670
+ if (!target) {
671
+ errors.push({ id: update.id, reason: "unknown_id" });
672
+ continue;
673
+ }
674
+ if (target.status === "done" || target.status === "canceled") {
675
+ errors.push({ id: update.id, reason: "terminal_reference" });
676
+ continue;
677
+ }
678
+ if (!canTransitionSubGoal(target.status, update.status)) {
679
+ errors.push({ id: update.id, reason: "illegal_transition" });
680
+ }
681
+ }
682
+ return errors;
683
+ }
684
+ /** Apply one transition to the accumulated list. Guards make replay of
685
+ * committed history idempotent: terminal sub-goals never change and
686
+ * same-status updates are no-ops. */
687
+ function applySubGoalUpdate(subGoals, update, rnd) {
688
+ const target = subGoals.find((sg) => sg.id === update.id);
689
+ if (!target)
690
+ return;
691
+ if (target.status === "done" || target.status === "canceled")
692
+ return;
693
+ if (!canTransitionSubGoal(target.status, update.status))
694
+ return;
695
+ if (target.status === update.status)
696
+ return;
697
+ target.status = update.status;
698
+ target.status_changed_at_round = rnd;
699
+ if (update.status === "done")
700
+ target.completed_at_round = rnd;
701
+ }
768
702
  /**
769
703
  * Manage the full sub-goal lifecycle across rounds.
770
704
  *
771
- * Accumulates sub-goals from all prior vault entries, processes the current
772
- * round's declarations, derives status changes, and returns the complete
773
- * structured SubGoal list.
774
- *
775
- * Status derivation priority (compiler-driven):
776
- * 1. Agent declared done/canceled/blocked → explicit status change
777
- * 2. next_action match → auto in_progress
778
- * 3. success_criteria_met match → auto done
705
+ * v3.7.1 derivation rule: EVERY committed round (round < currentRound) is
706
+ * replayed in round order — emerged_subtasks create pending items, then
707
+ * subgoal_updates apply explicit transitions. The round's own report
708
+ * (last_round_result, which the engine rebuilds from the same committed
709
+ * evaluation) contributes the same data on the fixture path, so applying
710
+ * it again is an idempotent no-op. Older transitions therefore never
711
+ * regress on later compiles, and no status is ever inferred.
779
712
  */
780
713
  function manageSubGoals(loopId, currentRound, lastRoundResult, vaultContext) {
781
714
  const policy = getPolicy().evolution;
782
715
  const allEntries = loopEntries(loopId, vaultContext);
783
- const completedEntries = allEntries.filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound);
716
+ const completedEntries = allEntries
717
+ .filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound)
718
+ .sort((a, b) => entryRound(a) - entryRound(b));
784
719
  const subGoals = [];
785
- // Phase 1 — Accumulate historical sub-goals from vault entries
786
- for (const entry of completedEntries) {
787
- const rnd = entryRound(entry);
788
- const emerged = entryEmergedSubtasks(entry);
789
- for (const desc of emerged) {
790
- const id = deriveSubGoalId(desc);
720
+ const createEmerged = (descs, rnd) => {
721
+ for (const desc of descs) {
791
722
  // Dedup: skip if an existing sub-goal is too similar
792
723
  if (matchSubGoal(desc, subGoals, policy.subgoal_dedup_threshold) >= 0)
793
724
  continue;
794
725
  subGoals.push(makeSubGoal({
795
- id,
726
+ id: deriveSubGoalId(desc),
796
727
  description: desc.trim(),
797
728
  status: "pending",
798
729
  declared_at_round: rnd,
@@ -800,80 +731,23 @@ function manageSubGoals(loopId, currentRound, lastRoundResult, vaultContext) {
800
731
  priority: subGoals.length,
801
732
  }));
802
733
  }
734
+ };
735
+ // Phase 1 — Replay every committed round in order: create, then apply
736
+ // that round's declared transitions.
737
+ for (const entry of completedEntries) {
738
+ const rnd = entryRound(entry);
739
+ createEmerged(entryEmergedSubtasks(entry), rnd);
740
+ for (const update of entrySubGoalUpdates(entry)) {
741
+ applySubGoalUpdate(subGoals, update, rnd);
742
+ }
803
743
  }
804
- // Phase 2 — Process current round's sub-task declarations
744
+ // Phase 2 — The current round's own report (fixture/rebuild path; on the
745
+ // vault path this duplicates the last committed round and is idempotent).
805
746
  if (lastRoundResult) {
806
747
  const rnd = lastRoundResult.round || (currentRound - 1);
807
- // New sub-goals from this round
808
- const emerged = lastRoundResult.emerged_subtasks ?? [];
809
- for (const desc of emerged) {
810
- if (matchSubGoal(desc, subGoals, policy.subgoal_dedup_threshold) >= 0)
811
- continue;
812
- subGoals.push(makeSubGoal({
813
- id: deriveSubGoalId(desc),
814
- description: desc.trim(),
815
- status: "pending",
816
- declared_at_round: rnd,
817
- status_changed_at_round: rnd,
818
- priority: subGoals.length,
819
- }));
820
- }
821
- // Completed sub-goals
822
- const completed = lastRoundResult.completed_subtasks ?? [];
823
- for (const desc of completed) {
824
- const idx = matchSubGoal(desc, subGoals, policy.subgoal_match_threshold);
825
- if (idx >= 0 && subGoals[idx].status !== "done" && subGoals[idx].status !== "canceled") {
826
- subGoals[idx].status = "done";
827
- subGoals[idx].status_changed_at_round = rnd;
828
- subGoals[idx].completed_at_round = rnd;
829
- }
830
- }
831
- // Blocked sub-goals
832
- const blocked = lastRoundResult.blocked_subtasks ?? [];
833
- for (const desc of blocked) {
834
- const idx = matchSubGoal(desc, subGoals, policy.subgoal_match_threshold);
835
- if (idx >= 0 && subGoals[idx].status !== "done" && subGoals[idx].status !== "canceled") {
836
- subGoals[idx].status = "blocked";
837
- subGoals[idx].status_changed_at_round = rnd;
838
- }
839
- }
840
- // Canceled sub-goals
841
- const canceled = lastRoundResult.canceled_subtasks ?? [];
842
- for (const desc of canceled) {
843
- const idx = matchSubGoal(desc, subGoals, policy.subgoal_match_threshold);
844
- if (idx >= 0 && subGoals[idx].status !== "done" && subGoals[idx].status !== "canceled") {
845
- subGoals[idx].status = "canceled";
846
- subGoals[idx].status_changed_at_round = rnd;
847
- }
848
- }
849
- // Phase 3 — Auto-derive: next_action match → in_progress
850
- const nextAction = lastRoundResult.next_action?.trim();
851
- if (nextAction) {
852
- for (const sg of subGoals) {
853
- if (sg.status === "pending") {
854
- if (jaccardSimilarity(nextAction, sg.description) >= policy.subgoal_auto_in_progress_threshold) {
855
- sg.status = "in_progress";
856
- sg.status_changed_at_round = rnd;
857
- break; // Only one sub-goal can be in_progress at a time
858
- }
859
- }
860
- }
861
- }
862
- // Phase 4 — Auto-complete: new criteria match → done
863
- const newCriteria = lastRoundResult.execution_evidence?.success_criteria_met ?? [];
864
- if (newCriteria.length > 0) {
865
- for (const sg of subGoals) {
866
- if (sg.status === "pending" || sg.status === "in_progress") {
867
- for (const crit of newCriteria) {
868
- if (jaccardSimilarity(crit, sg.description) >= policy.subgoal_auto_complete_threshold) {
869
- sg.status = "done";
870
- sg.status_changed_at_round = rnd;
871
- sg.completed_at_round = rnd;
872
- break;
873
- }
874
- }
875
- }
876
- }
748
+ createEmerged(lastRoundResult.emerged_subtasks ?? [], rnd);
749
+ for (const update of lastRoundResult.subgoal_updates ?? []) {
750
+ applySubGoalUpdate(subGoals, update, rnd);
877
751
  }
878
752
  }
879
753
  // Sort for display: in_progress first, then pending by age desc, then blocked, then done, then canceled
@@ -915,40 +789,6 @@ function constraintSource(text, objective, constraintsFromPlan) {
915
789
  return "criteria";
916
790
  return "discovered";
917
791
  }
918
- /** Find the earliest round a constraint was discovered by scanning vault entries.
919
- * v2.11: ID-first matching. Checks exact constraint ID match (c-XXXXXXXX)
920
- * against the derived ID of each vault entry's discovered_constraints, then
921
- * falls back to Jaccard similarity on text. */
922
- function findDiscoveredRound(text, vaultEntries) {
923
- const targetId = deriveConstraintId(text);
924
- let earliest = 0;
925
- for (const entry of vaultEntries) {
926
- const rnd = entryRound(entry);
927
- if (rnd < 1)
928
- continue;
929
- const view = committedView(entry);
930
- const discovered = view
931
- ? view.discoveredConstraints ?? []
932
- : Array.isArray(entry.discovered_constraints)
933
- ? entry.discovered_constraints.filter((v) => typeof v === "string")
934
- : [];
935
- for (const d of discovered) {
936
- // Phase 1: exact ID match (v2.11)
937
- if (d === targetId || deriveConstraintId(d) === targetId) {
938
- if (earliest === 0 || rnd < earliest)
939
- earliest = rnd;
940
- break;
941
- }
942
- // Phase 2: Jaccard fallback for natural-language references.
943
- if (jaccardSimilarity(text, d) >= getPolicy().evolution.constraint_match_threshold) {
944
- if (earliest === 0 || rnd < earliest)
945
- earliest = rnd;
946
- break;
947
- }
948
- }
949
- }
950
- return earliest;
951
- }
952
792
  /** Find the latest round a constraint was violated by scanning vault entries.
953
793
  * v2.11: ID-first matching. Checks exact constraint ID match (c-XXXXXXXX)
954
794
  * against the derived ID of each vault entry's constraint_violations, then
@@ -969,7 +809,7 @@ function findLastViolatedRound(text, vaultEntries) {
969
809
  const rnd = entryRound(entry);
970
810
  if (rnd < 1)
971
811
  continue;
972
- const view = committedView(entry);
812
+ const view = decodeRound(entry);
973
813
  const violations = view
974
814
  ? view.constraintViolations ?? []
975
815
  : Array.isArray(entry.constraint_violations)
@@ -986,15 +826,17 @@ function findLastViolatedRound(text, vaultEntries) {
986
826
  return latest;
987
827
  }
988
828
  /**
989
- * Manage constraint lifecycle with time-aware decay.
829
+ * Manage constraint lifecycle metadata for the active set.
990
830
  *
991
- * Discovered constraints that haven't been violated for N rounds are demoted
992
- * to inactive — removed from prompts but kept in state. Hard/plan/criteria
993
- * constraints never auto-decay. Inactive constraints are auto-reactivated
994
- * if violated again in the current round.
831
+ * v3.7: the v2.3 time-aware decay (discovered constraints demoted to
832
+ * inactive after N quiet rounds, auto-reactivation on a fresh violation)
833
+ * is gone — every active constraint stays active. What remains is the
834
+ * display metadata the v3 prompt surface needs: per-text source
835
+ * classification (v3.3.1: criteria texts carry the cr-XXXXXXXX namespace),
836
+ * and last_violated_at_round for the L1 collapse "(violated this round)"
837
+ * annotation (diffConstraints compares it to the current round).
995
838
  */
996
839
  function manageConstraintLifecycle(active, objective, constraintsFromPlan, vaultContext, currentRound, lastRoundViolations = []) {
997
- const policy = getPolicy().evolution;
998
840
  const allEntries = loopEntries(objective.loop_id || "", vaultContext);
999
841
  const completedEntries = allEntries.filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound);
1000
842
  const metadata = [];
@@ -1004,57 +846,25 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
1004
846
  // as a violation at currentRound — the previous rounds' scan cannot see
1005
847
  // it (this round is not committed yet). This makes the L1 collapse
1006
848
  // render "(violated this round)" (diffConstraints compares
1007
- // last_violated_at_round === round) and re-activates decayed
1008
- // discovered constraints.
849
+ // last_violated_at_round === round).
1009
850
  const violatedThisRound = lastRoundViolations.some((v) => matchesConstraintText(text, v));
1010
- // Structural constraints never decay
1011
- if (source === "hard" || source === "plan" || source === "criteria") {
1012
- // v3.3.1: one text, one identity — a success-criterion text that also
1013
- // lives in the merged active set belongs to the cr-XXXXXXXX namespace
1014
- // (its own Success Criteria section and the verification gate's
1015
- // criteriaMatch are ID-first on cr-). Rendering it under c-XXXXXXXX in
1016
- // the active list meant the same text carried two IDs in one prompt,
1017
- // and an agent echoing the active-list ID into success_criteria_met
1018
- // could never match.
1019
- metadata.push(makeConstraintMeta({
1020
- id: source === "criteria" ? deriveCriterionId(text) : deriveConstraintId(text),
1021
- text,
1022
- discovered_at_round: objective.created_at_round || 1,
1023
- last_violated_at_round: violatedThisRound
1024
- ? currentRound
1025
- : findLastViolatedRound(text, completedEntries),
1026
- source,
1027
- status: "active",
1028
- }));
1029
- continue;
1030
- }
1031
- // Discovered constraints: check for inactivity
1032
- const discoveredAt = findDiscoveredRound(text, completedEntries);
1033
- const lastViolated = violatedThisRound
1034
- ? currentRound
1035
- : findLastViolatedRound(text, completedEntries);
1036
- const roundsSinceRelevant = lastViolated > 0
1037
- ? currentRound - lastViolated
1038
- : currentRound - (discoveredAt || currentRound);
1039
- const status = roundsSinceRelevant > policy.constraint_inactive_rounds
1040
- ? "inactive"
1041
- : "active";
851
+ // v3.3.1: one text, one identity — a success-criterion text that also
852
+ // lives in the merged active set belongs to the cr-XXXXXXXX namespace
853
+ // (its own Success Criteria section and the verification gate's
854
+ // criteriaMatch are ID-first on cr-). Rendering it under c-XXXXXXXX in
855
+ // the active list meant the same text carried two IDs in one prompt,
856
+ // and an agent echoing the active-list ID into success_criteria_met
857
+ // could never match.
1042
858
  metadata.push(makeConstraintMeta({
1043
- id: deriveConstraintId(text),
859
+ id: source === "criteria" ? deriveCriterionId(text) : deriveConstraintId(text),
1044
860
  text,
1045
- discovered_at_round: discoveredAt || 0,
1046
- last_violated_at_round: lastViolated,
1047
- source: "discovered",
1048
- status,
861
+ last_violated_at_round: violatedThisRound
862
+ ? currentRound
863
+ : findLastViolatedRound(text, completedEntries),
864
+ source,
1049
865
  }));
1050
866
  }
1051
- const newActive = metadata
1052
- .filter((m) => m.status === "active")
1053
- .map((m) => m.text);
1054
- const inactive = metadata
1055
- .filter((m) => m.status === "inactive")
1056
- .map((m) => m.text);
1057
- return { active: newActive, inactive, metadata };
867
+ return { active, metadata };
1058
868
  }
1059
869
  export function alignTask(proposedTask, request, context) {
1060
870
  const objective = request.loop_objective ?? latestObjective(request.loop_id, context);
@@ -1099,7 +909,7 @@ function levelDecision(request, context) {
1099
909
  last?.revised_success_criteria?.length ||
1100
910
  last?.wrong_assumptions?.length);
1101
911
  const lastFullRound = loopEntries(request.loop_id, context)
1102
- .filter((entry) => lineage(entry).recompile_level === "l2")
912
+ .filter((entry) => entryLineage(entry).recompile_level === "l2")
1103
913
  .map(entryRound)
1104
914
  .filter((round) => round < request.round)
1105
915
  .at(-1) ?? 1;
@@ -1108,7 +918,7 @@ function levelDecision(request, context) {
1108
918
  // rehydration (the decidePromptLevel recovery_boundary branch existed
1109
919
  // but no caller ever set it).
1110
920
  const recoveryBoundary = loopEntries(request.loop_id, context)
1111
- .some((entry) => lineage(entry).committed_action === "backtrack" &&
921
+ .some((entry) => entryLineage(entry).committed_action === "backtrack" &&
1112
922
  entryRound(entry) === request.round);
1113
923
  return decidePromptLevel({
1114
924
  round: request.round,
@@ -1130,17 +940,17 @@ function levelDecision(request, context) {
1130
940
  export function decideLevel(request, context) {
1131
941
  return levelDecision(request, context).level;
1132
942
  }
1133
- export function buildSelfEvalBlock(round, prevDriftFlags,
943
+ export function buildSelfEvalBlock(round, prevDriftFlags,
1134
944
  /** v2.12: L0 is the minimal retry template — the v2.12 declarative fields
1135
945
  * (outcome/blocker/retroactiveClaims) are L1/L2 additions so the retry
1136
946
  * prompt stays within its tight budget. */
1137
- level,
947
+ level,
1138
948
  /** v3.3/v3.4: Whether this round's Current Task IS the ACTIVE Round
1139
949
  * Contract (derived from committed rounds — compileLoop passes
1140
950
  * `activeContract != null`). Only then does the template ask the agent
1141
951
  * to restate/propose it — a generic empty contract template would invite
1142
952
  * placeholder submissions that trigger round_underspecified noise. */
1143
- hasContract = false,
953
+ hasContract = false,
1144
954
  /** v3.5: L2-only prose suggesting a Round Contract declaration when the
1145
955
  * Current Task is NOT one (contract_nudge_on_l2 policy, computed at the
1146
956
  * compileLoop call site). Mutually exclusive with hasContract. The prose
@@ -1168,9 +978,9 @@ proposalNudge = false) {
1168
978
  },
1169
979
  wrong_assumptions: [],
1170
980
  next_action: "<next concrete action, or empty when complete>",
1171
- completed_subtasks: ["<sg-XXXXXXXX or text>"],
1172
- blocked_subtasks: ["<sg-XXXXXXXX or text>"],
1173
- canceled_subtasks: ["<sg-XXXXXXXX or text>"],
981
+ subgoal_updates: [
982
+ { id: "<sg-XXXXXXXX from the Sub-Goal Dashboard>", status: "done" },
983
+ ],
1174
984
  };
1175
985
  // v2.12: Declarative tri-state outcome (L1/L2 only — keeps L0 retry lean)
1176
986
  if (declareOutcome) {
@@ -1219,7 +1029,8 @@ proposalNudge = false) {
1219
1029
  lines.push("IMPORTANT: Replace every <placeholder> with your actual data.");
1220
1030
  lines.push("Set success=true only when the full goal and ALL hard constraints are verified.");
1221
1031
  lines.push("Use IDs for exact matching: c-XXXXXXXX, cr-XXXXXXXX, sg-XXXXXXXX.");
1222
- lines.push("For subtasks, use sub-goal IDs from the Sub-Goal Dashboard above.");
1032
+ lines.push("Sub-goal changes go through `subgoal_updates` (active sub-goal IDs only;");
1033
+ lines.push("done/canceled are terminal — reopen via `emerged_subtasks`; bad IDs reject).");
1223
1034
  // v3.5: L2 contract-less nudge (prose only — never the JSON key name, so
1224
1035
  // contract-less rounds keep their template-lean assertions).
1225
1036
  if (proposalNudge) {
@@ -1233,6 +1044,43 @@ proposalNudge = false) {
1233
1044
  // v2.5: checkpoint() removed — agent_declared milestones from
1234
1045
  // compression_checkpoint supersede the separate CheckpointSummary.
1235
1046
  // See buildRollingSummary() signal 1.
1047
+ /** v3.7.1: Derived Recovery Brief for the state-file Recent tier. Present
1048
+ * ONLY while a committed backtrack decision is the current round's record
1049
+ * (the recovery window); the redo commit replaces that record, so the
1050
+ * brief exits the projection by construction. Sources are the committed
1051
+ * rollback decision's own fields — no new persistence, no rejected
1052
+ * payloads. */
1053
+ function committedBacktrackBrief(loopId, round, context) {
1054
+ const entry = loopEntries(loopId, context).find((e) => entryLineage(e).committed_action === "backtrack" && entryRound(e) === round);
1055
+ if (!entry)
1056
+ return undefined;
1057
+ // Read the facts the engine stamped onto the merged lineage entry from
1058
+ // the committed rollback decision (engine.ts mergeCommittedRound).
1059
+ const lineage = entryLineage(entry);
1060
+ const asStrings = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
1061
+ const asNumbers = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "number") : [];
1062
+ const failedRounds = asNumbers(lineage.backtrackFailedRounds);
1063
+ const approaches = asStrings(lineage.backtrackApproaches).slice(0, 6);
1064
+ const assumptions = asStrings(lineage.backtrackWrongAssumptions).slice(0, 5);
1065
+ const target = typeof lineage.backtrackTarget === "number" ? lineage.backtrackTarget : 0;
1066
+ const trigger = typeof lineage.backtrackTriggerRule === "string"
1067
+ ? lineage.backtrackTriggerRule
1068
+ : "unknown";
1069
+ const lines = [
1070
+ `- **Trigger**: ${trigger}`,
1071
+ `- **Restored to round**: ${target} (redo round ${target + 1})`,
1072
+ ];
1073
+ if (failedRounds.length > 0)
1074
+ lines.push(`- **Failed rounds**: ${failedRounds.join(", ")}`);
1075
+ for (const approach of approaches)
1076
+ lines.push(` - Failed approach: ${approach}`);
1077
+ if (assumptions.length > 0) {
1078
+ lines.push("- **Falsified assumptions** (do not rebuild on these):");
1079
+ for (const assumption of assumptions)
1080
+ lines.push(` - ${assumption}`);
1081
+ }
1082
+ return lines;
1083
+ }
1236
1084
  export function compileLoop(request, context) {
1237
1085
  const decision = levelDecision(request, context);
1238
1086
  const previous = getPreviousRound(request.loop_id, request.round - 1, context);
@@ -1285,7 +1133,6 @@ export function compileLoop(request, context) {
1285
1133
  lineage: [`${request.loop_id}:r${request.round}`],
1286
1134
  constraints_active: constraintLifecycle.active,
1287
1135
  constraints_retired: constraints.retired,
1288
- constraints_inactive: constraintLifecycle.inactive,
1289
1136
  constraint_metadata: constraintLifecycle.metadata,
1290
1137
  loop_id: request.loop_id,
1291
1138
  round: request.round,
@@ -1306,14 +1153,27 @@ export function compileLoop(request, context) {
1306
1153
  warnings,
1307
1154
  });
1308
1155
  const policy = getPolicy();
1309
- const statePath = `${policy.state_file.directory}/${request.loop_id}-state.md`;
1156
+ // L7 (v3.7.x): the state-file path is empty when the file is disabled —
1157
+ // prompts must never point at (or collapse content behind) a file that
1158
+ // writeStateFile will not create. An empty path propagates into the
1159
+ // assembler's pointer lines and the L1 collapse gate below.
1160
+ const statePath = policy.state_file.enabled
1161
+ ? `${policy.state_file.directory}/${request.loop_id}-state.md`
1162
+ : "";
1310
1163
  const state = createCanonicalLoopState(request, response, statePath, {
1311
1164
  roundStats,
1312
1165
  machineStatus,
1313
1166
  // v3.4: derived active contract — the Current Task's source of truth.
1314
1167
  roundContract: activeContract,
1315
1168
  });
1316
- const markdown = renderCanonicalStateMarkdown(state);
1169
+ const markdown = renderCanonicalStateMarkdown(state, {
1170
+ // v3.7.1: retry attempts are marked in the derived view (attempt is
1171
+ // request context, not a committed fact). The Recovery Brief renders in
1172
+ // the Recent tier only while a committed backtrack decision is this
1173
+ // round's record; the redo commit removes it by construction.
1174
+ attempt: request.attempt,
1175
+ recoveryBrief: committedBacktrackBrief(request.loop_id, request.round, context),
1176
+ });
1317
1177
  // v2.4–v2.5: Adaptive L2 budget — scales with loop complexity
1318
1178
  const adaptiveL2 = policy.prompt.l2_adaptive_enabled
1319
1179
  ? Math.min(policy.prompt.l2_max_chars
@@ -1324,23 +1184,25 @@ export function compileLoop(request, context) {
1324
1184
  // v2.8: L2 pointer mode — when enabled, skip the monolithic markdown blob.
1325
1185
  // Structured L2 sections (milestones, sub-goals, trust, progress) still
1326
1186
  // render. The state file on disk is the durable source of truth.
1327
- const l2Pointer = policy.prompt.l2_pointer_enabled && decision.level === "l2";
1187
+ // L7: pointer mode REQUIRES the file — with state_file disabled it would
1188
+ // silently drop the full state from L2 prompts and point at nothing.
1189
+ const l2Pointer = policy.state_file.enabled &&
1190
+ policy.prompt.l2_pointer_enabled && decision.level === "l2";
1328
1191
  const artifact = assemblePromptArtifact({
1329
1192
  state,
1330
1193
  level: decision.level,
1331
1194
  reasons: decision.reasons,
1332
- mode: policy.prompt.injection_mode,
1333
1195
  budgets: {
1334
1196
  l0: policy.prompt.l0_max_chars,
1335
1197
  l1: policy.prompt.l1_max_chars,
1336
1198
  l2: adaptiveL2,
1337
1199
  },
1338
1200
  attempt: request.attempt,
1339
- selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags, decision.level,
1201
+ selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags, decision.level,
1340
1202
  // v3.4: restate the contract only when this prompt's Current Task IS
1341
1203
  // the ACTIVE contract (derived — not the previous submission's field,
1342
1204
  // which is a proposal and may differ from what this round executes).
1343
- activeContract != null,
1205
+ activeContract != null,
1344
1206
  // v3.5: L2-only declaration nudge when nothing is active (policy-gated;
1345
1207
  // mutually exclusive with the restate template above).
1346
1208
  decision.level === "l2" && activeContract === null &&