loopforge 3.7.1 → 3.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +84 -130
  2. package/dist/audit.d.ts +53 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +106 -22
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +52 -67
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +134 -147
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +103 -4
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts +13 -0
  14. package/dist/cognitive-facts.d.ts.map +1 -1
  15. package/dist/cognitive-facts.js +28 -13
  16. package/dist/cognitive-facts.js.map +1 -1
  17. package/dist/committed-round.d.ts +103 -21
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +188 -45
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/contract-items.d.ts +55 -0
  22. package/dist/contract-items.d.ts.map +1 -0
  23. package/dist/contract-items.js +180 -0
  24. package/dist/contract-items.js.map +1 -0
  25. package/dist/enforcement-gate.d.ts +20 -2
  26. package/dist/enforcement-gate.d.ts.map +1 -1
  27. package/dist/enforcement-gate.js +197 -386
  28. package/dist/enforcement-gate.js.map +1 -1
  29. package/dist/engine.d.ts +1 -1
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +53 -38
  32. package/dist/engine.js.map +1 -1
  33. package/dist/evidence-claims.d.ts +4 -4
  34. package/dist/evidence-claims.d.ts.map +1 -1
  35. package/dist/evidence-claims.js +16 -14
  36. package/dist/evidence-claims.js.map +1 -1
  37. package/dist/evidence-provider.d.ts +85 -89
  38. package/dist/evidence-provider.d.ts.map +1 -1
  39. package/dist/evidence-provider.js +328 -184
  40. package/dist/evidence-provider.js.map +1 -1
  41. package/dist/explain.d.ts +76 -0
  42. package/dist/explain.d.ts.map +1 -0
  43. package/dist/explain.js +127 -0
  44. package/dist/explain.js.map +1 -0
  45. package/dist/generate-schema.js +152 -16
  46. package/dist/generate-schema.js.map +1 -1
  47. package/dist/index.d.ts +6 -6
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +5 -5
  50. package/dist/index.js.map +1 -1
  51. package/dist/loop-compiler.d.ts +40 -53
  52. package/dist/loop-compiler.d.ts.map +1 -1
  53. package/dist/loop-compiler.js +229 -553
  54. package/dist/loop-compiler.js.map +1 -1
  55. package/dist/loop-extras-parser.d.ts +0 -1
  56. package/dist/loop-extras-parser.d.ts.map +1 -1
  57. package/dist/loop-extras-parser.js +0 -4
  58. package/dist/loop-extras-parser.js.map +1 -1
  59. package/dist/loop-projection.d.ts.map +1 -1
  60. package/dist/loop-projection.js +3 -1
  61. package/dist/loop-projection.js.map +1 -1
  62. package/dist/mcp/round-lifecycle.d.ts +30 -21
  63. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  64. package/dist/mcp/round-lifecycle.js +47 -103
  65. package/dist/mcp/round-lifecycle.js.map +1 -1
  66. package/dist/mcp/server.d.ts.map +1 -1
  67. package/dist/mcp/server.js +51 -7
  68. package/dist/mcp/server.js.map +1 -1
  69. package/dist/mcp/session.d.ts +39 -23
  70. package/dist/mcp/session.d.ts.map +1 -1
  71. package/dist/mcp/session.js +148 -163
  72. package/dist/mcp/session.js.map +1 -1
  73. package/dist/mcp/submission-boundary.d.ts +89 -0
  74. package/dist/mcp/submission-boundary.d.ts.map +1 -0
  75. package/dist/mcp/submission-boundary.js +203 -0
  76. package/dist/mcp/submission-boundary.js.map +1 -0
  77. package/dist/mcp/tools.d.ts.map +1 -1
  78. package/dist/mcp/tools.js +249 -128
  79. package/dist/mcp/tools.js.map +1 -1
  80. package/dist/policy-metrics.js +2 -2
  81. package/dist/policy-metrics.js.map +1 -1
  82. package/dist/policy.d.ts +55 -93
  83. package/dist/policy.d.ts.map +1 -1
  84. package/dist/policy.js +149 -38
  85. package/dist/policy.js.map +1 -1
  86. package/dist/prompt-assembler.d.ts +1 -32
  87. package/dist/prompt-assembler.d.ts.map +1 -1
  88. package/dist/prompt-assembler.js +282 -435
  89. package/dist/prompt-assembler.js.map +1 -1
  90. package/dist/prompt-policy.d.ts +1 -4
  91. package/dist/prompt-policy.d.ts.map +1 -1
  92. package/dist/prompt-policy.js +6 -7
  93. package/dist/prompt-policy.js.map +1 -1
  94. package/dist/protocol.d.ts +365 -142
  95. package/dist/protocol.d.ts.map +1 -1
  96. package/dist/protocol.js +21 -66
  97. package/dist/protocol.js.map +1 -1
  98. package/dist/replay.js +2 -2
  99. package/dist/replay.js.map +1 -1
  100. package/dist/round-contract.d.ts +69 -57
  101. package/dist/round-contract.d.ts.map +1 -1
  102. package/dist/round-contract.js +104 -64
  103. package/dist/round-contract.js.map +1 -1
  104. package/dist/round-coordinator.d.ts +36 -16
  105. package/dist/round-coordinator.d.ts.map +1 -1
  106. package/dist/round-coordinator.js +166 -102
  107. package/dist/round-coordinator.js.map +1 -1
  108. package/dist/round-driver.d.ts +3 -5
  109. package/dist/round-driver.d.ts.map +1 -1
  110. package/dist/round-driver.js +0 -1
  111. package/dist/round-driver.js.map +1 -1
  112. package/dist/round-facts.d.ts +96 -0
  113. package/dist/round-facts.d.ts.map +1 -0
  114. package/dist/round-facts.js +216 -0
  115. package/dist/round-facts.js.map +1 -0
  116. package/dist/round-transaction.d.ts +19 -9
  117. package/dist/round-transaction.d.ts.map +1 -1
  118. package/dist/round-transaction.js +90 -22
  119. package/dist/round-transaction.js.map +1 -1
  120. package/dist/self-eval.d.ts +87 -9
  121. package/dist/self-eval.d.ts.map +1 -1
  122. package/dist/self-eval.js +396 -27
  123. package/dist/self-eval.js.map +1 -1
  124. package/dist/subgoal-state.d.ts +77 -0
  125. package/dist/subgoal-state.d.ts.map +1 -0
  126. package/dist/subgoal-state.js +198 -0
  127. package/dist/subgoal-state.js.map +1 -0
  128. package/dist/token-utils.d.ts +52 -19
  129. package/dist/token-utils.d.ts.map +1 -1
  130. package/dist/token-utils.js +83 -72
  131. package/dist/token-utils.js.map +1 -1
  132. package/dist/verification-gate.d.ts +14 -31
  133. package/dist/verification-gate.d.ts.map +1 -1
  134. package/dist/verification-gate.js +178 -496
  135. package/dist/verification-gate.js.map +1 -1
  136. package/dist/version.d.ts +1 -1
  137. package/dist/version.js +1 -1
  138. package/dist/workspace.d.ts +9 -0
  139. package/dist/workspace.d.ts.map +1 -1
  140. package/dist/workspace.js +17 -0
  141. package/dist/workspace.js.map +1 -1
  142. package/loop_policy.json +59 -83
  143. package/package.json +67 -67
  144. package/skills/perception/SKILL.md +218 -204
@@ -5,13 +5,16 @@
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
7
  import { getPolicy } from "./policy.js";
8
- import { AgentStatus, makeLoopCompileResponse, makeLoopHealth, makeLoopObjective, makeConstraintMeta, makeMilestoneSummary, makeRollingSummary, makeSubGoal, makeTaskAlignment, } from "./protocol.js";
8
+ import { AgentStatus, makeLoopCompileResponse, makeLoopObjective, makeConstraintMeta, makeMilestoneSummary, makeRollingSummary, } from "./protocol.js";
9
9
  import { createCanonicalLoopState, renderCanonicalStateMarkdown, } from "./canonical-state.js";
10
- import { decodeRound, entryLineage, entryCriteriaMet, entryCriteriaRemaining, entryActiveConstraints, entryRetractedConstraints, entryProgressEstimate, entryEmergedSubtasks, entrySubGoalUpdates, machineGitMotionSeries, mergedRoundsFromEntries, } from "./committed-round.js";
11
- import { contractRoundEvaluations, deriveActiveRoundContract, } from "./round-contract.js";
10
+ import { decodeBacktrackRecord, decodeRound, entryLineage, entryCriteriaMet, entryCriteriaRemaining, entryActiveConstraints, entryRetractedConstraints, entryProgressEstimate, machineGitMotionSeries, derivationRounds, } from "./committed-round.js";
11
+ import { NO_IN_FLIGHT_ROUND, deriveRoundFacts } from "./round-facts.js";
12
+ import { recoveryBriefLines } from "./enforcement-gate.js";
13
+ import { deriveSubGoals, duplicateEmergedDeclarations } from "./subgoal-state.js";
14
+ import { claimedMetCriteria, claimedRemainingCriteria } from "./self-eval.js";
12
15
  import { assemblePromptArtifact } from "./prompt-assembler.js";
13
16
  import { decidePromptLevel, } from "./prompt-policy.js";
14
- import { deriveItemId, jaccardSimilarity, unique, entryRound } from "./token-utils.js";
17
+ import { deriveCriterionId, deriveItemId, isCriterionId, normalizeText, unique, entryRound, } from "./token-utils.js";
15
18
  function contextEntries(context) {
16
19
  if (!context || !Array.isArray(context.results))
17
20
  return [];
@@ -55,32 +58,6 @@ export function deriveGoalId(loopId, task, explicit = "") {
55
58
  .replace(/^-|-$/g, "")
56
59
  .slice(0, 48) || computeGoalTextHash(task)}`;
57
60
  }
58
- /** v3.2: Read the previous round's persisted L1 presentation snapshot — the
59
- * diff baseline for L1 collapse. Returns null when the previous round's
60
- * lineage entry lacks the three presented_* fields (as on L0/L2 compiles),
61
- * so callers render the full L1 state. */
62
- export function readPresentedBaseline(loopId, round, context) {
63
- const entry = roundCanonicalEntry(loopEntries(loopId, context), round - 1);
64
- if (!entry)
65
- return null;
66
- const data = entryLineage(entry);
67
- const constraintIds = data.presented_constraint_ids;
68
- const subGoals = data.presented_subgoals;
69
- const milestoneRanges = data.presented_milestone_ranges;
70
- if (!Array.isArray(constraintIds) || !constraintIds.every((v) => typeof v === "string") ||
71
- !Array.isArray(subGoals) || !subGoals.every((v) => Array.isArray(v) && v.length === 2 &&
72
- typeof v[0] === "string" && typeof v[1] === "string") ||
73
- !Array.isArray(milestoneRanges) || !milestoneRanges.every((v) => Array.isArray(v) && v.length === 2 &&
74
- typeof v[0] === "number" && typeof v[1] === "number")) {
75
- return null;
76
- }
77
- return {
78
- round: round - 1,
79
- constraintIds: constraintIds,
80
- subGoals: subGoals,
81
- milestoneRanges: milestoneRanges,
82
- };
83
- }
84
61
  export function getPreviousRound(loopId, round, context) {
85
62
  const entry = roundCanonicalEntry(loopEntries(loopId, context), round);
86
63
  if (!entry)
@@ -216,86 +193,50 @@ function evolveConstraints(request, objective, previous, context) {
216
193
  // ═══════════════════════════════════════════════════════════════════════════
217
194
  // Hierarchical Summary (v2.1)
218
195
  // ═══════════════════════════════════════════════════════════════════════════
219
- /** v3.2: Deterministic lessons learned — constraints violated repeatedly or
220
- * verification checks failing repeatedly across rounds (full history, unlike
221
- * the enforcement gate's R2 3-round window). Presentation only: the output
222
- * never feeds enforcement decisions. */
223
- export function deriveLessons(loopId, context, currentRound) {
224
- const entries = loopEntries(loopId, context).filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
225
- const violations = new Map();
226
- const errors = new Map();
227
- const warns = new Map();
228
- for (const entry of entries) {
229
- const rnd = entryRound(entry);
230
- const view = decodeRound(entry);
231
- const viols = view
232
- ? view.constraintViolations ?? []
233
- : Array.isArray(entry.constraint_violations)
234
- ? entry.constraint_violations.filter((v) => typeof v === "string")
235
- : [];
236
- for (const text of viols) {
237
- const list = violations.get(text) ?? [];
238
- list.push(rnd);
239
- violations.set(text, list);
240
- }
241
- const flags = view
242
- ? view.verificationFlags
243
- : Array.isArray(entry.verification_flags) ? entry.verification_flags : [];
244
- for (const flag of flags) {
245
- const target = flag.severity === "error"
246
- ? errors
247
- : flag.severity === "warn" ? warns : null;
248
- if (!target)
249
- continue;
250
- const list = target.get(flag.check) ?? [];
251
- list.push(rnd);
252
- target.set(flag.check, list);
253
- }
254
- }
255
- const lessons = [];
256
- for (const [text, rounds] of violations) {
257
- if (rounds.length >= 2) {
258
- lessons.push({ text, kind: "constraint_violation", count: rounds.length, rounds });
196
+ /** v3.8.1: THE recurring-fact derivation — one walk over the committed
197
+ * history, grouping repeated machine facts by what they are ABOUT.
198
+ *
199
+ * This replaces three separate answers to "what keeps going wrong":
200
+ * `deriveLessons` (whole history, count >= 2, keyed by constraint text or
201
+ * check id), `rolling_summary.recurring_issues` (the last 5 rounds' raw
202
+ * violation texts, with NO threshold at all despite the name), and the L1/L2
203
+ * sections built on top of each. Three windows over one fact set is exactly
204
+ * the parallel semantics this release removes.
205
+ *
206
+ * Callers FILTER this list rather than re-deriving: the state file renders
207
+ * the genuinely recurring set (count >= 2), the prompt renders the recent
208
+ * tail as Active Warnings.
209
+ *
210
+ * Presentation only: the result never feeds enforcement. */
211
+ export function deriveRecurringFlags(committedRounds) {
212
+ const byKey = new Map();
213
+ const record = (kind, subject, ref, round) => {
214
+ const key = `${kind}${subject}${ref}`;
215
+ const existing = byKey.get(key);
216
+ if (existing) {
217
+ existing.count++;
218
+ if (!existing.rounds.includes(round))
219
+ existing.rounds.push(round);
220
+ return;
259
221
  }
260
- }
261
- for (const [text, rounds] of errors) {
262
- if (rounds.length >= 2) {
263
- lessons.push({ text, kind: "verification_error", count: rounds.length, rounds });
222
+ byKey.set(key, { subject, kind, ref, count: 1, rounds: [round] });
223
+ };
224
+ for (const round of committedRounds) {
225
+ for (const text of round.constraintViolations ?? []) {
226
+ record("constraint_violation", text, "", round.round);
264
227
  }
265
- }
266
- for (const [text, rounds] of warns) {
267
- if (rounds.length >= 2) {
268
- lessons.push({ text, kind: "verification_warning", count: rounds.length, rounds });
228
+ for (const flag of round.verificationFlags) {
229
+ const kind = flag.severity === "error"
230
+ ? "verification_error"
231
+ : flag.severity === "warn" ? "verification_warning" : null;
232
+ if (!kind)
233
+ continue;
234
+ record(kind, flag.check, flag.ref ?? "", round.round);
269
235
  }
270
236
  }
271
- return lessons.sort((a, b) => b.count - a.count);
272
- }
273
- /** v3.3: Derive display-only round statistics over the last `window`
274
- * committed rounds — files changed, rejected attempts (snapshot.attempt
275
- * minus one: the successful commit's attempt counts the redo), and the
276
- * self-reported progress delta vs the previous round. One row per round,
277
- * preferring the :feedback entry when both lineage and feedback entries
278
- * exist. Zero persistence — re-derived from committed entries. */
279
- function deriveRoundStats(rounds, window = 5) {
280
- const stats = [];
281
- let prevProgress = null;
282
- for (const round of rounds.slice(-window)) {
283
- const ev = round.executionEvidence;
284
- const files = ev?.files_changed;
285
- const rawProgress = ev?.progress_estimate;
286
- const progress = typeof rawProgress === "number" ? rawProgress : null;
287
- stats.push({
288
- round: round.round,
289
- filesChangedCount: Array.isArray(files) ? files.length : null,
290
- rejectedAttempts: round.attempt >= 1 ? round.attempt - 1 : null,
291
- progressDelta: progress !== null && prevProgress !== null
292
- ? Number((progress - prevProgress).toFixed(3))
293
- : null,
294
- });
295
- if (progress !== null)
296
- prevProgress = progress;
297
- }
298
- return stats;
237
+ return [...byKey.values()].sort((a, b) => b.count - a.count ||
238
+ a.subject.localeCompare(b.subject) ||
239
+ a.ref.localeCompare(b.ref));
299
240
  }
300
241
  /** v3.3: Machine git-motion cross-check over the last 3 committed rounds
301
242
  * (the R4 window). Undefined when no committed rounds carry git snapshots
@@ -308,39 +249,36 @@ function deriveMachineStatus(rounds, currentRound) {
308
249
  const motionRounds = series.filter(Boolean).length;
309
250
  return { windowRounds: 3, gitMotion: motionRounds > 0, motionRounds };
310
251
  }
311
- /** v3.4: ACTIVE Round Contract for the compile round — derived from the
312
- * committed evals of earlier rounds only, never from request data. Reads
313
- * the hydrated merged lineage entries (one per round; a committed decision
314
- * is merged onto the round's lineage entry with `committed_action` set),
315
- * skipping rounds whose committed action was "backtrack" (a roll-back
316
- * directive, not an executed round). Because it runs identically on every
317
- * compile path — including rejection retries and resume / unpause /
318
- * backtrack compiles that carry no last_round_result — a contract round's
319
- * re-compiles keep showing its contract as the Current Task. Null when no
320
- * active contract: the Current Task falls back to the original task text. */
321
- function deriveActiveContract(loopId, context, currentRound) {
322
- const entries = loopEntries(loopId, context);
323
- const canonical = Array.from({ length: Math.max(0, currentRound - 1) }, (_, index) => roundCanonicalEntry(entries, index + 1)).filter((entry) => entry !== null);
324
- return deriveActiveRoundContract(contractRoundEvaluations(mergedRoundsFromEntries(canonical, currentRound)));
325
- }
326
252
  /** v3.2: Derive per-criterion status — the "goal → criteria → evidence"
327
253
  * vertical view. Each objective criterion gets: met/remaining/unknown
328
- * (from per-round success_criteria_met/remaining reports, ID-first
329
- * matching), the round it was first reported met, and any sub-goals whose
330
- * description matches it (Jaccard). Zero persistence — re-derived from the
331
- * vault every compile. */
332
- export function deriveCriterionStatuses(loopId, context, objective, currentRound, subGoals, lastRoundResult) {
254
+ * (from per-round criterion_claims, ID-first or normalized-exact matching),
255
+ * the round it was first reported met, and the sub-goals a contract item
256
+ * referencing it also names (explicit `subgoal_refs`, never a text guess).
257
+ * Zero persistence — re-derived from the vault every compile. */
258
+ export function deriveCriterionStatuses(loopId, context, objective, currentRound, lastRoundResult,
259
+ /** v3.8.1: the criterion machine facts from the shared bundle. A criterion
260
+ * is `verified` / `contradicted` / `insufficient` only through an item that
261
+ * references it — a claim alone can never reach `verified` — and the fact
262
+ * is derived over the WHOLE committed history, so it survives its contract
263
+ * closing (see `deriveCriterionFacts`). */
264
+ criterionFacts = []) {
333
265
  if (!objective || objective.success_criteria.length === 0)
334
266
  return [];
335
267
  const entries = loopEntries(loopId, context).filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
336
268
  // The current round's report lives in last_round_result (the vault only
337
269
  // gains it on commit); treat it as the report for round currentRound - 1.
338
- const lastEv = lastRoundResult?.execution_evidence;
339
- const lastMet = lastEv?.success_criteria_met ?? [];
340
- const lastRemaining = lastEv?.success_criteria_remaining ?? [];
270
+ const lastEv = lastRoundResult?.execution_report;
271
+ const lastMet = claimedMetCriteria(lastEv);
272
+ const lastRemaining = claimedRemainingCriteria(lastEv);
341
273
  const lastEntry = entries[entries.length - 1] ?? null;
342
274
  const lastEntryRemaining = lastEntry ? entryCriteriaRemaining(lastEntry) : [];
343
- const matchThreshold = getPolicy().evolution.subgoal_match_threshold;
275
+ // v3.8.1: criterion → machine status + explicit sub-goal links, from the
276
+ // shared `RoundFacts` bundle (whole committed history). It used to be
277
+ // re-derived here from the ACTIVE contract, which made both maps empty the
278
+ // moment the contract closed — the criterion's machine fact, and the
279
+ // sub-goal links carried on the same item, disappeared while the sub-goal
280
+ // side of the very same fact survived. One derivation, one window.
281
+ const factsByCriterion = new Map(criterionFacts.map((fact) => [fact.criterion_id, fact]));
344
282
  return objective.success_criteria.map((text) => {
345
283
  let metAtRound = null;
346
284
  for (const entry of entries) {
@@ -353,28 +291,33 @@ export function deriveCriterionStatuses(loopId, context, objective, currentRound
353
291
  if (metAtRound === null && lastMet.some((met) => criteriaMatch(met, text))) {
354
292
  metAtRound = currentRound - 1;
355
293
  }
356
- const status = metAtRound !== null
357
- ? "met"
294
+ // v3.8: a claim reaches `claimed` at most. Only a machine fact about a
295
+ // contract item that references this criterion can go further.
296
+ const claimedStatus = metAtRound !== null
297
+ ? "claimed"
358
298
  : lastRemaining.some((remaining) => criteriaMatch(remaining, text)) ||
359
299
  lastEntryRemaining.some((remaining) => criteriaMatch(remaining, text))
360
300
  ? "remaining"
361
301
  : "unknown";
302
+ const fact = factsByCriterion.get(deriveCriterionId(text));
303
+ const status = fact?.status ?? claimedStatus;
362
304
  return {
363
305
  id: deriveCriterionId(text),
364
306
  text,
365
307
  status,
366
308
  ...(metAtRound !== null ? { met_at_round: metAtRound } : {}),
367
- related_subgoal_ids: subGoals
368
- .filter((sg) => jaccardSimilarity(text, sg.description) >= matchThreshold)
369
- .map((sg) => sg.id),
309
+ // v3.8.1: explicit refs only — empty when no contract item links this
310
+ // criterion to a sub-goal.
311
+ related_subgoal_ids: fact?.related_subgoal_ids ?? [],
370
312
  };
371
313
  });
372
314
  }
373
- /** v2.11: Match two criterion references for deduplication.
374
- * If either is a criterion ID (cr-XXXXXXXX), uses exact ID comparison.
375
- * Otherwise falls back to Jaccard similarity.
376
- * v3.3: exported for the verification gate's windowed criteria-completion
377
- * scan (R4/R5 exculpatory cross-check). */
315
+ /** v3.8: Rank item statuses so the strongest machine fact wins when several
316
+ * items reference the same criterion. */
317
+ /** Match two criterion references. If either is a criterion id
318
+ * (cr-XXXXXXXX), compares ids; otherwise requires the two texts to be
319
+ * EXACTLY equal after normalization (v3.8.1 — the similarity fallback is
320
+ * gone, so a paraphrase is a different criterion). */
378
321
  export function criteriaMatch(a, b) {
379
322
  const aIsId = isCriterionId(a);
380
323
  const bIsId = isCriterionId(b);
@@ -386,13 +329,16 @@ export function criteriaMatch(a, b) {
386
329
  return a === deriveCriterionId(b);
387
330
  if (bIsId)
388
331
  return deriveCriterionId(a) === b;
389
- // Neither is an ID — Jaccard fallback
390
- return jaccardSimilarity(a, b) >= getPolicy().evolution.criteria_dedup_threshold;
332
+ // v3.8.1: normalized text must be EXACTLY equal. The former Jaccard
333
+ // fallback let two merely-similar criterion strings count as the same
334
+ // criterion, which could fuse distinct criteria (and suppress or invent a
335
+ // criteria milestone) on a similarity score rather than on an identity.
336
+ return normalizeText(a) === normalizeText(b);
391
337
  }
392
338
  /** Detect newly met criteria by comparing the current entry's
393
- * success_criteria_met against the previous entry's.
394
- * v2.11: ID-first matching (cr-XXXXXXXX) with Jaccard similarity
395
- * fallback for natural-language references.
339
+ * met criterion_claims against the previous entry's.
340
+ * v2.11/v3.8.1: id-first (cr-XXXXXXXX), then normalized-exact text —
341
+ * no similarity.
396
342
  * Returns empty array when there is no previous entry — the first
397
343
  * entry's criteria are the baseline, not a "new" event. */
398
344
  function detectNewCriteria(current, previous) {
@@ -433,56 +379,14 @@ function buildMilestoneFromEntries(phaseEntries, startRound, endRound, label, ki
433
379
  generated_at_round: endRound,
434
380
  });
435
381
  }
436
- /** v3.0.1: Evenly sample `budget` entries from `mid`, preferring
437
- * agent_declared milestones (human/agent-labeled anchors) first and filling
438
- * the remainder at even time positions. The caller guarantees
439
- * `mid.length > budget`. */
440
- function sampleMiddleMilestones(mid, budget) {
441
- if (budget <= 0)
442
- return [];
443
- if (mid.length <= budget)
444
- return mid;
445
- const chosen = [];
446
- const used = new Set();
447
- for (const m of mid) {
448
- if (m.kind === "agent_declared") {
449
- chosen.push(m);
450
- used.add(m);
451
- if (chosen.length >= budget)
452
- return chosen;
453
- }
454
- }
455
- const rest = mid.filter((m) => !used.has(m));
456
- const need = budget - chosen.length;
457
- for (let i = 0; i < need; i++) {
458
- const idx = Math.floor(((i + 0.5) * rest.length) / need);
459
- chosen.push(rest[idx]);
460
- }
461
- return chosen;
462
- }
463
- /** v3.0.1: L1 milestone sampling — when milestones exceed max_milestones,
464
- * keep the oldest `milestone_head_count` (history anchors), the newest
465
- * `milestone_tail_count` (current progress), and an even sample of the
466
- * middle. L2 (full rehydration) is never sampled: the recovery view keeps
467
- * every milestone as the loop's memory skeleton. */
468
- function sampleMilestones(milestones, policy, level) {
469
- if (level === "l2")
470
- return milestones;
471
- if (milestones.length <= policy.max_milestones)
472
- return milestones;
473
- const head = Math.max(0, policy.milestone_head_count);
474
- const tail = Math.max(0, policy.milestone_tail_count);
475
- const midBudget = Math.max(0, policy.max_milestones - head - tail);
476
- if (midBudget === 0)
477
- return milestones.slice(0, head).concat(milestones.slice(milestones.length - tail));
478
- const mid = milestones.slice(head, milestones.length - tail);
479
- return [
480
- ...milestones.slice(0, head),
481
- ...sampleMiddleMilestones(mid, midBudget),
482
- ...milestones.slice(milestones.length - tail),
483
- ];
484
- }
485
- export function buildRollingSummary(loopId, currentRound, context, sinceRound = 0, level) {
382
+ /** v3.8.1: the milestone history bound. Applied identically at every prompt
383
+ * level and in the state file, so the milestone set is a function of
384
+ * committed facts rather than of which level happened to compile. (The
385
+ * v3.0.1 sampler chose which milestones to keep from an UNBOUNDED list and
386
+ * did nothing at L2 — so the state file’s phase history depended on the
387
+ * prompt level and had no bound of its own.) */
388
+ const MILESTONE_HISTORY_CAP = 50;
389
+ export function buildRollingSummary(loopId, currentRound, context, sinceRound = 0) {
486
390
  const policy = getPolicy().summary;
487
391
  const allEntries = loopEntries(loopId, context);
488
392
  // ── Phase 1: Recent Window (preserves existing behavior) ──
@@ -493,7 +397,6 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
493
397
  })
494
398
  .slice(-policy.window);
495
399
  const outcomes = [];
496
- const issues = [];
497
400
  for (const entry of windowEntries) {
498
401
  const view = decodeRound(entry);
499
402
  const data = entryLineage(entry);
@@ -513,12 +416,6 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
513
416
  : success === false ? "failed" : "accepted";
514
417
  if (summary)
515
418
  outcomes.push(`[R${round}] ${outcomeLabel}: ${summary}`);
516
- const violations = view
517
- ? view.constraintViolations ?? []
518
- : Array.isArray(entry.constraint_violations)
519
- ? entry.constraint_violations
520
- : Array.isArray(data.constraint_violations) ? data.constraint_violations : [];
521
- issues.push(...violations.filter((item) => typeof item === "string"));
522
419
  }
523
420
  // ── Phase 2: Milestone Accumulation ──
524
421
  // Scan all completed entries in round order. For each entry, check three
@@ -575,15 +472,17 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
575
472
  milestones.push(buildMilestoneFromEntries(phaseEntries, startRound, endRound, `Rounds ${startRound}–${endRound}`, "auto"));
576
473
  }
577
474
  }
578
- // v3.0.1: L1 keeps head anchors + newest tail + an even middle sample;
579
- // L2 (full rehydration) keeps every milestone — the recovery view is the
580
- // loop's memory skeleton and must not be truncated.
581
- const cappedMilestones = sampleMilestones(milestones, policy, level);
475
+ // v3.8.1: one bound, applied identically at every prompt level. The v3.0.1
476
+ // sampler kept head anchors + a newest tail + an even middle sample, and
477
+ // deliberately did nothing at L2 — so the milestone set written to the state
478
+ // file depended on which level happened to compile, not on committed facts.
479
+ // It also did not bound growth at all (it only chose WHICH milestones to
480
+ // keep from an unbounded list), which is why the cap has to live here.
481
+ const cappedMilestones = milestones.slice(-MILESTONE_HISTORY_CAP);
582
482
  if (windowEntries.length === 0 && cappedMilestones.length === 0)
583
483
  return null;
584
484
  return makeRollingSummary({
585
485
  key_outcomes: unique(outcomes),
586
- recurring_issues: unique(issues),
587
486
  rounds_sampled: windowEntries.length,
588
487
  generated_at_round: currentRound,
589
488
  failed_patterns: [],
@@ -593,188 +492,17 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
593
492
  // ═══════════════════════════════════════════════════════════════════════════
594
493
  // Sub-Goal Structured Tracking (v2.2)
595
494
  // ═══════════════════════════════════════════════════════════════════════════
596
- /** Derive a stable sub-goal ID from its description hash.
597
- * Exported as the single source of truth (verification-gate imports it). */
598
- export function deriveSubGoalId(description) {
599
- return "sg-" + deriveItemId(description);
600
- }
601
495
  /** v2.11: Derive a stable constraint ID from its text hash (c-XXXXXXXX).
602
496
  * Same hash strategy as SubGoal — deterministic across rounds. */
603
497
  export function deriveConstraintId(text) {
604
498
  return "c-" + deriveItemId(text);
605
499
  }
606
- /** v2.11: Derive a stable criterion ID from its text hash (cr-XXXXXXXX).
607
- * Same hash strategy as SubGoal — deterministic across rounds. */
608
- export function deriveCriterionId(text) {
609
- return "cr-" + deriveItemId(text);
610
- }
611
- /** v2.11: Check whether a user-provided reference looks like a criterion ID.
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) {
616
- return /^cr-[a-f0-9]{8}$/.test(ref);
617
- }
618
- /**
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.
623
- */
624
- function matchSubGoal(target, subGoals, threshold) {
625
- const trimmed = target.trim();
626
- // Phase 1: Exact ID match
627
- for (let i = 0; i < subGoals.length; i++) {
628
- if (subGoals[i].id === trimmed)
629
- return i;
630
- }
631
- // Phase 2: Jaccard fallback on description text
632
- let bestIdx = -1;
633
- let bestScore = 0;
634
- for (let i = 0; i < subGoals.length; i++) {
635
- const score = jaccardSimilarity(trimmed, subGoals[i].description);
636
- if (score > bestScore) {
637
- bestScore = score;
638
- bestIdx = i;
639
- }
640
- }
641
- return bestScore >= threshold ? bestIdx : -1;
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
- }
702
- /**
703
- * Manage the full sub-goal lifecycle across rounds.
704
- *
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.
712
- */
713
- function manageSubGoals(loopId, currentRound, lastRoundResult, vaultContext) {
714
- const policy = getPolicy().evolution;
715
- const allEntries = loopEntries(loopId, vaultContext);
716
- const completedEntries = allEntries
717
- .filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound)
718
- .sort((a, b) => entryRound(a) - entryRound(b));
719
- const subGoals = [];
720
- const createEmerged = (descs, rnd) => {
721
- for (const desc of descs) {
722
- // Dedup: skip if an existing sub-goal is too similar
723
- if (matchSubGoal(desc, subGoals, policy.subgoal_dedup_threshold) >= 0)
724
- continue;
725
- subGoals.push(makeSubGoal({
726
- id: deriveSubGoalId(desc),
727
- description: desc.trim(),
728
- status: "pending",
729
- declared_at_round: rnd,
730
- status_changed_at_round: rnd,
731
- priority: subGoals.length,
732
- }));
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
- }
743
- }
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).
746
- if (lastRoundResult) {
747
- const rnd = lastRoundResult.round || (currentRound - 1);
748
- createEmerged(lastRoundResult.emerged_subtasks ?? [], rnd);
749
- for (const update of lastRoundResult.subgoal_updates ?? []) {
750
- applySubGoalUpdate(subGoals, update, rnd);
751
- }
752
- }
753
- // Sort for display: in_progress first, then pending by age desc, then blocked, then done, then canceled
754
- subGoals.sort((a, b) => {
755
- const rank = (s) => {
756
- switch (s.status) {
757
- case "in_progress": return 0;
758
- case "pending": return 1;
759
- case "blocked": return 2;
760
- case "done": return 3;
761
- case "canceled": return 4;
762
- }
763
- };
764
- const ra = rank(a), rb = rank(b);
765
- if (ra !== rb)
766
- return ra - rb;
767
- // Within same status: pending by oldest first, done by most recent first
768
- // (v2.14: the previous expression simplified to b.declared - a.declared,
769
- // which sorted NEWEST first despite the comment)
770
- if (a.status === "pending")
771
- return a.declared_at_round - b.declared_at_round;
772
- if (a.status === "done")
773
- return (b.completed_at_round ?? 0) - (a.completed_at_round ?? 0);
774
- return a.declared_at_round - b.declared_at_round;
775
- });
776
- return subGoals;
777
- }
500
+ // v3.8.1: `deriveCriterionId` / `isCriterionId` moved to token-utils.ts, where
501
+ // the other stable-id helpers live — the criterion fact derivation in
502
+ // round-facts.ts needs them, and defining them here would make
503
+ // round-facts ⇄ loop-compiler an import cycle. Re-exported so this module's
504
+ // public surface ("./compiler") is unchanged.
505
+ export { deriveCriterionId, isCriterionId } from "./token-utils.js";
778
506
  // ═══════════════════════════════════════════════════════════════════════════
779
507
  // Time-Aware Constraint Management (v2.3)
780
508
  // ═══════════════════════════════════════════════════════════════════════════
@@ -789,19 +517,18 @@ function constraintSource(text, objective, constraintsFromPlan) {
789
517
  return "criteria";
790
518
  return "discovered";
791
519
  }
792
- /** Find the latest round a constraint was violated by scanning vault entries.
793
- * v2.11: ID-first matching. Checks exact constraint ID match (c-XXXXXXXX)
794
- * against the derived ID of each vault entry's constraint_violations, then
795
- * falls back to Jaccard similarity on text. Returns 0 if never violated. */
796
- /** True when a reported violation text matches a constraint text — same
797
- * ID-first + Jaccard fallback matching as findLastViolatedRound. */
520
+ /** True when a reported violation text matches a constraint text.
521
+ * v2.11: ID-first — an exact `c-XXXXXXXX` match against the derived id.
522
+ * v3.8.1: the Jaccard fallback is gone; the remaining arm requires the
523
+ * reported text to be EXACTLY equal to the constraint after normalization,
524
+ * so a reported violation can no longer attach itself to a constraint it
525
+ * merely resembles. */
798
526
  function matchesConstraintText(text, violation) {
799
527
  const targetId = deriveConstraintId(text);
800
528
  if (violation === targetId || deriveConstraintId(violation) === targetId) {
801
529
  return true;
802
530
  }
803
- return jaccardSimilarity(text, violation) >=
804
- getPolicy().evolution.constraint_match_threshold;
531
+ return normalizeText(text) === normalizeText(violation);
805
532
  }
806
533
  function findLastViolatedRound(text, vaultEntries) {
807
534
  let latest = 0;
@@ -853,7 +580,7 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
853
580
  // (its own Success Criteria section and the verification gate's
854
581
  // criteriaMatch are ID-first on cr-). Rendering it under c-XXXXXXXX in
855
582
  // 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
583
+ // and an agent echoing the active-list ID into its criterion claims
857
584
  // could never match.
858
585
  metadata.push(makeConstraintMeta({
859
586
  id: source === "criteria" ? deriveCriterionId(text) : deriveConstraintId(text),
@@ -866,38 +593,6 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
866
593
  }
867
594
  return { active, metadata };
868
595
  }
869
- export function alignTask(proposedTask, request, context) {
870
- const objective = request.loop_objective ?? latestObjective(request.loop_id, context);
871
- if (!objective)
872
- return makeTaskAlignment();
873
- // v2.14: threshold is policy-driven (evolution.progress_mismatch_threshold)
874
- const threshold = getPolicy().evolution.progress_mismatch_threshold;
875
- const score = jaccardSimilarity(proposedTask, [objective.objective, ...objective.success_criteria, ...objective.hard_constraints].join(" "));
876
- return makeTaskAlignment({
877
- is_aligned: score >= threshold,
878
- alignment_score: Number(score.toFixed(2)),
879
- warning: score < threshold ? "Current task may be drifting from the loop objective." : "",
880
- escalation: score < threshold ? "warn" : "none",
881
- });
882
- }
883
- export function checkLoopHealth(loopId, request, context) {
884
- const previous = getPreviousRound(loopId, request.round - 1, context);
885
- const alignment = alignTask(request.task, request, context);
886
- const violations = request.last_round_result?.constraint_violations.length ?? 0;
887
- const integrity = Math.max(0, 1 - violations * 0.2);
888
- const continuity = previous ? jaccardSimilarity(previous.task, request.task) : 1;
889
- // v2.14: thresholds are policy-driven
890
- const drift = alignment.alignment_score < getPolicy().evolution.progress_mismatch_threshold ||
891
- continuity < getPolicy().evolution.task_continuity_threshold;
892
- return makeLoopHealth({
893
- goal_alignment: alignment.alignment_score,
894
- constraint_integrity: integrity,
895
- drift_detected: drift,
896
- strategy_stability: true,
897
- task_continuity: Number(continuity.toFixed(2)),
898
- escalation_recommended: drift || integrity < 0.6 ? "l2" : "none",
899
- });
900
- }
901
596
  function levelDecision(request, context) {
902
597
  const previous = getPreviousRound(request.loop_id, request.round - 1, context);
903
598
  const last = request.last_round_result;
@@ -908,11 +603,6 @@ function levelDecision(request, context) {
908
603
  last?.retracted_constraints?.length ||
909
604
  last?.revised_success_criteria?.length ||
910
605
  last?.wrong_assumptions?.length);
911
- const lastFullRound = loopEntries(request.loop_id, context)
912
- .filter((entry) => entryLineage(entry).recompile_level === "l2")
913
- .map(entryRound)
914
- .filter((round) => round < request.round)
915
- .at(-1) ?? 1;
916
606
  // v2.14: a backtrack committed for this round means the loop is
917
607
  // re-walking restored state — the recovery boundary forces L2
918
608
  // rehydration (the decidePromptLevel recovery_boundary branch existed
@@ -933,14 +623,12 @@ function levelDecision(request, context) {
933
623
  verificationContradicted: (request.verification_flags ?? [])
934
624
  .some((flag) => flag.severity === "error"),
935
625
  consecutiveRejections: request.consecutive_rejections,
936
- fullRefreshInterval: getPolicy().prompt.full_refresh_interval,
937
- lastFullRound,
938
626
  });
939
627
  }
940
628
  export function decideLevel(request, context) {
941
629
  return levelDecision(request, context).level;
942
630
  }
943
- export function buildSelfEvalBlock(round, prevDriftFlags,
631
+ export function buildSelfEvalBlock(round,
944
632
  /** v2.12: L0 is the minimal retry template — the v2.12 declarative fields
945
633
  * (outcome/blocker/retroactiveClaims) are L1/L2 additions so the retry
946
634
  * prompt stays within its tight budget. */
@@ -950,16 +638,7 @@ level,
950
638
  * `activeContract != null`). Only then does the template ask the agent
951
639
  * to restate/propose it — a generic empty contract template would invite
952
640
  * placeholder submissions that trigger round_underspecified noise. */
953
- hasContract = false,
954
- /** v3.5: L2-only prose suggesting a Round Contract declaration when the
955
- * Current Task is NOT one (contract_nudge_on_l2 policy, computed at the
956
- * compileLoop call site). Mutually exclusive with hasContract. The prose
957
- * must never contain the JSON key name `round_contract` — contract-less
958
- * L2 tests assert its lowercase absence. */
959
- proposalNudge = false) {
960
- const hasIntentDrift = prevDriftFlags?.some(f => f.check === "intent_drift") ?? false;
961
- const hasSubGoalDrift = prevDriftFlags?.some(f => f.check === "subgoal_drift") ?? false;
962
- const needsClarification = hasIntentDrift || hasSubGoalDrift;
641
+ hasContract = false) {
963
642
  const declareOutcome = level !== "l0";
964
643
  const restateContract = hasContract && declareOutcome;
965
644
  const evalObj = {
@@ -969,24 +648,31 @@ proposalNudge = false) {
969
648
  should_continue: true,
970
649
  discovered_constraints: ["<new constraint>"],
971
650
  emerged_subtasks: [],
972
- execution_evidence: {
651
+ execution_report: {
973
652
  files_changed: [],
974
- test_results: { passed: 0, failed: 0, skipped: 0 },
975
- success_criteria_met: ["<cr-XXXXXXXX or text>"],
976
- success_criteria_remaining: ["<cr-XXXXXXXX or text>"],
653
+ tests_reported: { passed: 0, failed: 0, skipped: 0 },
654
+ // The contract claim is functional (it is what advances an item), so it
655
+ // stays at every level. The advisory criterion claim is an L1/L2
656
+ // addition — the L0 retry template is a lean delta kept under budget.
657
+ contract_item_claims: [
658
+ { item_id: "<rci-XXXXXXXX from the Current Task>", outcome: "met" },
659
+ ],
977
660
  progress_estimate: 0,
978
661
  },
979
662
  wrong_assumptions: [],
980
- next_action: "<next concrete action, or empty when complete>",
981
663
  subgoal_updates: [
982
664
  { id: "<sg-XXXXXXXX from the Sub-Goal Dashboard>", status: "done" },
983
665
  ],
984
666
  };
985
- // v2.12: Declarative tri-state outcome (L1/L2 only — keeps L0 retry lean)
667
+ // v2.12: Declarative outcome (L1/L2 only — keeps L0 retry lean)
986
668
  if (declareOutcome) {
987
669
  evalObj.outcome = "<success|partial|failed|blocked>";
988
670
  evalObj.blocker = "<required only when outcome=blocked>";
989
671
  evalObj.retroactiveClaims = [];
672
+ // v3.8: the advisory criterion claim rides with the other L1/L2 additions.
673
+ evalObj.execution_report.criterion_claims = [
674
+ { criterion_id: "<cr-XXXXXXXX or text>", outcome: "met" },
675
+ ];
990
676
  }
991
677
  // v3.3/v3.4: Round Contract proposal template (L1/L2, active-contract
992
678
  // rounds only — L0 retry stays lean, and rounds without an active
@@ -1002,26 +688,12 @@ proposalNudge = false) {
1002
688
  scope: [],
1003
689
  };
1004
690
  }
1005
- // v2.8: Inject drift clarification field when previous round had drift flags
1006
- if (needsClarification) {
1007
- const flagTypes = [
1008
- hasIntentDrift ? "intent_drift" : "",
1009
- hasSubGoalDrift ? "subgoal_drift" : "",
1010
- ].filter(Boolean).join(" / ");
1011
- evalObj.drift_clarification =
1012
- `<explain why your Round ${round} actions diverged from your stated plan ` +
1013
- `(${flagTypes} detected in previous round). ` +
1014
- `Leave empty if no divergence occurred>`;
1015
- }
1016
691
  const lines = [
1017
692
  "### LoopForge Evaluation (Required)",
1018
693
  "",
1019
694
  `After completing Round ${round}, replace the placeholder values below`,
1020
695
  "with your actual results and pass this object as `loopforge_next.evaluation`.",
1021
696
  ];
1022
- if (needsClarification) {
1023
- lines.push("", "> ⚠️ **Drift Detected:** The verification gate detected that your previous", "> round's declared intent (next_action) did not match your actual output.", "> Please explain the pivot in the `drift_clarification` field below.", "> An honest explanation helps the enforcement gate distinguish intentional", "> course corrections from unacknowledged drift.");
1024
- }
1025
697
  lines.push("```json");
1026
698
  lines.push(JSON.stringify(evalObj, null, 2));
1027
699
  lines.push("```");
@@ -1031,13 +703,8 @@ proposalNudge = false) {
1031
703
  lines.push("Use IDs for exact matching: c-XXXXXXXX, cr-XXXXXXXX, sg-XXXXXXXX.");
1032
704
  lines.push("Sub-goal changes go through `subgoal_updates` (active sub-goal IDs only;");
1033
705
  lines.push("done/canceled are terminal — reopen via `emerged_subtasks`; bad IDs reject).");
1034
- // v3.5: L2 contract-less nudge (prose only — never the JSON key name, so
1035
- // contract-less rounds keep their template-lean assertions).
1036
- if (proposalNudge) {
1037
- lines.push("", "If the remaining work will span several rounds, consider declaring a", "Round Contract for the next round — list its done_when items, the", "verification_plan commands that will back them, and the scope. This", "keeps each round's boundary machine-checkable.");
1038
- }
1039
706
  if (restateContract) {
1040
- lines.push("Round Contract: restate the Current Task's contract UNCHANGED in", "`round_contract` (work_item/done_when/verification_plan/scope) and run", "its Verify commands; list satisfied done_when items in", "`success_criteria_met`. When the current contract's done_when items are", "all satisfied, or the round is blocked (outcome=blocked + blocker),", "declare the NEXT round's contract instead (or omit `round_contract`", "when the whole task is done — the Current Task reverts to the", "original task).");
707
+ lines.push("Round Contract: restate the Current Task's contract UNCHANGED in", "`round_contract` (work_item/items/scope) and run each item's Verify", "commands; claim the items you completed in", "`execution_report.contract_item_claims` using the `rci-XXXXXXXX` ids", "the Current Task renders. An item only becomes verified when its bound", "command is observed passing — a claim alone leaves it insufficient.", "When every item is verified, or the round is blocked (outcome=blocked", "+ blocker), declare the NEXT round's contract instead (or omit", "`round_contract` when the whole task is done — the Current Task", "reverts to the original task).");
1041
708
  }
1042
709
  return lines.join("\n");
1043
710
  }
@@ -1054,32 +721,22 @@ function committedBacktrackBrief(loopId, round, context) {
1054
721
  const entry = loopEntries(loopId, context).find((e) => entryLineage(e).committed_action === "backtrack" && entryRound(e) === round);
1055
722
  if (!entry)
1056
723
  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;
724
+ // v3.8.1: one decoder for the committed rollback record and one renderer for
725
+ // the brief. The facts ride on the merged lineage entry (engine.ts stamps
726
+ // them from the committed decision) and the same `decodeBacktrackRecord`
727
+ // reads the durable :feedback envelope, so both carriers decode to one shape
728
+ // — and this brief and the rollback prompt no longer render the same
729
+ // rollback with two hand-written line builders.
730
+ const record = decodeBacktrackRecord(entryLineage(entry));
731
+ if (!record)
732
+ return undefined;
733
+ return recoveryBriefLines({
734
+ target: record.target,
735
+ triggerRule: record.triggerRule,
736
+ failedRounds: record.failedRounds,
737
+ approaches: record.approaches,
738
+ wrongAssumptions: record.wrongAssumptions,
739
+ });
1083
740
  }
1084
741
  export function compileLoop(request, context) {
1085
742
  const decision = levelDecision(request, context);
@@ -1087,45 +744,76 @@ export function compileLoop(request, context) {
1087
744
  const objective = evolveObjective(request, context);
1088
745
  const constraints = evolveConstraints(request, objective, previous, context);
1089
746
  const constraintLifecycle = manageConstraintLifecycle(constraints.active, objective, request.constraints_from_plan, context, request.round, request.last_round_result?.constraint_violations ?? []);
1090
- const rolling = buildRollingSummary(request.loop_id, request.round, context, 0, decision.level);
1091
- const subGoals = manageSubGoals(request.loop_id, request.round, request.last_round_result, context);
747
+ const rolling = buildRollingSummary(request.loop_id, request.round, context, 0);
748
+ // v3.8: ONE committed-round derivation feeds every projection below
749
+ // (sub-goals, the active contract, its item statuses, criteria).
750
+ const windowRounds = derivationRounds(loopEntries(request.loop_id, context), request.round);
751
+ // v3.8: sub-goal lifecycle lives in subgoal-state.ts and consumes the
752
+ // shared committed-round view — one history interpretation.
753
+ const subGoals = deriveSubGoals({
754
+ loopId: request.loop_id,
755
+ currentRound: request.round,
756
+ rounds: windowRounds,
757
+ currentReport: request.last_round_result
758
+ ? {
759
+ round: request.last_round_result.round || (request.round - 1),
760
+ emergedSubtasks: request.last_round_result.emerged_subtasks ?? [],
761
+ subgoalUpdates: request.last_round_result.subgoal_updates ?? [],
762
+ }
763
+ : null,
764
+ });
765
+ // v3.4/v3.8/v3.8.1: the ACTIVE Round Contract, its per-item statuses and the
766
+ // verified sub-goal facts for this compile — ONE `deriveRoundFacts` call,
767
+ // the same bundle the projection consumes. Derived from committed rounds, so
768
+ // rejection retries, resume, unpause and backtrack compiles (which carry no
769
+ // last_round_result) keep showing the same contract, and a verified or
770
+ // blocked contract stops being shown. A verified item stays proven after its
771
+ // contract closes because the bundle reads the whole history, not the active
772
+ // contract. The compile path has no in-flight observations of its own.
773
+ const roundFacts = deriveRoundFacts({
774
+ rounds: windowRounds,
775
+ currentRound: request.round,
776
+ inFlight: NO_IN_FLIGHT_ROUND,
777
+ subGoals,
778
+ commands: getPolicy().evidence.commands ?? [],
779
+ });
780
+ // `roundFacts.verifiedSubGoals` is not read here — it reaches the state file
781
+ // and the projection through the bundle itself.
782
+ const { activeContract } = roundFacts;
1092
783
  // v3.2: Goal → criteria → evidence vertical view (derived, zero persistence).
1093
- const criterionStatuses = deriveCriterionStatuses(request.loop_id, context, objective, request.round, subGoals, request.last_round_result);
1094
- // v3.2: Lessons learned — repeated violations / verification failures.
1095
- const lessons = deriveLessons(request.loop_id, context, request.round);
1096
- const alignment = alignTask(request.task, request, context);
1097
- const health = checkLoopHealth(request.loop_id, { ...request, loop_objective: objective }, context);
784
+ const criterionStatuses = deriveCriterionStatuses(request.loop_id, context, objective, request.round, request.last_round_result, roundFacts.criterionFacts);
785
+ // v3.8.1: the ONE recurring-fact derivation, over the shared window. The
786
+ // prompt renders its recent tail; the state file renders the recurring set.
787
+ const recurringFlags = deriveRecurringFlags(windowRounds);
788
+ // v3.8.1: a same-round exact repeat is the only sub-goal duplication the
789
+ // runtime can state as a fact — those entries really were dropped, so the
790
+ // agent is told. The cross-round near-duplicate similarity diagnostic is
791
+ // gone with the rest of the fuzzy matching, and so are the task-alignment
792
+ // and loop-health warnings: both were Jaccard scores over task/objective
793
+ // text, never machine facts.
794
+ const repeatedDeclarations = duplicateEmergedDeclarations(request.last_round_result?.emerged_subtasks ?? []);
1098
795
  const warnings = unique([
1099
- alignment.warning,
1100
- health.escalation_recommended !== "none"
1101
- ? `Loop health recommends ${health.escalation_recommended}.`
796
+ repeatedDeclarations.length > 0
797
+ ? `duplicate_declaration: ${repeatedDeclarations.length} sub-goal ` +
798
+ `declaration(s) repeated an earlier entry in the same round and were ` +
799
+ `not created: ${repeatedDeclarations.join("; ")}`
1102
800
  : "",
1103
801
  ]);
1104
- // ── v2.5: Agent trust score ──────────────────────────────────────────
1105
- const flags = request.verification_flags ?? [];
1106
- const errors = flags.filter((f) => f.severity === "error").length;
1107
- const warns = flags.filter((f) => f.severity === "warn").length;
1108
- const trustScore = Math.max(0, Math.min(1, 1.0 - (errors * 0.15) - (warns * 0.03)));
1109
- // Trend — derive from vault entries (violation counts per round as proxy).
1110
- // No new persistence; reconstructed same as milestones/sub-goals.
802
+ // v3.8.1: the agent trust score and trend are deleted. The score was
803
+ // `1 - errors*0.15 - warns*0.03` — arbitrary weights with no machine
804
+ // meaning, re-encoding flag counts that are already visible. The trend was
805
+ // worse: `1 - violations*0.1` per round, computed from
806
+ // `constraint_violations`, i.e. a DIFFERENT source from the score it was
807
+ // displayed next to. "Trend" was therefore not the score's history at all.
808
+ // Neither fed a decision.
1111
809
  const completedEntries = loopEntries(request.loop_id, context)
1112
810
  .filter((e) => entryRound(e) >= 1 && entryRound(e) < request.round);
1113
- const committedRounds = mergedRoundsFromEntries(completedEntries, request.round);
1114
- const trend = [];
1115
- for (const round of committedRounds.slice(-10)) {
1116
- const viols = round.evaluation?.constraint_violations ?? [];
1117
- // Simple proxy: no violations = perfect trust, each violation = -0.1
1118
- const score = Math.max(0, 1.0 - viols.length * 0.1);
1119
- trend.push(Number(score.toFixed(2)));
1120
- }
1121
- // v3.3: Round stats + machine git-motion status (display-only, derived).
1122
- const roundStats = deriveRoundStats(committedRounds);
811
+ const committedRounds = derivationRounds(completedEntries, request.round);
812
+ // v3.3/v3.8.1: machine git-motion (display-only, derived). The per-round
813
+ // "round stats" table is deleted: its only renderer was the L2 prompt
814
+ // section removed above, so it was computed, put into the canonical state
815
+ // (and therefore into stateHash) and read by nothing.
1123
816
  const machineStatus = deriveMachineStatus(committedRounds, request.round);
1124
- // v3.4: ACTIVE Round Contract for this compile — derived from committed
1125
- // rounds, so rejection retries / resume / unpause / backtrack compiles
1126
- // (no last_round_result) keep showing the contract as Current Task, and a
1127
- // satisfied or blocked contract stops being shown.
1128
- const activeContract = deriveActiveContract(request.loop_id, context, request.round);
1129
817
  const response = makeLoopCompileResponse({
1130
818
  status: AgentStatus.OK,
1131
819
  recompile_level: decision.level,
@@ -1139,16 +827,10 @@ export function compileLoop(request, context) {
1139
827
  goal_id: deriveGoalId(request.loop_id, request.task, request.goal_id),
1140
828
  goal_text_hash: computeGoalTextHash(request.task),
1141
829
  loop_objective: objective,
1142
- loop_health: health,
1143
- task_alignment: alignment,
1144
830
  rolling_summary: rolling,
1145
831
  sub_goals: subGoals,
1146
832
  criterion_statuses: criterionStatuses,
1147
- lessons,
1148
- agent_trust_score: request.round > 1 ? Number(trustScore.toFixed(2)) : undefined,
1149
- agent_trust_trend: request.round > 1 ? trend : [],
1150
- suggested_next_task: request.last_round_result?.next_action?.trim() ||
1151
- request.last_round_result?.emerged_subtasks?.[0] || "",
833
+ recurring_flags: recurringFlags,
1152
834
  plan_source: request.plan_source,
1153
835
  warnings,
1154
836
  });
@@ -1161,10 +843,11 @@ export function compileLoop(request, context) {
1161
843
  ? `${policy.state_file.directory}/${request.loop_id}-state.md`
1162
844
  : "";
1163
845
  const state = createCanonicalLoopState(request, response, statePath, {
1164
- roundStats,
1165
846
  machineStatus,
1166
- // v3.4: derived active contract — the Current Task's source of truth.
1167
- roundContract: activeContract,
847
+ // v3.4/v3.8.1: the contract-fact bundle — the Current Task's source of
848
+ // truth, its verification view and the machine-verified sub-goal facts as
849
+ // ONE derivation. The static capability rides on the state itself.
850
+ roundFacts,
1168
851
  });
1169
852
  const markdown = renderCanonicalStateMarkdown(state, {
1170
853
  // v3.7.1: retry attempts are marked in the derived view (attempt is
@@ -1174,13 +857,12 @@ export function compileLoop(request, context) {
1174
857
  attempt: request.attempt,
1175
858
  recoveryBrief: committedBacktrackBrief(request.loop_id, request.round, context),
1176
859
  });
1177
- // v2.4–v2.5: Adaptive L2 budget — scales with loop complexity
1178
- const adaptiveL2 = policy.prompt.l2_adaptive_enabled
1179
- ? Math.min(policy.prompt.l2_max_chars
1180
- + (request.round * policy.prompt.l2_adaptive_round_factor)
1181
- + ((rolling?.milestones?.length ?? 0) * policy.prompt.l2_adaptive_milestone_factor)
1182
- + (subGoals.length * policy.prompt.l2_adaptive_subgoal_factor), policy.prompt.l2_adaptive_max_chars)
1183
- : policy.prompt.l2_max_chars;
860
+ // v3.8.1: the L2 budget is a FIXED ceiling. The v2.4 adaptive budget
861
+ // scaled it by round count, milestone count and sub-goal count, which made
862
+ // the prompt's allowed content depend on how long the loop had been running
863
+ // rather than on the facts of this round — the same loop could render a
864
+ // section at round 5 and silently drop it at round 50.
865
+ //
1184
866
  // v2.8: L2 pointer mode — when enabled, skip the monolithic markdown blob.
1185
867
  // Structured L2 sections (milestones, sub-goals, trust, progress) still
1186
868
  // render. The state file on disk is the durable source of truth.
@@ -1195,23 +877,17 @@ export function compileLoop(request, context) {
1195
877
  budgets: {
1196
878
  l0: policy.prompt.l0_max_chars,
1197
879
  l1: policy.prompt.l1_max_chars,
1198
- l2: adaptiveL2,
880
+ l2: policy.prompt.l2_max_chars,
1199
881
  },
1200
882
  attempt: request.attempt,
1201
- selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags, decision.level,
883
+ selfEvaluationBlock: buildSelfEvalBlock(request.round, decision.level,
1202
884
  // v3.4: restate the contract only when this prompt's Current Task IS
1203
885
  // the ACTIVE contract (derived — not the previous submission's field,
1204
886
  // which is a proposal and may differ from what this round executes).
1205
- activeContract != null,
1206
- // v3.5: L2-only declaration nudge when nothing is active (policy-gated;
1207
- // mutually exclusive with the restate template above).
1208
- decision.level === "l2" && activeContract === null &&
1209
- getPolicy().prompt.contract_nudge_on_l2),
887
+ activeContract != null),
1210
888
  fullStateMarkdown: l2Pointer ? undefined : markdown,
1211
889
  // v2.9: Model's information needs from the previous round's SelfEvaluation
1212
890
  promptRequests: request.last_round_result?.prompt_requests,
1213
- // v3.2: Previous L1 presentation as the collapse diff baseline.
1214
- presentedBaseline: readPresentedBaseline(request.loop_id, request.round, context),
1215
891
  });
1216
892
  response.prompt = artifact.renderedPrompt;
1217
893
  response.prompt_artifact = artifact;