loopforge 3.9.1 → 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 (115) hide show
  1. package/README.md +11 -8
  2. package/dist/canonical-state.d.ts +39 -1
  3. package/dist/canonical-state.d.ts.map +1 -1
  4. package/dist/canonical-state.js +114 -32
  5. package/dist/canonical-state.js.map +1 -1
  6. package/dist/committed-round.d.ts +27 -4
  7. package/dist/committed-round.d.ts.map +1 -1
  8. package/dist/committed-round.js +87 -14
  9. package/dist/committed-round.js.map +1 -1
  10. package/dist/enforcement-gate.d.ts +9 -32
  11. package/dist/enforcement-gate.d.ts.map +1 -1
  12. package/dist/enforcement-gate.js +9 -122
  13. package/dist/enforcement-gate.js.map +1 -1
  14. package/dist/engine.d.ts.map +1 -1
  15. package/dist/engine.js +13 -2
  16. package/dist/engine.js.map +1 -1
  17. package/dist/explain.d.ts +1 -0
  18. package/dist/explain.d.ts.map +1 -1
  19. package/dist/explain.js +1 -0
  20. package/dist/explain.js.map +1 -1
  21. package/dist/files-reconciliation.d.ts +18 -0
  22. package/dist/files-reconciliation.d.ts.map +1 -0
  23. package/dist/files-reconciliation.js +59 -0
  24. package/dist/files-reconciliation.js.map +1 -0
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/loop-compiler.d.ts +17 -1
  29. package/dist/loop-compiler.d.ts.map +1 -1
  30. package/dist/loop-compiler.js +220 -119
  31. package/dist/loop-compiler.js.map +1 -1
  32. package/dist/mcp/round-lifecycle.d.ts +31 -1
  33. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  34. package/dist/mcp/round-lifecycle.js +102 -60
  35. package/dist/mcp/round-lifecycle.js.map +1 -1
  36. package/dist/mcp/server.d.ts +5 -0
  37. package/dist/mcp/server.d.ts.map +1 -1
  38. package/dist/mcp/server.js +19 -1
  39. package/dist/mcp/server.js.map +1 -1
  40. package/dist/mcp/session.d.ts.map +1 -1
  41. package/dist/mcp/session.js +23 -5
  42. package/dist/mcp/session.js.map +1 -1
  43. package/dist/mcp/submission-boundary.d.ts +1 -1
  44. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  45. package/dist/mcp/submission-boundary.js +31 -8
  46. package/dist/mcp/submission-boundary.js.map +1 -1
  47. package/dist/mcp/tools.d.ts.map +1 -1
  48. package/dist/mcp/tools.js +62 -6
  49. package/dist/mcp/tools.js.map +1 -1
  50. package/dist/policy-metrics.d.ts +4 -0
  51. package/dist/policy-metrics.d.ts.map +1 -1
  52. package/dist/policy-metrics.js +17 -3
  53. package/dist/policy-metrics.js.map +1 -1
  54. package/dist/policy.d.ts +5 -2
  55. package/dist/policy.d.ts.map +1 -1
  56. package/dist/policy.js +2 -2
  57. package/dist/policy.js.map +1 -1
  58. package/dist/prompt-assembler.d.ts +8 -3
  59. package/dist/prompt-assembler.d.ts.map +1 -1
  60. package/dist/prompt-assembler.js +233 -240
  61. package/dist/prompt-assembler.js.map +1 -1
  62. package/dist/prompt-coverage.d.ts +45 -0
  63. package/dist/prompt-coverage.d.ts.map +1 -0
  64. package/dist/prompt-coverage.js +84 -0
  65. package/dist/prompt-coverage.js.map +1 -0
  66. package/dist/prompt-freshness.d.ts +36 -0
  67. package/dist/prompt-freshness.d.ts.map +1 -0
  68. package/dist/prompt-freshness.js +83 -0
  69. package/dist/prompt-freshness.js.map +1 -0
  70. package/dist/prompt-policy.d.ts +5 -2
  71. package/dist/prompt-policy.d.ts.map +1 -1
  72. package/dist/prompt-policy.js +10 -7
  73. package/dist/prompt-policy.js.map +1 -1
  74. package/dist/protocol.d.ts +70 -8
  75. package/dist/protocol.d.ts.map +1 -1
  76. package/dist/protocol.js +7 -1
  77. package/dist/protocol.js.map +1 -1
  78. package/dist/round-coordinator.d.ts +4 -2
  79. package/dist/round-coordinator.d.ts.map +1 -1
  80. package/dist/round-coordinator.js +7 -7
  81. package/dist/round-coordinator.js.map +1 -1
  82. package/dist/round-facts.d.ts.map +1 -1
  83. package/dist/round-facts.js +9 -3
  84. package/dist/round-facts.js.map +1 -1
  85. package/dist/round-transaction.d.ts.map +1 -1
  86. package/dist/round-transaction.js +19 -1
  87. package/dist/round-transaction.js.map +1 -1
  88. package/dist/self-eval.d.ts +23 -7
  89. package/dist/self-eval.d.ts.map +1 -1
  90. package/dist/self-eval.js +88 -16
  91. package/dist/self-eval.js.map +1 -1
  92. package/dist/submission-guidance.d.ts +36 -4
  93. package/dist/submission-guidance.d.ts.map +1 -1
  94. package/dist/submission-guidance.js +225 -78
  95. package/dist/submission-guidance.js.map +1 -1
  96. package/dist/submission-receipt.d.ts.map +1 -1
  97. package/dist/submission-receipt.js +6 -0
  98. package/dist/submission-receipt.js.map +1 -1
  99. package/dist/token-utils.d.ts +26 -0
  100. package/dist/token-utils.d.ts.map +1 -1
  101. package/dist/token-utils.js +56 -6
  102. package/dist/token-utils.js.map +1 -1
  103. package/dist/verification-actions.d.ts +13 -0
  104. package/dist/verification-actions.d.ts.map +1 -0
  105. package/dist/verification-actions.js +45 -0
  106. package/dist/verification-actions.js.map +1 -0
  107. package/dist/verification-gate.d.ts +5 -4
  108. package/dist/verification-gate.d.ts.map +1 -1
  109. package/dist/verification-gate.js +32 -45
  110. package/dist/verification-gate.js.map +1 -1
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/loop_policy.json +58 -58
  114. package/package.json +68 -68
  115. package/skills/perception/SKILL.md +35 -11
@@ -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 { deriveCriterionId, deriveItemId, isCriterionId, 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. */
@@ -320,21 +358,8 @@ criterionFacts = []) {
320
358
  * EXACTLY equal after normalization (v3.8.1 — the similarity fallback is
321
359
  * gone, so a paraphrase is a different criterion). */
322
360
  export function criteriaMatch(a, b) {
323
- const aIsId = isCriterionId(a);
324
- const bIsId = isCriterionId(b);
325
- // Both are IDs — exact string comparison
326
- if (aIsId && bIsId)
327
- return a === b;
328
- // One is an ID — derive ID from the other and compare
329
- if (aIsId)
330
- return a === deriveCriterionId(b);
331
- if (bIsId)
332
- return deriveCriterionId(a) === b;
333
- // v3.8.1: normalized text must be EXACTLY equal. The former Jaccard
334
- // fallback let two merely-similar criterion strings count as the same
335
- // criterion, which could fuse distinct criteria (and suppress or invent a
336
- // criteria milestone) on a similarity score rather than on an identity.
337
- return normalizeText(a) === normalizeText(b);
361
+ // v3.9.2: ONE identity rule, shared with the prompt and submission boundary.
362
+ return criterionRefMatches(a, b);
338
363
  }
339
364
  /** Detect newly met criteria by comparing the current entry's
340
365
  * met criterion_claims against the previous entry's.
@@ -507,13 +532,16 @@ export { deriveCriterionId, isCriterionId } from "./token-utils.js";
507
532
  // ═══════════════════════════════════════════════════════════════════════════
508
533
  /** Determine the source of a constraint by matching against known sources. */
509
534
  function constraintSource(text, objective, constraintsFromPlan) {
510
- const norm = text.toLowerCase().trim();
511
- 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))
512
542
  return "hard";
513
- if (constraintsFromPlan.some((c) => c.toLowerCase().trim() === norm))
543
+ if (constraintsFromPlan.some((c) => normalizeText(c) === norm))
514
544
  return "plan";
515
- if (objective.success_criteria.some((c) => c.toLowerCase().trim() === norm))
516
- return "criteria";
517
545
  return "discovered";
518
546
  }
519
547
  /** True when a reported violation text matches a constraint text.
@@ -592,40 +620,57 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
592
620
  }
593
621
  return { active, metadata };
594
622
  }
595
- function levelDecision(request, context) {
596
- const previous = getPreviousRound(request.loop_id, request.round - 1, context);
597
- const last = request.last_round_result;
598
- const hasNewInformation = Boolean(request.new_since_last_round.trim() ||
599
- last?.discovered_constraints?.length ||
600
- last?.objective_refinement?.trim() ||
601
- last?.emerged_subtasks?.length ||
602
- last?.retracted_constraints?.length ||
603
- last?.revised_success_criteria?.length ||
604
- last?.wrong_assumptions?.length);
605
- // v2.14: a backtrack committed for this round means the loop is
606
- // re-walking restored state — the recovery boundary forces L2
607
- // rehydration (the decidePromptLevel recovery_boundary branch existed
608
- // but no caller ever set it).
609
- const recoveryBoundary = loopEntries(request.loop_id, context)
610
- .some((entry) => entryLineage(entry).committed_action === "backtrack" &&
611
- 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) {
612
649
  return decidePromptLevel({
613
650
  round: request.round,
614
651
  attempt: request.attempt,
615
652
  forceLevel: request.force_level,
616
653
  hasPlanSource: Boolean(request.plan_source),
617
- recoveryBoundary,
618
- checkpointBoundary: last?.compression_checkpoint === true,
619
- goalChanged: previous !== null && previous.goal_id !== deriveGoalId(request.loop_id, request.task, request.goal_id),
620
- previousStateMissing: request.round > 1 && previous === null,
621
- previousFailedWithoutNewInformation: last?.success === false && !hasNewInformation,
622
- 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
623
667
  .some((flag) => flag.severity === "error"),
624
668
  consecutiveRejections: request.consecutive_rejections,
625
669
  });
626
670
  }
627
671
  export function decideLevel(request, context) {
628
- 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;
629
674
  }
630
675
  /** The prompt's evaluation block. All content (examples, contract/sub-goal
631
676
  * guidance, flag-specific hints) comes from the one FIELD_GUIDES table in
@@ -643,47 +688,85 @@ export function buildSelfEvalBlock(ctx) {
643
688
  * rollback decision's own fields — no new persistence, no rejected
644
689
  * payloads. */
645
690
  function committedBacktrackBrief(loopId, round, context) {
646
- const entry = loopEntries(loopId, context).find((e) => entryLineage(e).committed_action === "backtrack" && entryRound(e) === round);
647
- if (!entry)
648
- return undefined;
649
- // v3.8.1: one decoder for the committed rollback record and one renderer for
650
- // the brief. The facts ride on the merged lineage entry (engine.ts stamps
651
- // them from the committed decision) and the same `decodeBacktrackRecord`
652
- // reads the durable :feedback envelope, so both carriers decode to one shape
653
- // — and this brief and the rollback prompt no longer render the same
654
- // rollback with two hand-written line builders.
655
- 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;
656
697
  if (!record)
657
698
  return undefined;
658
- return recoveryBriefLines({
699
+ const lines = recoveryBriefLines({
659
700
  target: record.target,
660
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
+ : {}),
661
708
  failedRounds: record.failedRounds,
662
709
  approaches: record.approaches,
663
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}`,
664
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;
665
740
  }
666
741
  export function compileLoop(request, context) {
667
- const decision = levelDecision(request, context);
668
- const previous = getPreviousRound(request.loop_id, request.round - 1, context);
669
- const objective = evolveObjective(request, context);
670
- const constraints = evolveConstraints(request, objective, previous, context);
671
- const constraintLifecycle = manageConstraintLifecycle(constraints.active, objective, request.constraints_from_plan, context, request.round, request.last_round_result?.constraint_violations ?? []);
672
- const rolling = buildRollingSummary(request.loop_id, request.round, context, 0);
673
- // v3.8: ONE committed-round derivation feeds every projection below
674
- // (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.
675
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);
676
757
  // v3.8: sub-goal lifecycle lives in subgoal-state.ts and consumes the
677
- // 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.
678
761
  const subGoals = deriveSubGoals({
679
762
  loopId: request.loop_id,
680
763
  currentRound: request.round,
681
764
  rounds: windowRounds,
682
- currentReport: request.last_round_result
765
+ currentReport: previous.result
683
766
  ? {
684
- round: request.last_round_result.round || (request.round - 1),
685
- emergedSubtasks: request.last_round_result.emerged_subtasks ?? [],
686
- 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 ?? [],
687
770
  }
688
771
  : null,
689
772
  });
@@ -705,8 +788,18 @@ export function compileLoop(request, context) {
705
788
  // `roundFacts.verifiedSubGoals` is not read here — it reaches the state file
706
789
  // and the projection through the bundle itself.
707
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
+ };
708
801
  // v3.2: Goal → criteria → evidence vertical view (derived, zero persistence).
709
- 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);
710
803
  // v3.8.1: the ONE recurring-fact derivation, over the shared window. The
711
804
  // prompt renders its recent tail; the state file renders the recurring set.
712
805
  const recurringFlags = deriveRecurringFlags(windowRounds);
@@ -716,7 +809,7 @@ export function compileLoop(request, context) {
716
809
  // gone with the rest of the fuzzy matching, and so are the task-alignment
717
810
  // and loop-health warnings: both were Jaccard scores over task/objective
718
811
  // text, never machine facts.
719
- const repeatedDeclarations = duplicateEmergedDeclarations(request.last_round_result?.emerged_subtasks ?? []);
812
+ const repeatedDeclarations = duplicateEmergedDeclarations(previous.result?.emerged_subtasks ?? []);
720
813
  const warnings = unique([
721
814
  repeatedDeclarations.length > 0
722
815
  ? `duplicate_declaration: ${repeatedDeclarations.length} sub-goal ` +
@@ -731,14 +824,12 @@ export function compileLoop(request, context) {
731
824
  // `constraint_violations`, i.e. a DIFFERENT source from the score it was
732
825
  // displayed next to. "Trend" was therefore not the score's history at all.
733
826
  // Neither fed a decision.
734
- const completedEntries = loopEntries(request.loop_id, context)
735
- .filter((e) => entryRound(e) >= 1 && entryRound(e) < request.round);
736
- const committedRounds = derivationRounds(completedEntries, request.round);
737
827
  // v3.3/v3.8.1: machine git-motion (display-only, derived). The per-round
738
828
  // "round stats" table is deleted: its only renderer was the L2 prompt
739
829
  // section removed above, so it was computed, put into the canonical state
740
- // (and therefore into stateHash) and read by nothing.
741
- 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);
742
833
  const response = makeLoopCompileResponse({
743
834
  status: AgentStatus.OK,
744
835
  recompile_level: decision.level,
@@ -773,6 +864,10 @@ export function compileLoop(request, context) {
773
864
  // truth, its verification view and the machine-verified sub-goal facts as
774
865
  // ONE derivation. The static capability rides on the state itself.
775
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,
776
871
  });
777
872
  // The recovery brief is a current-round fact, computed once and shared by
778
873
  // the state file and the evaluation block. It never becomes prompt control
@@ -821,13 +916,19 @@ export function compileLoop(request, context) {
821
916
  commands: contractCommands,
822
917
  contractVerificationAvailable: configuredCapability.contractVerificationAvailable,
823
918
  subGoals,
824
- verificationFlags: request.verification_flags ?? [],
919
+ verificationFlags: previous.verificationFlags,
825
920
  recovery: recoveryBrief !== undefined,
826
921
  gateEnabled: policy.gate.enabled === true,
922
+ criteria: criterionStatuses,
923
+ currentRequirement: state.currentTask,
827
924
  }),
828
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,
829
930
  // v2.9: Model's information needs from the previous round's SelfEvaluation
830
- promptRequests: request.last_round_result?.prompt_requests,
931
+ promptRequests: previous.result?.prompt_requests,
831
932
  });
832
933
  response.prompt = artifact.renderedPrompt;
833
934
  response.prompt_artifact = artifact;