loopforge 3.9.2 → 3.9.4

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 (104) hide show
  1. package/dist/canonical-state.d.ts +39 -1
  2. package/dist/canonical-state.d.ts.map +1 -1
  3. package/dist/canonical-state.js +114 -32
  4. package/dist/canonical-state.js.map +1 -1
  5. package/dist/committed-round.d.ts +27 -4
  6. package/dist/committed-round.d.ts.map +1 -1
  7. package/dist/committed-round.js +87 -14
  8. package/dist/committed-round.js.map +1 -1
  9. package/dist/enforcement-gate.d.ts +9 -32
  10. package/dist/enforcement-gate.d.ts.map +1 -1
  11. package/dist/enforcement-gate.js +9 -122
  12. package/dist/enforcement-gate.js.map +1 -1
  13. package/dist/engine.d.ts.map +1 -1
  14. package/dist/engine.js +13 -2
  15. package/dist/engine.js.map +1 -1
  16. package/dist/explain.d.ts +1 -0
  17. package/dist/explain.d.ts.map +1 -1
  18. package/dist/explain.js +1 -0
  19. package/dist/explain.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/loop-compiler.d.ts +17 -1
  24. package/dist/loop-compiler.d.ts.map +1 -1
  25. package/dist/loop-compiler.js +217 -104
  26. package/dist/loop-compiler.js.map +1 -1
  27. package/dist/mcp/round-lifecycle.d.ts +31 -1
  28. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  29. package/dist/mcp/round-lifecycle.js +102 -60
  30. package/dist/mcp/round-lifecycle.js.map +1 -1
  31. package/dist/mcp/server.d.ts +5 -0
  32. package/dist/mcp/server.d.ts.map +1 -1
  33. package/dist/mcp/server.js +19 -1
  34. package/dist/mcp/server.js.map +1 -1
  35. package/dist/mcp/session.d.ts.map +1 -1
  36. package/dist/mcp/session.js +23 -5
  37. package/dist/mcp/session.js.map +1 -1
  38. package/dist/mcp/submission-boundary.js +14 -4
  39. package/dist/mcp/submission-boundary.js.map +1 -1
  40. package/dist/mcp/tools.d.ts.map +1 -1
  41. package/dist/mcp/tools.js +62 -6
  42. package/dist/mcp/tools.js.map +1 -1
  43. package/dist/policy.d.ts +5 -2
  44. package/dist/policy.d.ts.map +1 -1
  45. package/dist/policy.js +2 -2
  46. package/dist/policy.js.map +1 -1
  47. package/dist/prompt-assembler.d.ts +8 -3
  48. package/dist/prompt-assembler.d.ts.map +1 -1
  49. package/dist/prompt-assembler.js +233 -240
  50. package/dist/prompt-assembler.js.map +1 -1
  51. package/dist/prompt-coverage.d.ts +45 -0
  52. package/dist/prompt-coverage.d.ts.map +1 -0
  53. package/dist/prompt-coverage.js +84 -0
  54. package/dist/prompt-coverage.js.map +1 -0
  55. package/dist/prompt-freshness.d.ts +36 -0
  56. package/dist/prompt-freshness.d.ts.map +1 -0
  57. package/dist/prompt-freshness.js +83 -0
  58. package/dist/prompt-freshness.js.map +1 -0
  59. package/dist/prompt-policy.d.ts +5 -2
  60. package/dist/prompt-policy.d.ts.map +1 -1
  61. package/dist/prompt-policy.js +10 -7
  62. package/dist/prompt-policy.js.map +1 -1
  63. package/dist/protocol.d.ts +45 -8
  64. package/dist/protocol.d.ts.map +1 -1
  65. package/dist/protocol.js +4 -1
  66. package/dist/protocol.js.map +1 -1
  67. package/dist/round-coordinator.d.ts +4 -2
  68. package/dist/round-coordinator.d.ts.map +1 -1
  69. package/dist/round-coordinator.js +7 -7
  70. package/dist/round-coordinator.js.map +1 -1
  71. package/dist/round-facts.d.ts.map +1 -1
  72. package/dist/round-facts.js +9 -3
  73. package/dist/round-facts.js.map +1 -1
  74. package/dist/round-transaction.d.ts.map +1 -1
  75. package/dist/round-transaction.js +19 -1
  76. package/dist/round-transaction.js.map +1 -1
  77. package/dist/self-eval.d.ts +8 -4
  78. package/dist/self-eval.d.ts.map +1 -1
  79. package/dist/self-eval.js +55 -5
  80. package/dist/self-eval.js.map +1 -1
  81. package/dist/submission-guidance.d.ts +25 -11
  82. package/dist/submission-guidance.d.ts.map +1 -1
  83. package/dist/submission-guidance.js +102 -44
  84. package/dist/submission-guidance.js.map +1 -1
  85. package/dist/submission-receipt.d.ts.map +1 -1
  86. package/dist/submission-receipt.js +1 -0
  87. package/dist/submission-receipt.js.map +1 -1
  88. package/dist/token-utils.d.ts +19 -0
  89. package/dist/token-utils.d.ts.map +1 -1
  90. package/dist/token-utils.js +41 -6
  91. package/dist/token-utils.js.map +1 -1
  92. package/dist/verification-actions.d.ts +13 -0
  93. package/dist/verification-actions.d.ts.map +1 -0
  94. package/dist/verification-actions.js +45 -0
  95. package/dist/verification-actions.js.map +1 -0
  96. package/dist/verification-gate.d.ts +5 -1
  97. package/dist/verification-gate.d.ts.map +1 -1
  98. package/dist/verification-gate.js +19 -7
  99. package/dist/verification-gate.js.map +1 -1
  100. package/dist/version.d.ts +1 -1
  101. package/dist/version.js +1 -1
  102. package/loop_policy.json +2 -2
  103. package/package.json +68 -68
  104. package/skills/perception/SKILL.md +25 -6
@@ -7,7 +7,8 @@ import { createHash } from "node:crypto";
7
7
  import { deriveConfiguredCapability, getPolicy } from "./policy.js";
8
8
  import { AgentStatus, makeLoopCompileResponse, makeLoopObjective, makeConstraintMeta, makeMilestoneSummary, makeRollingSummary, } from "./protocol.js";
9
9
  import { createCanonicalLoopState, renderCanonicalStateMarkdown, } from "./canonical-state.js";
10
- import { decodeBacktrackRecord, decodeRound, entryLineage, entryCriteriaMet, entryCriteriaRemaining, entryActiveConstraints, entryRetractedConstraints, machineGitMotionSeries, derivationRounds, } from "./committed-round.js";
10
+ import { decodeRound, entryLineage, entryCriteriaMet, entryCriteriaRemaining, entryActiveConstraints, entryRetractedConstraints, pendingBacktrackRecord, roundResultFromCommittedRound, machineGitMotionSeries, derivationRounds, } from "./committed-round.js";
11
+ import { derivePromptFreshness, UNKNOWN_PROMPT_FRESHNESS, } from "./prompt-freshness.js";
11
12
  import { NO_IN_FLIGHT_ROUND, deriveRoundFacts } from "./round-facts.js";
12
13
  import { recoveryBriefLines } from "./enforcement-gate.js";
13
14
  import { deriveSubGoals, duplicateEmergedDeclarations } from "./subgoal-state.js";
@@ -15,7 +16,7 @@ import { claimedMetCriteria, claimedRemainingCriteria } from "./self-eval.js";
15
16
  import { assemblePromptArtifact } from "./prompt-assembler.js";
16
17
  import { renderSelfEvalBlock, } from "./submission-guidance.js";
17
18
  import { decidePromptLevel, } from "./prompt-policy.js";
18
- import { criterionRefMatches, deriveCriterionId, deriveItemId, normalizeText, unique, entryRound, } from "./token-utils.js";
19
+ import { criterionRefMatches, deriveCriterionId, deriveItemId, normalizeText, unique, uniqueNormalized, entryRound, } from "./token-utils.js";
19
20
  function contextEntries(context) {
20
21
  if (!context || !Array.isArray(context.results))
21
22
  return [];
@@ -82,8 +83,21 @@ export function getPreviousRound(loopId, round, context) {
82
83
  : typeof data.output_summary === "string" ? data.output_summary : "",
83
84
  };
84
85
  }
85
- function latestObjective(loopId, context) {
86
- const candidates = loopEntries(loopId, context).reverse();
86
+ function latestObjective(loopId, context,
87
+ /** Exclusive compile-round bound. A deep-restore redo must never read the
88
+ * abandoned branch's objective (and its rolled-back refinements). */
89
+ beforeRound,
90
+ /** v3.9.4: a round-1 same-round retry has no earlier committed objective.
91
+ * Reading the prior compile of the SAME round recovers the initial
92
+ * criteria supplied to `loopforge_start`. It is never enabled for a first
93
+ * attempt or for later rounds, so it cannot read an abandoned branch. */
94
+ includeRoundOne = false) {
95
+ const candidates = loopEntries(loopId, context)
96
+ .filter((entry) => {
97
+ const round = entryRound(entry);
98
+ return round >= 1 && (round < beforeRound || (includeRoundOne && round === 1));
99
+ })
100
+ .reverse();
87
101
  for (const entry of candidates) {
88
102
  const raw = entry.loop_objective;
89
103
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -107,8 +121,9 @@ function latestObjective(loopId, context) {
107
121
  }
108
122
  return null;
109
123
  }
110
- function evolveObjective(request, context) {
111
- const current = request.loop_objective ?? latestObjective(request.loop_id, context) ??
124
+ function evolveObjective(request, context, previous) {
125
+ const current = request.loop_objective ??
126
+ latestObjective(request.loop_id, context, request.round, request.round === 1 && (request.attempt ?? 1) > 1) ??
112
127
  makeLoopObjective({
113
128
  objective: request.task,
114
129
  success_criteria: ["Task goal achieved with verifiable evidence"],
@@ -116,7 +131,7 @@ function evolveObjective(request, context) {
116
131
  created_at_round: 1,
117
132
  loop_id: request.loop_id,
118
133
  });
119
- const last = request.last_round_result;
134
+ const last = previous.result;
120
135
  const history = [...(current.refinement_history ?? [])];
121
136
  let objective = current.objective || request.task;
122
137
  let version = current.version ?? 1;
@@ -124,9 +139,9 @@ function evolveObjective(request, context) {
124
139
  const refinement = last.objective_refinement.trim();
125
140
  // M5: fold each refinement exactly once per persisted objective state.
126
141
  // A retry recompiles the same round against the same committed
127
- // last_round_result; without this guard every retry attempt re-appends
128
- // the refinement and inflates the version — the objective text and the
129
- // state file grew "Refinement: X" once per attempt.
142
+ // previous-round projection; without this guard every retry attempt
143
+ // re-appends the refinement and inflates the version — the objective text
144
+ // and the state file grew "Refinement: X" once per attempt.
130
145
  if (!history.includes(refinement)) {
131
146
  history.push(refinement);
132
147
  objective = `${objective}\nRefinement: ${refinement}`;
@@ -139,56 +154,69 @@ function evolveObjective(request, context) {
139
154
  objective,
140
155
  version,
141
156
  refinement_history: history.slice(-getPolicy().evolution.max_objective_versions),
142
- success_criteria: unique(current.success_criteria.map((item) => revisions.get(item) ?? item)),
143
- hard_constraints: unique(current.hard_constraints),
157
+ // v3.9.4: one normalized-exact text, one cr-* identity. `unique` was
158
+ // trim-only, so case/whitespace variants survived as two entries that
159
+ // derived to the SAME criterion id.
160
+ success_criteria: uniqueNormalized(current.success_criteria
161
+ .filter((item) => typeof item === "string")
162
+ .map((item) => revisions.get(item) ?? item)),
163
+ hard_constraints: uniqueNormalized(current.hard_constraints),
144
164
  loop_id: request.loop_id,
145
165
  });
146
166
  }
147
- function evolveConstraints(request, objective, previous, context) {
148
- const retired = unique(request.last_round_result?.retracted_constraints ?? []);
149
- const retiredSet = new Set(retired);
150
- const discovered = unique(request.last_round_result?.discovered_constraints ?? [])
167
+ function evolveConstraints(request, objective, previous, windowRounds) {
168
+ const retired = unique(previous.result?.retracted_constraints ?? []);
169
+ // v3.9.4: a revised-away criterion text is no longer an objective criterion.
170
+ // It must not survive as a "discovered" active constraint just because the
171
+ // previous round's active set carried it. This is a merge-time suppression,
172
+ // not an alias and not a constraint retirement.
173
+ const revisedAway = unique((previous.result?.revised_success_criteria ?? []).map((item) => item.old));
174
+ const immediateRetired = new Set([...retired, ...revisedAway].map((text) => normalizeText(text)));
175
+ const discovered = unique(previous.result?.discovered_constraints ?? [])
151
176
  .slice(0, getPolicy().evolution.max_discovered_constraints_per_round);
152
177
  // v2.14: agent-declared retractions of plan/hard/criteria constraints
153
178
  // persist for constraints.retire_window rounds (they are re-merged from
154
179
  // the request/objective every round, so without this the documented
155
180
  // "Removed from the active constraint set" never held for them). The
156
- // retraction rounds are derived from vault entries — no new persistence
157
- // format. After the window the constraint returns (hard/plan/criteria
158
- // constraints never auto-decay — only agent action removes them, and the
159
- // action's memory expires).
181
+ // retraction rounds are derived from the SAME committed window as every
182
+ // other compile fact — an abandoned branch above the compile round can
183
+ // never suppress a constraint on the restored branch.
160
184
  const window = getPolicy().constraints.retire_window;
161
185
  const lastRetractedRound = new Map();
162
186
  if (window > 0) {
163
- for (const entry of loopEntries(request.loop_id, context)) {
164
- const retractedList = entryLineage(entry).retracted_constraints;
165
- if (!Array.isArray(retractedList))
166
- continue;
167
- const rnd = entryRound(entry);
187
+ for (const round of windowRounds) {
188
+ const retractedList = round.retractedConstraints ?? [];
168
189
  for (const text of retractedList) {
169
- if (typeof text !== "string" || !text)
190
+ if (!text)
170
191
  continue;
171
- const prev = lastRetractedRound.get(text);
172
- if (prev === undefined || rnd > prev)
173
- lastRetractedRound.set(text, rnd);
192
+ const key = normalizeText(text);
193
+ const previousRound = lastRetractedRound.get(key);
194
+ if (previousRound === undefined || round.round > previousRound) {
195
+ lastRetractedRound.set(key, round.round);
196
+ }
174
197
  }
175
198
  }
176
199
  }
177
- const active = unique([
178
- ...(previous?.constraints_active ?? []),
200
+ // v3.9.4: normalized-exact dedupe. The trim-only `unique` let case/space
201
+ // variants coexist while deriving the SAME c-/cr- id. The silent
202
+ // max_active_constraints slice is gone: every merged active constraint is
203
+ // normative, and prompt budget pressure is reported as protectedOverflow.
204
+ const active = uniqueNormalized([
205
+ ...(previous.view?.activeConstraints ?? []),
179
206
  ...request.constraints_from_plan,
180
207
  ...objective.hard_constraints,
181
208
  ...objective.success_criteria,
182
209
  ...discovered,
183
210
  ]).filter((item) => {
184
- if (retiredSet.has(item))
211
+ const key = normalizeText(item);
212
+ if (immediateRetired.has(key))
185
213
  return false;
186
- const retractedRound = lastRetractedRound.get(item);
214
+ const retractedRound = lastRetractedRound.get(key);
187
215
  if (retractedRound !== undefined && request.round - retractedRound < window) {
188
216
  return false;
189
217
  }
190
218
  return true;
191
- }).slice(0, getPolicy().evolution.max_active_constraints);
219
+ });
192
220
  return { active, retired };
193
221
  }
194
222
  // ═══════════════════════════════════════════════════════════════════════════
@@ -266,8 +294,8 @@ criterionFacts = []) {
266
294
  if (!objective || objective.success_criteria.length === 0)
267
295
  return [];
268
296
  const entries = loopEntries(loopId, context).filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
269
- // The current round's report lives in last_round_result (the vault only
270
- // gains it on commit); treat it as the report for round currentRound - 1.
297
+ // The previous round's report, resolved from the committed view (or the
298
+ // library fallback), is treated as the report for round currentRound - 1.
271
299
  const lastEv = lastRoundResult?.execution_report;
272
300
  const lastMet = claimedMetCriteria(lastEv);
273
301
  const lastRemaining = claimedRemainingCriteria(lastEv);
@@ -280,7 +308,16 @@ criterionFacts = []) {
280
308
  // sub-goal links carried on the same item, disappeared while the sub-goal
281
309
  // side of the very same fact survived. One derivation, one window.
282
310
  const factsByCriterion = new Map(criterionFacts.map((fact) => [fact.criterion_id, fact]));
283
- return objective.success_criteria.map((text) => {
311
+ // v3.9.4: one row per derived id. The objective is authored text; a case or
312
+ // whitespace variant derives the same cr-* and must not produce a second
313
+ // row (nor a second contract reference space).
314
+ const seenIds = new Set();
315
+ const statuses = [];
316
+ for (const text of objective.success_criteria) {
317
+ const id = deriveCriterionId(text);
318
+ if (seenIds.has(id))
319
+ continue;
320
+ seenIds.add(id);
284
321
  let metAtRound = null;
285
322
  for (const entry of entries) {
286
323
  const rnd = entryRound(entry);
@@ -300,18 +337,19 @@ criterionFacts = []) {
300
337
  lastEntryRemaining.some((remaining) => criteriaMatch(remaining, text))
301
338
  ? "remaining"
302
339
  : "unknown";
303
- const fact = factsByCriterion.get(deriveCriterionId(text));
340
+ const fact = factsByCriterion.get(id);
304
341
  const status = fact?.status ?? claimedStatus;
305
- return {
306
- id: deriveCriterionId(text),
342
+ statuses.push({
343
+ id,
307
344
  text,
308
345
  status,
309
346
  ...(metAtRound !== null ? { met_at_round: metAtRound } : {}),
310
347
  // v3.8.1: explicit refs only — empty when no contract item links this
311
348
  // criterion to a sub-goal.
312
349
  related_subgoal_ids: fact?.related_subgoal_ids ?? [],
313
- };
314
- });
350
+ });
351
+ }
352
+ return statuses;
315
353
  }
316
354
  /** v3.8: Rank item statuses so the strongest machine fact wins when several
317
355
  * items reference the same criterion. */
@@ -494,13 +532,16 @@ export { deriveCriterionId, isCriterionId } from "./token-utils.js";
494
532
  // ═══════════════════════════════════════════════════════════════════════════
495
533
  /** Determine the source of a constraint by matching against known sources. */
496
534
  function constraintSource(text, objective, constraintsFromPlan) {
497
- const norm = text.toLowerCase().trim();
498
- if (objective.hard_constraints.some((c) => c.toLowerCase().trim() === norm))
535
+ const norm = normalizeText(text);
536
+ // v3.9.4: criteria identity wins. A text that is both a criterion and a
537
+ // hard/plan constraint keeps its cr-* id everywhere; the prompt partition
538
+ // renders it once, in the criteria section.
539
+ if (objective.success_criteria.some((c) => normalizeText(c) === norm))
540
+ return "criteria";
541
+ if (objective.hard_constraints.some((c) => normalizeText(c) === norm))
499
542
  return "hard";
500
- if (constraintsFromPlan.some((c) => c.toLowerCase().trim() === norm))
543
+ if (constraintsFromPlan.some((c) => normalizeText(c) === norm))
501
544
  return "plan";
502
- if (objective.success_criteria.some((c) => c.toLowerCase().trim() === norm))
503
- return "criteria";
504
545
  return "discovered";
505
546
  }
506
547
  /** True when a reported violation text matches a constraint text.
@@ -579,40 +620,57 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
579
620
  }
580
621
  return { active, metadata };
581
622
  }
582
- function levelDecision(request, context) {
583
- const previous = getPreviousRound(request.loop_id, request.round - 1, context);
584
- const last = request.last_round_result;
585
- const hasNewInformation = Boolean(request.new_since_last_round.trim() ||
586
- last?.discovered_constraints?.length ||
587
- last?.objective_refinement?.trim() ||
588
- last?.emerged_subtasks?.length ||
589
- last?.retracted_constraints?.length ||
590
- last?.revised_success_criteria?.length ||
591
- last?.wrong_assumptions?.length);
592
- // v2.14: a backtrack committed for this round means the loop is
593
- // re-walking restored state — the recovery boundary forces L2
594
- // rehydration (the decidePromptLevel recovery_boundary branch existed
595
- // but no caller ever set it).
596
- const recoveryBoundary = loopEntries(request.loop_id, context)
597
- .some((entry) => entryLineage(entry).committed_action === "backtrack" &&
598
- entryRound(entry) === request.round);
623
+ /** Resolve the ONE previous-round fact bundle for a compile.
624
+ *
625
+ * The committed view wins whenever a round is committed. `last_round_result`
626
+ * is only the caller's in-flight projection for engine/library callers with
627
+ * no committed round in the context. In-flight attempt findings are
628
+ * authoritative even when empty: a retry must never inherit the previous
629
+ * committed round's flags. */
630
+ function resolvePreviousRoundFacts(request, context, windowRounds) {
631
+ const rounds = windowRounds ??
632
+ derivationRounds(loopEntries(request.loop_id, context), request.round);
633
+ const view = rounds.find((round) => round.round === request.round - 1) ?? null;
634
+ const recoveryBoundary = pendingBacktrackRecord(loopEntries(request.loop_id, context), request.round) !== null;
635
+ const inFlight = (request.attempt ?? 1) > 1 || recoveryBoundary;
636
+ return {
637
+ view,
638
+ result: view
639
+ ? roundResultFromCommittedRound(view)
640
+ : request.last_round_result,
641
+ verificationFlags: inFlight
642
+ ? (request.verification_flags ?? [])
643
+ : (view?.verificationFlags ?? request.verification_flags ?? []),
644
+ freshness: view ? derivePromptFreshness(view) : UNKNOWN_PROMPT_FRESHNESS,
645
+ recoveryBoundary,
646
+ };
647
+ }
648
+ function levelDecision(request, prior, previous) {
599
649
  return decidePromptLevel({
600
650
  round: request.round,
601
651
  attempt: request.attempt,
602
652
  forceLevel: request.force_level,
603
653
  hasPlanSource: Boolean(request.plan_source),
604
- recoveryBoundary,
605
- checkpointBoundary: last?.compression_checkpoint === true,
606
- goalChanged: previous !== null && previous.goal_id !== deriveGoalId(request.loop_id, request.task, request.goal_id),
607
- previousStateMissing: request.round > 1 && previous === null,
608
- previousFailedWithoutNewInformation: last?.success === false && !hasNewInformation,
609
- verificationContradicted: (request.verification_flags ?? [])
654
+ recoveryBoundary: previous.recoveryBoundary,
655
+ checkpointBoundary: previous.result?.compression_checkpoint === true,
656
+ // An empty goal_id is unobservable, not a goal change: never fabricate L2.
657
+ goalChanged: prior !== null && prior.goal_id !== "" &&
658
+ prior.goal_id !== deriveGoalId(request.loop_id, request.task, request.goal_id),
659
+ previousStateMissing: request.round > 1 && prior === null,
660
+ previousRoundFailed: previous.view
661
+ ? previous.view.success === false
662
+ : previous.result?.success === false,
663
+ previousRoundObservableNewInformation: previous.view
664
+ ? previous.freshness.observableNewInformation
665
+ : null,
666
+ verificationContradicted: previous.verificationFlags
610
667
  .some((flag) => flag.severity === "error"),
611
668
  consecutiveRejections: request.consecutive_rejections,
612
669
  });
613
670
  }
614
671
  export function decideLevel(request, context) {
615
- return levelDecision(request, context).level;
672
+ const prior = getPreviousRound(request.loop_id, request.round - 1, context);
673
+ return levelDecision(request, prior, resolvePreviousRoundFacts(request, context)).level;
616
674
  }
617
675
  /** The prompt's evaluation block. All content (examples, contract/sub-goal
618
676
  * guidance, flag-specific hints) comes from the one FIELD_GUIDES table in
@@ -630,47 +688,85 @@ export function buildSelfEvalBlock(ctx) {
630
688
  * rollback decision's own fields — no new persistence, no rejected
631
689
  * payloads. */
632
690
  function committedBacktrackBrief(loopId, round, context) {
633
- const entry = loopEntries(loopId, context).find((e) => entryLineage(e).committed_action === "backtrack" && entryRound(e) === round);
634
- if (!entry)
635
- return undefined;
636
- // v3.8.1: one decoder for the committed rollback record and one renderer for
637
- // the brief. The facts ride on the merged lineage entry (engine.ts stamps
638
- // them from the committed decision) and the same `decodeBacktrackRecord`
639
- // reads the durable :feedback envelope, so both carriers decode to one shape
640
- // — and this brief and the rollback prompt no longer render the same
641
- // rollback with two hand-written line builders.
642
- const record = decodeBacktrackRecord(entryLineage(entry));
691
+ // The SAME pending-backtrack lookup the recovery boundary uses: a deep
692
+ // restore commits at the failed round while its redo targets
693
+ // `backtrackTarget + 1`, so an exact-round match loses both the boundary
694
+ // and this brief.
695
+ const view = pendingBacktrackRecord(loopEntries(loopId, context), round);
696
+ const record = view?.backtrack;
643
697
  if (!record)
644
698
  return undefined;
645
- return recoveryBriefLines({
699
+ const lines = recoveryBriefLines({
646
700
  target: record.target,
647
701
  triggerRule: record.triggerRule,
702
+ // The committed decision's own sentence — the machine's reason for the
703
+ // rollback, preserved as a fact (the old prose header's "Why" content).
704
+ ...(typeof view?.result?.enforcementReason === "string" &&
705
+ view.result.enforcementReason.trim()
706
+ ? { failureReason: view.result.enforcementReason }
707
+ : {}),
648
708
  failedRounds: record.failedRounds,
649
709
  approaches: record.approaches,
650
710
  wrongAssumptions: record.wrongAssumptions,
711
+ // The redo round's stable identity is a fact the old prepended prose
712
+ // carried; keep it inside the artifact now.
713
+ recoveryRoundId: `loop:${loopId}:round:${record.target + 1}`,
651
714
  });
715
+ // v3.9.4: the restore point's machine facts belong in the prompt, not only
716
+ // in the prose backtrack header. Deterministic order and a visible overflow
717
+ // count keep the fact complete without making the section unbounded.
718
+ if (record.targetGitHead) {
719
+ lines.push(`- **Restore HEAD**: \`${record.targetGitHead}\``);
720
+ }
721
+ if (record.skippedFiles.length > 0) {
722
+ const files = [...record.skippedFiles].sort();
723
+ const shown = files.slice(0, 15);
724
+ lines.push("- **Files changed in failed rounds**:");
725
+ for (const file of shown)
726
+ lines.push(` - \`${file}\``);
727
+ if (files.length > shown.length) {
728
+ lines.push(` - … and ${files.length - shown.length} more files`);
729
+ }
730
+ }
731
+ // The machine requirement the recovery checkpoint enforces. A fact, not a
732
+ // recipe: LoopForge never says HOW to restore the workspace.
733
+ if (record.targetGitHead || record.skippedFiles.length > 0) {
734
+ lines.push(`- **Restore requirement**: the next submission's workspace is checked ` +
735
+ `against round ${record.target}` +
736
+ (record.targetGitHead ? ` (HEAD \`${record.targetGitHead}\`)` : "") +
737
+ "; a leftover change from the failed rounds rejects the recovery checkpoint.");
738
+ }
739
+ return lines;
652
740
  }
653
741
  export function compileLoop(request, context) {
654
- const decision = levelDecision(request, context);
655
- const previous = getPreviousRound(request.loop_id, request.round - 1, context);
656
- const objective = evolveObjective(request, context);
657
- const constraints = evolveConstraints(request, objective, previous, context);
658
- const constraintLifecycle = manageConstraintLifecycle(constraints.active, objective, request.constraints_from_plan, context, request.round, request.last_round_result?.constraint_violations ?? []);
659
- const rolling = buildRollingSummary(request.loop_id, request.round, context, 0);
660
- // v3.8: ONE committed-round derivation feeds every projection below
661
- // (sub-goals, the active contract, its item statuses, criteria).
742
+ // ONE history window and ONE previous-round fact bundle for the whole
743
+ // compile. Every consumer below reads these values; none re-reads the
744
+ // caller's mutable last_round_result.
662
745
  const windowRounds = derivationRounds(loopEntries(request.loop_id, context), request.round);
746
+ const prior = getPreviousRound(request.loop_id, request.round - 1, context);
747
+ const previous = resolvePreviousRoundFacts(request, context, windowRounds);
748
+ const decision = levelDecision(request, prior, previous);
749
+ const objective = evolveObjective(request, context, previous);
750
+ const constraints = evolveConstraints(request, objective, previous, windowRounds);
751
+ const constraintLifecycle = manageConstraintLifecycle(constraints.active, objective, request.constraints_from_plan, context, request.round,
752
+ // "Violated this round" means the CURRENT compile round's report. A
753
+ // committed previous round's violations are found by the committed
754
+ // history scan at their real round, not relabelled as this round.
755
+ previous.view ? [] : (request.last_round_result?.constraint_violations ?? []));
756
+ const rolling = buildRollingSummary(request.loop_id, request.round, context, 0);
663
757
  // v3.8: sub-goal lifecycle lives in subgoal-state.ts and consumes the
664
- // shared committed-round view — one history interpretation.
758
+ // shared committed-round view — one history interpretation. The previous
759
+ // round's report is idempotently replayed; a committed round is already in
760
+ // the window, while the library fallback has no view.
665
761
  const subGoals = deriveSubGoals({
666
762
  loopId: request.loop_id,
667
763
  currentRound: request.round,
668
764
  rounds: windowRounds,
669
- currentReport: request.last_round_result
765
+ currentReport: previous.result
670
766
  ? {
671
- round: request.last_round_result.round || (request.round - 1),
672
- emergedSubtasks: request.last_round_result.emerged_subtasks ?? [],
673
- subgoalUpdates: request.last_round_result.subgoal_updates ?? [],
767
+ round: previous.result.round || (request.round - 1),
768
+ emergedSubtasks: previous.result.emerged_subtasks ?? [],
769
+ subgoalUpdates: previous.result.subgoal_updates ?? [],
674
770
  }
675
771
  : null,
676
772
  });
@@ -692,8 +788,18 @@ export function compileLoop(request, context) {
692
788
  // `roundFacts.verifiedSubGoals` is not read here — it reaches the state file
693
789
  // and the projection through the bundle itself.
694
790
  const { activeContract } = roundFacts;
791
+ // v3.9.4: the criterion reference space is the ACTIVE criterion set, not
792
+ // every text the objective ever carried. A retracted criterion must not be
793
+ // claimable or contract-referenceable while the prompt does not render it.
794
+ const activeConstraintKeys = new Set(constraintLifecycle.active.map((text) => normalizeText(text)));
795
+ const activeCriteria = uniqueNormalized(objective.success_criteria)
796
+ .filter((text) => activeConstraintKeys.has(normalizeText(text)));
797
+ const criterionObjective = {
798
+ ...objective,
799
+ success_criteria: activeCriteria,
800
+ };
695
801
  // v3.2: Goal → criteria → evidence vertical view (derived, zero persistence).
696
- const criterionStatuses = deriveCriterionStatuses(request.loop_id, context, objective, request.round, request.last_round_result, roundFacts.criterionFacts);
802
+ const criterionStatuses = deriveCriterionStatuses(request.loop_id, context, criterionObjective, request.round, previous.result, roundFacts.criterionFacts);
697
803
  // v3.8.1: the ONE recurring-fact derivation, over the shared window. The
698
804
  // prompt renders its recent tail; the state file renders the recurring set.
699
805
  const recurringFlags = deriveRecurringFlags(windowRounds);
@@ -703,7 +809,7 @@ export function compileLoop(request, context) {
703
809
  // gone with the rest of the fuzzy matching, and so are the task-alignment
704
810
  // and loop-health warnings: both were Jaccard scores over task/objective
705
811
  // text, never machine facts.
706
- const repeatedDeclarations = duplicateEmergedDeclarations(request.last_round_result?.emerged_subtasks ?? []);
812
+ const repeatedDeclarations = duplicateEmergedDeclarations(previous.result?.emerged_subtasks ?? []);
707
813
  const warnings = unique([
708
814
  repeatedDeclarations.length > 0
709
815
  ? `duplicate_declaration: ${repeatedDeclarations.length} sub-goal ` +
@@ -718,14 +824,12 @@ export function compileLoop(request, context) {
718
824
  // `constraint_violations`, i.e. a DIFFERENT source from the score it was
719
825
  // displayed next to. "Trend" was therefore not the score's history at all.
720
826
  // Neither fed a decision.
721
- const completedEntries = loopEntries(request.loop_id, context)
722
- .filter((e) => entryRound(e) >= 1 && entryRound(e) < request.round);
723
- const committedRounds = derivationRounds(completedEntries, request.round);
724
827
  // v3.3/v3.8.1: machine git-motion (display-only, derived). The per-round
725
828
  // "round stats" table is deleted: its only renderer was the L2 prompt
726
829
  // section removed above, so it was computed, put into the canonical state
727
- // (and therefore into stateHash) and read by nothing.
728
- const machineStatus = deriveMachineStatus(committedRounds, request.round);
830
+ // (and therefore into stateHash) and read by nothing. `windowRounds` is the
831
+ // ONE window computed at the top of this function.
832
+ const machineStatus = deriveMachineStatus(windowRounds, request.round);
729
833
  const response = makeLoopCompileResponse({
730
834
  status: AgentStatus.OK,
731
835
  recompile_level: decision.level,
@@ -760,6 +864,10 @@ export function compileLoop(request, context) {
760
864
  // truth, its verification view and the machine-verified sub-goal facts as
761
865
  // ONE derivation. The static capability rides on the state itself.
762
866
  roundFacts,
867
+ // 3.9.3: the same previous-round bundle the level decision used. This is
868
+ // what keeps changes/discoveries/blockers/remaining/progress identical on
869
+ // live, resume, and unpause compiles.
870
+ previousRound: previous,
763
871
  });
764
872
  // The recovery brief is a current-round fact, computed once and shared by
765
873
  // the state file and the evaluation block. It never becomes prompt control
@@ -808,14 +916,19 @@ export function compileLoop(request, context) {
808
916
  commands: contractCommands,
809
917
  contractVerificationAvailable: configuredCapability.contractVerificationAvailable,
810
918
  subGoals,
811
- verificationFlags: request.verification_flags ?? [],
919
+ verificationFlags: previous.verificationFlags,
812
920
  recovery: recoveryBrief !== undefined,
813
921
  gateEnabled: policy.gate.enabled === true,
814
922
  criteria: criterionStatuses,
923
+ currentRequirement: state.currentTask,
815
924
  }),
816
925
  fullStateMarkdown: l2Pointer ? undefined : markdown,
926
+ // v3.9.4: the SAME committed-backtrack facts the state file renders. The
927
+ // recovery section is part of the artifact, so the delivered prompt is
928
+ // exactly `artifact.renderedPrompt` (no post-compile prepend).
929
+ recoveryBrief,
817
930
  // v2.9: Model's information needs from the previous round's SelfEvaluation
818
- promptRequests: request.last_round_result?.prompt_requests,
931
+ promptRequests: previous.result?.prompt_requests,
819
932
  });
820
933
  response.prompt = artifact.renderedPrompt;
821
934
  response.prompt_artifact = artifact;