loopforge 2.11.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +4 -4
  2. package/dist/audit.d.ts +50 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +158 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/canonical-state.d.ts +21 -9
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +34 -16
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +16 -6
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +40 -0
  14. package/dist/cognitive-governance.d.ts.map +1 -0
  15. package/dist/cognitive-governance.js +126 -0
  16. package/dist/cognitive-governance.js.map +1 -0
  17. package/dist/enforcement-gate.d.ts +8 -3
  18. package/dist/enforcement-gate.d.ts.map +1 -1
  19. package/dist/enforcement-gate.js +419 -92
  20. package/dist/enforcement-gate.js.map +1 -1
  21. package/dist/engine.d.ts +47 -7
  22. package/dist/engine.d.ts.map +1 -1
  23. package/dist/engine.js +303 -60
  24. package/dist/engine.js.map +1 -1
  25. package/dist/evidence-claims.d.ts +50 -0
  26. package/dist/evidence-claims.d.ts.map +1 -0
  27. package/dist/evidence-claims.js +134 -0
  28. package/dist/evidence-claims.js.map +1 -0
  29. package/dist/generate-schema.js +6 -1
  30. package/dist/generate-schema.js.map +1 -1
  31. package/dist/index.d.ts +3 -3
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/loop-compiler.d.ts +41 -3
  36. package/dist/loop-compiler.d.ts.map +1 -1
  37. package/dist/loop-compiler.js +310 -38
  38. package/dist/loop-compiler.js.map +1 -1
  39. package/dist/loop-projection.d.ts +26 -0
  40. package/dist/loop-projection.d.ts.map +1 -0
  41. package/dist/loop-projection.js +142 -0
  42. package/dist/loop-projection.js.map +1 -0
  43. package/dist/loop-store.d.ts +91 -41
  44. package/dist/loop-store.d.ts.map +1 -1
  45. package/dist/loop-store.js +243 -173
  46. package/dist/loop-store.js.map +1 -1
  47. package/dist/mcp/round-lifecycle.d.ts +216 -0
  48. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  49. package/dist/mcp/round-lifecycle.js +926 -0
  50. package/dist/mcp/round-lifecycle.js.map +1 -0
  51. package/dist/mcp/server.d.ts +3 -2
  52. package/dist/mcp/server.d.ts.map +1 -1
  53. package/dist/mcp/server.js +65 -9
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/mcp/session.d.ts +47 -139
  56. package/dist/mcp/session.d.ts.map +1 -1
  57. package/dist/mcp/session.js +284 -784
  58. package/dist/mcp/session.js.map +1 -1
  59. package/dist/mcp/tools.d.ts +23 -1
  60. package/dist/mcp/tools.d.ts.map +1 -1
  61. package/dist/mcp/tools.js +270 -79
  62. package/dist/mcp/tools.js.map +1 -1
  63. package/dist/policy-metrics.d.ts +11 -0
  64. package/dist/policy-metrics.d.ts.map +1 -1
  65. package/dist/policy-metrics.js +118 -21
  66. package/dist/policy-metrics.js.map +1 -1
  67. package/dist/policy.d.ts +26 -1
  68. package/dist/policy.d.ts.map +1 -1
  69. package/dist/policy.js +13 -2
  70. package/dist/policy.js.map +1 -1
  71. package/dist/prompt-assembler.d.ts +34 -1
  72. package/dist/prompt-assembler.d.ts.map +1 -1
  73. package/dist/prompt-assembler.js +320 -42
  74. package/dist/prompt-assembler.js.map +1 -1
  75. package/dist/protocol.d.ts +169 -1
  76. package/dist/protocol.d.ts.map +1 -1
  77. package/dist/protocol.js +39 -0
  78. package/dist/protocol.js.map +1 -1
  79. package/dist/replay.d.ts +5 -5
  80. package/dist/replay.d.ts.map +1 -1
  81. package/dist/replay.js +9 -8
  82. package/dist/replay.js.map +1 -1
  83. package/dist/round-coordinator.d.ts +6 -3
  84. package/dist/round-coordinator.d.ts.map +1 -1
  85. package/dist/round-coordinator.js +38 -19
  86. package/dist/round-coordinator.js.map +1 -1
  87. package/dist/round-driver.d.ts +9 -4
  88. package/dist/round-driver.d.ts.map +1 -1
  89. package/dist/round-driver.js +15 -7
  90. package/dist/round-driver.js.map +1 -1
  91. package/dist/round-transaction.d.ts +7 -3
  92. package/dist/round-transaction.d.ts.map +1 -1
  93. package/dist/round-transaction.js +93 -88
  94. package/dist/round-transaction.js.map +1 -1
  95. package/dist/self-eval.d.ts +31 -1
  96. package/dist/self-eval.d.ts.map +1 -1
  97. package/dist/self-eval.js +115 -12
  98. package/dist/self-eval.js.map +1 -1
  99. package/dist/storage.d.ts +3 -4
  100. package/dist/storage.d.ts.map +1 -1
  101. package/dist/storage.js +5 -4
  102. package/dist/storage.js.map +1 -1
  103. package/dist/token-utils.d.ts +20 -7
  104. package/dist/token-utils.d.ts.map +1 -1
  105. package/dist/token-utils.js +68 -9
  106. package/dist/token-utils.js.map +1 -1
  107. package/dist/verification-gate.d.ts +54 -2
  108. package/dist/verification-gate.d.ts.map +1 -1
  109. package/dist/verification-gate.js +433 -25
  110. package/dist/verification-gate.js.map +1 -1
  111. package/loop_policy.json +35 -8
  112. package/package.json +3 -2
  113. package/skills/perception/SKILL.md +24 -6
  114. package/dist/backends/interface.d.ts +0 -39
  115. package/dist/backends/interface.d.ts.map +0 -1
  116. package/dist/backends/interface.js +0 -6
  117. package/dist/backends/interface.js.map +0 -1
@@ -9,7 +9,7 @@ import { AgentStatus, makeLoopCompileResponse, makeLoopHealth, makeLoopObjective
9
9
  import { createCanonicalLoopState, renderCanonicalStateMarkdown, } from "./canonical-state.js";
10
10
  import { assemblePromptArtifact } from "./prompt-assembler.js";
11
11
  import { decidePromptLevel, } from "./prompt-policy.js";
12
- import { jaccardSimilarity, unique, entryRound } from "./token-utils.js";
12
+ import { deriveItemId, jaccardSimilarity, unique, entryRound } from "./token-utils.js";
13
13
  function contextEntries(context) {
14
14
  if (!context || !Array.isArray(context.results))
15
15
  return [];
@@ -36,6 +36,34 @@ export function deriveGoalId(loopId, task, explicit = "") {
36
36
  .replace(/^-|-$/g, "")
37
37
  .slice(0, 48) || computeGoalTextHash(task)}`;
38
38
  }
39
+ /** v3.2: Read the previous round's persisted L1 presentation snapshot — the
40
+ * diff baseline for L1 collapse. Returns null when the previous round's
41
+ * lineage entry exists but lacks all three presented_* fields (old-format
42
+ * entries, L0/L2 compiles) — callers then render in full, which keeps
43
+ * existing fixtures and pre-v3.2 loops on the full-render path. */
44
+ export function readPresentedBaseline(loopId, round, context) {
45
+ const entry = [...loopEntries(loopId, context)].reverse()
46
+ .find((candidate) => entryRound(candidate) === round - 1);
47
+ if (!entry)
48
+ return null;
49
+ const data = lineage(entry);
50
+ const constraintIds = data.presented_constraint_ids;
51
+ const subGoals = data.presented_subgoals;
52
+ const milestoneRanges = data.presented_milestone_ranges;
53
+ if (!Array.isArray(constraintIds) || !constraintIds.every((v) => typeof v === "string") ||
54
+ !Array.isArray(subGoals) || !subGoals.every((v) => Array.isArray(v) && v.length === 2 &&
55
+ typeof v[0] === "string" && typeof v[1] === "string") ||
56
+ !Array.isArray(milestoneRanges) || !milestoneRanges.every((v) => Array.isArray(v) && v.length === 2 &&
57
+ typeof v[0] === "number" && typeof v[1] === "number")) {
58
+ return null;
59
+ }
60
+ return {
61
+ round: round - 1,
62
+ constraintIds: constraintIds,
63
+ subGoals: subGoals,
64
+ milestoneRanges: milestoneRanges,
65
+ };
66
+ }
39
67
  export function getPreviousRound(loopId, round, context) {
40
68
  const entry = [...loopEntries(loopId, context)].reverse()
41
69
  .find((candidate) => entryRound(candidate) === round);
@@ -115,19 +143,51 @@ function evolveObjective(request, context) {
115
143
  loop_id: request.loop_id,
116
144
  });
117
145
  }
118
- function evolveConstraints(request, objective, previous) {
146
+ function evolveConstraints(request, objective, previous, context) {
119
147
  const retired = unique(request.last_round_result?.retracted_constraints ?? []);
120
148
  const retiredSet = new Set(retired);
121
149
  const discovered = unique(request.last_round_result?.discovered_constraints ?? [])
122
150
  .slice(0, getPolicy().evolution.max_discovered_constraints_per_round);
151
+ // v2.14: agent-declared retractions of plan/hard/criteria constraints
152
+ // persist for constraints.retire_window rounds (they are re-merged from
153
+ // the request/objective every round, so without this the documented
154
+ // "Removed from the active constraint set" never held for them). The
155
+ // retraction rounds are derived from vault entries — no new persistence
156
+ // format. After the window the constraint returns (hard/plan/criteria
157
+ // constraints never auto-decay — only agent action removes them, and the
158
+ // action's memory expires).
159
+ const window = getPolicy().constraints.retire_window;
160
+ const lastRetractedRound = new Map();
161
+ if (window > 0) {
162
+ for (const entry of loopEntries(request.loop_id, context)) {
163
+ const retractedList = lineage(entry).retracted_constraints;
164
+ if (!Array.isArray(retractedList))
165
+ continue;
166
+ const rnd = entryRound(entry);
167
+ for (const text of retractedList) {
168
+ if (typeof text !== "string" || !text)
169
+ continue;
170
+ const prev = lastRetractedRound.get(text);
171
+ if (prev === undefined || rnd > prev)
172
+ lastRetractedRound.set(text, rnd);
173
+ }
174
+ }
175
+ }
123
176
  const active = unique([
124
177
  ...(previous?.constraints_active ?? []),
125
178
  ...request.constraints_from_plan,
126
179
  ...objective.hard_constraints,
127
180
  ...objective.success_criteria,
128
181
  ...discovered,
129
- ]).filter((item) => !retiredSet.has(item))
130
- .slice(0, getPolicy().evolution.max_active_constraints);
182
+ ]).filter((item) => {
183
+ if (retiredSet.has(item))
184
+ return false;
185
+ const retractedRound = lastRetractedRound.get(item);
186
+ if (retractedRound !== undefined && request.round - retractedRound < window) {
187
+ return false;
188
+ }
189
+ return true;
190
+ }).slice(0, getPolicy().evolution.max_active_constraints);
131
191
  return { active, retired };
132
192
  }
133
193
  // ═══════════════════════════════════════════════════════════════════════════
@@ -155,6 +215,112 @@ function entryCriteriaMet(entry) {
155
215
  const arr = ev.success_criteria_met;
156
216
  return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
157
217
  }
218
+ /** Read success_criteria_remaining from an entry's execution_evidence. */
219
+ function entryCriteriaRemaining(entry) {
220
+ const ev = entryExecutionEvidence(entry);
221
+ if (!ev)
222
+ return [];
223
+ const arr = ev.success_criteria_remaining;
224
+ return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
225
+ }
226
+ /** v3.2: Deterministic lessons learned — constraints violated repeatedly or
227
+ * verification checks failing repeatedly across rounds (full history, unlike
228
+ * the enforcement gate's R2 3-round window). Presentation only: the output
229
+ * never feeds enforcement decisions. */
230
+ export function deriveLessons(loopId, context, currentRound) {
231
+ const entries = loopEntries(loopId, context).filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
232
+ const violations = new Map();
233
+ const errors = new Map();
234
+ const warns = new Map();
235
+ for (const entry of entries) {
236
+ const rnd = entryRound(entry);
237
+ const viols = Array.isArray(entry.constraint_violations)
238
+ ? entry.constraint_violations.filter((v) => typeof v === "string")
239
+ : [];
240
+ for (const text of viols) {
241
+ const list = violations.get(text) ?? [];
242
+ list.push(rnd);
243
+ violations.set(text, list);
244
+ }
245
+ const flags = Array.isArray(entry.verification_flags)
246
+ ? entry.verification_flags
247
+ : [];
248
+ for (const flag of flags) {
249
+ const target = flag.severity === "error"
250
+ ? errors
251
+ : flag.severity === "warn" ? warns : null;
252
+ if (!target)
253
+ continue;
254
+ const list = target.get(flag.check) ?? [];
255
+ list.push(rnd);
256
+ target.set(flag.check, list);
257
+ }
258
+ }
259
+ const lessons = [];
260
+ for (const [text, rounds] of violations) {
261
+ if (rounds.length >= 2) {
262
+ lessons.push({ text, kind: "constraint_violation", count: rounds.length, rounds });
263
+ }
264
+ }
265
+ for (const [text, rounds] of errors) {
266
+ if (rounds.length >= 2) {
267
+ lessons.push({ text, kind: "verification_error", count: rounds.length, rounds });
268
+ }
269
+ }
270
+ for (const [text, rounds] of warns) {
271
+ if (rounds.length >= 2) {
272
+ lessons.push({ text, kind: "verification_warning", count: rounds.length, rounds });
273
+ }
274
+ }
275
+ return lessons.sort((a, b) => b.count - a.count);
276
+ }
277
+ /** v3.2: Derive per-criterion status — the "goal → criteria → evidence"
278
+ * vertical view. Each objective criterion gets: met/remaining/unknown
279
+ * (from per-round success_criteria_met/remaining reports, ID-first
280
+ * matching), the round it was first reported met, and any sub-goals whose
281
+ * description matches it (Jaccard). Zero persistence — re-derived from the
282
+ * vault every compile. */
283
+ export function deriveCriterionStatuses(loopId, context, objective, currentRound, subGoals, lastRoundResult) {
284
+ if (!objective || objective.success_criteria.length === 0)
285
+ return [];
286
+ const entries = loopEntries(loopId, context).filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
287
+ // The current round's report lives in last_round_result (the vault only
288
+ // gains it on commit); treat it as the report for round currentRound - 1.
289
+ const lastEv = lastRoundResult?.execution_evidence;
290
+ const lastMet = lastEv?.success_criteria_met ?? [];
291
+ const lastRemaining = lastEv?.success_criteria_remaining ?? [];
292
+ const lastEntry = entries[entries.length - 1] ?? null;
293
+ const lastEntryRemaining = lastEntry ? entryCriteriaRemaining(lastEntry) : [];
294
+ const matchThreshold = getPolicy().evolution.subgoal_match_threshold;
295
+ return objective.success_criteria.map((text) => {
296
+ let metAtRound = null;
297
+ for (const entry of entries) {
298
+ const rnd = entryRound(entry);
299
+ if (entryCriteriaMet(entry).some((met) => criteriaMatch(met, text))) {
300
+ metAtRound = rnd;
301
+ break; // first report wins — chronological scan
302
+ }
303
+ }
304
+ if (metAtRound === null && lastMet.some((met) => criteriaMatch(met, text))) {
305
+ metAtRound = currentRound - 1;
306
+ }
307
+ const status = metAtRound !== null
308
+ ? "met"
309
+ : lastRemaining.some((remaining) => criteriaMatch(remaining, text)) ||
310
+ lastEntryRemaining.some((remaining) => criteriaMatch(remaining, text))
311
+ ? "remaining"
312
+ : "unknown";
313
+ return {
314
+ id: deriveCriterionId(text),
315
+ text,
316
+ status,
317
+ ...(metAtRound !== null ? { met_at_round: metAtRound } : {}),
318
+ related_subgoal_ids: subGoals
319
+ .filter((sg) => jaccardSimilarity(text, sg.description) >= matchThreshold)
320
+ .map((sg) => sg.id),
321
+ };
322
+ });
323
+ }
158
324
  /** v2.11: Match two criterion references for deduplication.
159
325
  * If either is a criterion ID (cr-XXXXXXXX), uses exact ID comparison.
160
326
  * Otherwise falls back to Jaccard similarity. */
@@ -240,7 +406,56 @@ function buildMilestoneFromEntries(phaseEntries, startRound, endRound, label, ki
240
406
  generated_at_round: endRound,
241
407
  });
242
408
  }
243
- export function buildRollingSummary(loopId, currentRound, context, sinceRound = 0) {
409
+ /** v3.0.1: Evenly sample `budget` entries from `mid`, preferring
410
+ * agent_declared milestones (human/agent-labeled anchors) first and filling
411
+ * the remainder at even time positions. The caller guarantees
412
+ * `mid.length > budget`. */
413
+ function sampleMiddleMilestones(mid, budget) {
414
+ if (budget <= 0)
415
+ return [];
416
+ if (mid.length <= budget)
417
+ return mid;
418
+ const chosen = [];
419
+ const used = new Set();
420
+ for (const m of mid) {
421
+ if (m.kind === "agent_declared") {
422
+ chosen.push(m);
423
+ used.add(m);
424
+ if (chosen.length >= budget)
425
+ return chosen;
426
+ }
427
+ }
428
+ const rest = mid.filter((m) => !used.has(m));
429
+ const need = budget - chosen.length;
430
+ for (let i = 0; i < need; i++) {
431
+ const idx = Math.floor(((i + 0.5) * rest.length) / need);
432
+ chosen.push(rest[idx]);
433
+ }
434
+ return chosen;
435
+ }
436
+ /** v3.0.1: L1 milestone sampling — when milestones exceed max_milestones,
437
+ * keep the oldest `milestone_head_count` (history anchors), the newest
438
+ * `milestone_tail_count` (current progress), and an even sample of the
439
+ * middle. L2 (full rehydration) is never sampled: the recovery view keeps
440
+ * every milestone as the loop's memory skeleton. */
441
+ function sampleMilestones(milestones, policy, level) {
442
+ if (level === "l2")
443
+ return milestones;
444
+ if (milestones.length <= policy.max_milestones)
445
+ return milestones;
446
+ const head = Math.max(0, policy.milestone_head_count);
447
+ const tail = Math.max(0, policy.milestone_tail_count);
448
+ const midBudget = Math.max(0, policy.max_milestones - head - tail);
449
+ if (midBudget === 0)
450
+ return milestones.slice(0, head).concat(milestones.slice(milestones.length - tail));
451
+ const mid = milestones.slice(head, milestones.length - tail);
452
+ return [
453
+ ...milestones.slice(0, head),
454
+ ...sampleMiddleMilestones(mid, midBudget),
455
+ ...milestones.slice(milestones.length - tail),
456
+ ];
457
+ }
458
+ export function buildRollingSummary(loopId, currentRound, context, sinceRound = 0, level) {
244
459
  const policy = getPolicy().summary;
245
460
  const allEntries = loopEntries(loopId, context);
246
461
  // ── Phase 1: Recent Window (preserves existing behavior) ──
@@ -259,8 +474,15 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
259
474
  const summary = typeof entry.output_summary === "string"
260
475
  ? entry.output_summary
261
476
  : typeof data.output_summary === "string" ? data.output_summary : "";
477
+ // v2.12: Declared outcome wins the label; the default wording stays
478
+ // "accepted" for zero regression on existing loop output.
479
+ const declared = entry.outcome;
480
+ const outcomeLabel = declared === "success" || declared === "partial" ||
481
+ declared === "failed" || declared === "blocked"
482
+ ? declared
483
+ : success === false ? "failed" : "accepted";
262
484
  if (summary)
263
- outcomes.push(`[R${round}] ${success === false ? "failed" : "accepted"}: ${summary}`);
485
+ outcomes.push(`[R${round}] ${outcomeLabel}: ${summary}`);
264
486
  const violations = Array.isArray(entry.constraint_violations)
265
487
  ? entry.constraint_violations
266
488
  : Array.isArray(data.constraint_violations) ? data.constraint_violations : [];
@@ -314,7 +536,10 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
314
536
  milestones.push(buildMilestoneFromEntries(phaseEntries, startRound, endRound, `Rounds ${startRound}–${endRound}`, "auto"));
315
537
  }
316
538
  }
317
- const cappedMilestones = milestones.slice(-policy.max_milestones);
539
+ // v3.0.1: L1 keeps head anchors + newest tail + an even middle sample;
540
+ // L2 (full rehydration) keeps every milestone — the recovery view is the
541
+ // loop's memory skeleton and must not be truncated.
542
+ const cappedMilestones = sampleMilestones(milestones, policy, level);
318
543
  // ── Phase 3: Loop Synthesis (formulaic) ──
319
544
  let loopSynthesis = "";
320
545
  if (policy.enable_loop_synthesis && currentRound > 1) {
@@ -347,28 +572,29 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
347
572
  // ═══════════════════════════════════════════════════════════════════════════
348
573
  // Sub-Goal Structured Tracking (v2.2)
349
574
  // ═══════════════════════════════════════════════════════════════════════════
350
- /** Derive a stable sub-goal ID from its description hash. */
351
- function deriveSubGoalId(description) {
352
- return "sg-" + computeGoalTextHash(description).slice(0, 8);
575
+ /** Derive a stable sub-goal ID from its description hash.
576
+ * Exported as the single source of truth (verification-gate imports it). */
577
+ export function deriveSubGoalId(description) {
578
+ return "sg-" + deriveItemId(description);
353
579
  }
354
580
  /** v2.11: Derive a stable constraint ID from its text hash (c-XXXXXXXX).
355
581
  * Same hash strategy as SubGoal — deterministic across rounds. */
356
- function deriveConstraintId(text) {
357
- return "c-" + computeGoalTextHash(text).slice(0, 8);
582
+ export function deriveConstraintId(text) {
583
+ return "c-" + deriveItemId(text);
358
584
  }
359
585
  /** v2.11: Derive a stable criterion ID from its text hash (cr-XXXXXXXX).
360
586
  * Same hash strategy as SubGoal — deterministic across rounds. */
361
- function deriveCriterionId(text) {
362
- return "cr-" + computeGoalTextHash(text).slice(0, 8);
587
+ export function deriveCriterionId(text) {
588
+ return "cr-" + deriveItemId(text);
363
589
  }
364
590
  /** v2.11: Check whether a user-provided reference looks like a constraint ID.
365
591
  * Matches the pattern c-XXXXXXXX where X is a hex digit. */
366
- function isConstraintId(ref) {
592
+ export function isConstraintId(ref) {
367
593
  return /^c-[a-f0-9]{8}$/.test(ref);
368
594
  }
369
595
  /** v2.11: Check whether a user-provided reference looks like a criterion ID.
370
596
  * Matches the pattern cr-XXXXXXXX where X is a hex digit. */
371
- function isCriterionId(ref) {
597
+ export function isCriterionId(ref) {
372
598
  return /^cr-[a-f0-9]{8}$/.test(ref);
373
599
  }
374
600
  /** Read emerged_subtasks from a vault entry (handles direct + lineage nesting). */
@@ -555,8 +781,10 @@ function manageSubGoals(loopId, currentRound, lastRoundResult, vaultContext) {
555
781
  if (ra !== rb)
556
782
  return ra - rb;
557
783
  // Within same status: pending by oldest first, done by most recent first
784
+ // (v2.14: the previous expression simplified to b.declared - a.declared,
785
+ // which sorted NEWEST first despite the comment)
558
786
  if (a.status === "pending")
559
- return (currentRound - a.declared_at_round) - (currentRound - b.declared_at_round);
787
+ return a.declared_at_round - b.declared_at_round;
560
788
  if (a.status === "done")
561
789
  return (b.completed_at_round ?? 0) - (a.completed_at_round ?? 0);
562
790
  return a.declared_at_round - b.declared_at_round;
@@ -612,8 +840,17 @@ function findDiscoveredRound(text, vaultEntries) {
612
840
  * v2.11: ID-first matching. Checks exact constraint ID match (c-XXXXXXXX)
613
841
  * against the derived ID of each vault entry's constraint_violations, then
614
842
  * falls back to Jaccard similarity on text. Returns 0 if never violated. */
615
- function findLastViolatedRound(text, vaultEntries) {
843
+ /** True when a reported violation text matches a constraint text — same
844
+ * ID-first + Jaccard fallback matching as findLastViolatedRound. */
845
+ function matchesConstraintText(text, violation) {
616
846
  const targetId = deriveConstraintId(text);
847
+ if (violation === targetId || deriveConstraintId(violation) === targetId) {
848
+ return true;
849
+ }
850
+ return jaccardSimilarity(text, violation) >=
851
+ getPolicy().evolution.constraint_match_threshold;
852
+ }
853
+ function findLastViolatedRound(text, vaultEntries) {
617
854
  let latest = 0;
618
855
  for (const entry of vaultEntries) {
619
856
  const rnd = entryRound(entry);
@@ -623,14 +860,7 @@ function findLastViolatedRound(text, vaultEntries) {
623
860
  ? entry.constraint_violations.filter((v) => typeof v === "string")
624
861
  : [];
625
862
  for (const v of violations) {
626
- // Phase 1: exact ID match (v2.11)
627
- if (v === targetId || deriveConstraintId(v) === targetId) {
628
- if (rnd > latest)
629
- latest = rnd;
630
- break;
631
- }
632
- // Phase 2: Jaccard fallback (backward compatibility)
633
- if (jaccardSimilarity(text, v) >= getPolicy().evolution.constraint_match_threshold) {
863
+ if (matchesConstraintText(text, v)) {
634
864
  if (rnd > latest)
635
865
  latest = rnd;
636
866
  break;
@@ -647,20 +877,29 @@ function findLastViolatedRound(text, vaultEntries) {
647
877
  * constraints never auto-decay. Inactive constraints are auto-reactivated
648
878
  * if violated again in the current round.
649
879
  */
650
- function manageConstraintLifecycle(active, objective, constraintsFromPlan, vaultContext, currentRound) {
880
+ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vaultContext, currentRound, lastRoundViolations = []) {
651
881
  const policy = getPolicy().evolution;
652
882
  const allEntries = loopEntries(objective.loop_id || "", vaultContext);
653
883
  const completedEntries = allEntries.filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound);
654
884
  const metadata = [];
655
885
  for (const text of active) {
656
886
  const source = constraintSource(text, objective, constraintsFromPlan);
887
+ // v3.2.1: a violation reported in THIS round (last_round_result) counts
888
+ // as a violation at currentRound — the previous rounds' scan cannot see
889
+ // it (this round is not committed yet). This makes the L1 collapse
890
+ // render "(violated this round)" (diffConstraints compares
891
+ // last_violated_at_round === round) and re-activates decayed
892
+ // discovered constraints.
893
+ const violatedThisRound = lastRoundViolations.some((v) => matchesConstraintText(text, v));
657
894
  // Structural constraints never decay
658
895
  if (source === "hard" || source === "plan" || source === "criteria") {
659
896
  metadata.push(makeConstraintMeta({
660
897
  id: deriveConstraintId(text),
661
898
  text,
662
899
  discovered_at_round: objective.created_at_round || 1,
663
- last_violated_at_round: findLastViolatedRound(text, completedEntries),
900
+ last_violated_at_round: violatedThisRound
901
+ ? currentRound
902
+ : findLastViolatedRound(text, completedEntries),
664
903
  source,
665
904
  status: "active",
666
905
  }));
@@ -668,7 +907,9 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
668
907
  }
669
908
  // Discovered constraints: check for inactivity
670
909
  const discoveredAt = findDiscoveredRound(text, completedEntries);
671
- const lastViolated = findLastViolatedRound(text, completedEntries);
910
+ const lastViolated = violatedThisRound
911
+ ? currentRound
912
+ : findLastViolatedRound(text, completedEntries);
672
913
  const roundsSinceRelevant = lastViolated > 0
673
914
  ? currentRound - lastViolated
674
915
  : currentRound - (discoveredAt || currentRound);
@@ -696,12 +937,14 @@ export function alignTask(proposedTask, request, context) {
696
937
  const objective = request.loop_objective ?? latestObjective(request.loop_id, context);
697
938
  if (!objective)
698
939
  return makeTaskAlignment();
940
+ // v2.14: threshold is policy-driven (evolution.progress_mismatch_threshold)
941
+ const threshold = getPolicy().evolution.progress_mismatch_threshold;
699
942
  const score = jaccardSimilarity(proposedTask, [objective.objective, ...objective.success_criteria, ...objective.hard_constraints].join(" "));
700
943
  return makeTaskAlignment({
701
- is_aligned: score >= 0.3,
944
+ is_aligned: score >= threshold,
702
945
  alignment_score: Number(score.toFixed(2)),
703
- warning: score < 0.3 ? "Current task may be drifting from the loop objective." : "",
704
- escalation: score < 0.3 ? "warn" : "none",
946
+ warning: score < threshold ? "Current task may be drifting from the loop objective." : "",
947
+ escalation: score < threshold ? "warn" : "none",
705
948
  });
706
949
  }
707
950
  export function checkLoopHealth(loopId, request, context) {
@@ -710,7 +953,9 @@ export function checkLoopHealth(loopId, request, context) {
710
953
  const violations = request.last_round_result?.constraint_violations.length ?? 0;
711
954
  const integrity = Math.max(0, 1 - violations * 0.2);
712
955
  const continuity = previous ? jaccardSimilarity(previous.task, request.task) : 1;
713
- const drift = alignment.alignment_score < 0.3 || continuity < 0.2;
956
+ // v2.14: thresholds are policy-driven
957
+ const drift = alignment.alignment_score < getPolicy().evolution.progress_mismatch_threshold ||
958
+ continuity < getPolicy().evolution.task_continuity_threshold;
714
959
  return makeLoopHealth({
715
960
  goal_alignment: alignment.alignment_score,
716
961
  constraint_integrity: integrity,
@@ -735,11 +980,19 @@ function levelDecision(request, context) {
735
980
  .map(entryRound)
736
981
  .filter((round) => round < request.round)
737
982
  .at(-1) ?? 1;
983
+ // v2.14: a backtrack committed for this round means the loop is
984
+ // re-walking restored state — the recovery boundary forces L2
985
+ // rehydration (the decidePromptLevel recovery_boundary branch existed
986
+ // but no caller ever set it).
987
+ const recoveryBoundary = loopEntries(request.loop_id, context)
988
+ .some((entry) => lineage(entry).committed_action === "backtrack" &&
989
+ entryRound(entry) === request.round);
738
990
  return decidePromptLevel({
739
991
  round: request.round,
740
992
  attempt: request.attempt,
741
993
  forceLevel: request.force_level,
742
994
  hasPlanSource: Boolean(request.plan_source),
995
+ recoveryBoundary,
743
996
  checkpointBoundary: last?.compression_checkpoint === true,
744
997
  goalChanged: previous !== null && previous.goal_id !== deriveGoalId(request.loop_id, request.task, request.goal_id),
745
998
  previousStateMissing: request.round > 1 && previous === null,
@@ -754,10 +1007,15 @@ function levelDecision(request, context) {
754
1007
  export function decideLevel(request, context) {
755
1008
  return levelDecision(request, context).level;
756
1009
  }
757
- export function buildSelfEvalBlock(round, prevDriftFlags) {
1010
+ export function buildSelfEvalBlock(round, prevDriftFlags,
1011
+ /** v2.12: L0 is the minimal retry template — the v2.12 declarative fields
1012
+ * (outcome/blocker/retroactiveClaims) are L1/L2 additions so the retry
1013
+ * prompt stays within its tight budget. */
1014
+ level) {
758
1015
  const hasIntentDrift = prevDriftFlags?.some(f => f.check === "intent_drift") ?? false;
759
1016
  const hasSubGoalDrift = prevDriftFlags?.some(f => f.check === "subgoal_drift") ?? false;
760
1017
  const needsClarification = hasIntentDrift || hasSubGoalDrift;
1018
+ const declareOutcome = level !== "l0";
761
1019
  const evalObj = {
762
1020
  success: false,
763
1021
  output_summary: `<verified result of round ${round}>`,
@@ -778,6 +1036,12 @@ export function buildSelfEvalBlock(round, prevDriftFlags) {
778
1036
  blocked_subtasks: ["<sg-XXXXXXXX or text>"],
779
1037
  canceled_subtasks: ["<sg-XXXXXXXX or text>"],
780
1038
  };
1039
+ // v2.12: Declarative tri-state outcome (L1/L2 only — keeps L0 retry lean)
1040
+ if (declareOutcome) {
1041
+ evalObj.outcome = "<success|partial|failed|blocked>";
1042
+ evalObj.blocker = "<required only when outcome=blocked>";
1043
+ evalObj.retroactiveClaims = [];
1044
+ }
781
1045
  // v2.8: Inject drift clarification field when previous round had drift flags
782
1046
  if (needsClarification) {
783
1047
  const flagTypes = [
@@ -815,10 +1079,14 @@ export function compileLoop(request, context) {
815
1079
  const decision = levelDecision(request, context);
816
1080
  const previous = getPreviousRound(request.loop_id, request.round - 1, context);
817
1081
  const objective = evolveObjective(request, context);
818
- const constraints = evolveConstraints(request, objective, previous);
819
- const constraintLifecycle = manageConstraintLifecycle(constraints.active, objective, request.constraints_from_plan, context, request.round);
820
- const rolling = buildRollingSummary(request.loop_id, request.round, context);
1082
+ const constraints = evolveConstraints(request, objective, previous, context);
1083
+ const constraintLifecycle = manageConstraintLifecycle(constraints.active, objective, request.constraints_from_plan, context, request.round, request.last_round_result?.constraint_violations ?? []);
1084
+ const rolling = buildRollingSummary(request.loop_id, request.round, context, 0, decision.level);
821
1085
  const subGoals = manageSubGoals(request.loop_id, request.round, request.last_round_result, context);
1086
+ // v3.2: Goal → criteria → evidence vertical view (derived, zero persistence).
1087
+ const criterionStatuses = deriveCriterionStatuses(request.loop_id, context, objective, request.round, subGoals, request.last_round_result);
1088
+ // v3.2: Lessons learned — repeated violations / verification failures.
1089
+ const lessons = deriveLessons(request.loop_id, context, request.round);
822
1090
  const alignment = alignTask(request.task, request, context);
823
1091
  const health = checkLoopHealth(request.loop_id, { ...request, loop_objective: objective }, context);
824
1092
  const warnings = unique([
@@ -863,6 +1131,8 @@ export function compileLoop(request, context) {
863
1131
  task_alignment: alignment,
864
1132
  rolling_summary: rolling,
865
1133
  sub_goals: subGoals,
1134
+ criterion_statuses: criterionStatuses,
1135
+ lessons,
866
1136
  agent_trust_score: request.round > 1 ? Number(trustScore.toFixed(2)) : undefined,
867
1137
  agent_trust_trend: request.round > 1 ? trend : [],
868
1138
  suggested_next_task: request.last_round_result?.next_action?.trim() ||
@@ -896,10 +1166,12 @@ export function compileLoop(request, context) {
896
1166
  l2: adaptiveL2,
897
1167
  },
898
1168
  attempt: request.attempt,
899
- selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags),
1169
+ selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags, decision.level),
900
1170
  fullStateMarkdown: l2Pointer ? undefined : markdown,
901
1171
  // v2.9: Model's information needs from the previous round's SelfEvaluation
902
1172
  promptRequests: request.last_round_result?.prompt_requests,
1173
+ // v3.2: Previous L1 presentation as the collapse diff baseline.
1174
+ presentedBaseline: readPresentedBaseline(request.loop_id, request.round, context),
903
1175
  });
904
1176
  response.prompt = artifact.renderedPrompt;
905
1177
  response.prompt_artifact = artifact;