loopforge 3.2.1 → 3.6.0

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 (112) hide show
  1. package/README.md +67 -67
  2. package/dist/audit.d.ts.map +1 -1
  3. package/dist/audit.js +7 -34
  4. package/dist/audit.js.map +1 -1
  5. package/dist/canonical-state.d.ts +71 -4
  6. package/dist/canonical-state.d.ts.map +1 -1
  7. package/dist/canonical-state.js +176 -9
  8. package/dist/canonical-state.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +16 -18
  11. package/dist/cli.js.map +1 -1
  12. package/dist/cognitive-facts.d.ts +17 -0
  13. package/dist/cognitive-facts.d.ts.map +1 -0
  14. package/dist/cognitive-facts.js +99 -0
  15. package/dist/cognitive-facts.js.map +1 -0
  16. package/dist/committed-round.d.ts +59 -0
  17. package/dist/committed-round.d.ts.map +1 -0
  18. package/dist/committed-round.js +222 -0
  19. package/dist/committed-round.js.map +1 -0
  20. package/dist/enforcement-gate.d.ts +4 -2
  21. package/dist/enforcement-gate.d.ts.map +1 -1
  22. package/dist/enforcement-gate.js +339 -301
  23. package/dist/enforcement-gate.js.map +1 -1
  24. package/dist/engine.d.ts +16 -4
  25. package/dist/engine.d.ts.map +1 -1
  26. package/dist/engine.js +63 -15
  27. package/dist/engine.js.map +1 -1
  28. package/dist/evidence-claims.d.ts.map +1 -1
  29. package/dist/evidence-claims.js +21 -39
  30. package/dist/evidence-claims.js.map +1 -1
  31. package/dist/evidence-provider.d.ts +29 -0
  32. package/dist/evidence-provider.d.ts.map +1 -1
  33. package/dist/evidence-provider.js +93 -16
  34. package/dist/evidence-provider.js.map +1 -1
  35. package/dist/index.d.ts +3 -3
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/loop-compiler.d.ts +21 -4
  40. package/dist/loop-compiler.d.ts.map +1 -1
  41. package/dist/loop-compiler.js +244 -66
  42. package/dist/loop-compiler.js.map +1 -1
  43. package/dist/loop-projection.d.ts +5 -25
  44. package/dist/loop-projection.d.ts.map +1 -1
  45. package/dist/loop-projection.js +18 -134
  46. package/dist/loop-projection.js.map +1 -1
  47. package/dist/loop-store.d.ts +5 -2
  48. package/dist/loop-store.d.ts.map +1 -1
  49. package/dist/loop-store.js.map +1 -1
  50. package/dist/mcp/round-lifecycle.d.ts +6 -8
  51. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  52. package/dist/mcp/round-lifecycle.js +61 -52
  53. package/dist/mcp/round-lifecycle.js.map +1 -1
  54. package/dist/mcp/server.js +3 -3
  55. package/dist/mcp/server.js.map +1 -1
  56. package/dist/mcp/session.d.ts +14 -3
  57. package/dist/mcp/session.d.ts.map +1 -1
  58. package/dist/mcp/session.js +174 -52
  59. package/dist/mcp/session.js.map +1 -1
  60. package/dist/mcp/tools.d.ts +12 -0
  61. package/dist/mcp/tools.d.ts.map +1 -1
  62. package/dist/mcp/tools.js +160 -52
  63. package/dist/mcp/tools.js.map +1 -1
  64. package/dist/policy-metrics.d.ts +0 -7
  65. package/dist/policy-metrics.d.ts.map +1 -1
  66. package/dist/policy-metrics.js +15 -55
  67. package/dist/policy-metrics.js.map +1 -1
  68. package/dist/policy.d.ts +22 -20
  69. package/dist/policy.d.ts.map +1 -1
  70. package/dist/policy.js +24 -38
  71. package/dist/policy.js.map +1 -1
  72. package/dist/prompt-assembler.d.ts.map +1 -1
  73. package/dist/prompt-assembler.js +120 -140
  74. package/dist/prompt-assembler.js.map +1 -1
  75. package/dist/protocol.d.ts +57 -23
  76. package/dist/protocol.d.ts.map +1 -1
  77. package/dist/protocol.js +10 -4
  78. package/dist/protocol.js.map +1 -1
  79. package/dist/replay.d.ts.map +1 -1
  80. package/dist/replay.js +21 -22
  81. package/dist/replay.js.map +1 -1
  82. package/dist/round-contract.d.ts +64 -0
  83. package/dist/round-contract.d.ts.map +1 -0
  84. package/dist/round-contract.js +80 -0
  85. package/dist/round-contract.js.map +1 -0
  86. package/dist/round-coordinator.d.ts +5 -3
  87. package/dist/round-coordinator.d.ts.map +1 -1
  88. package/dist/round-coordinator.js +33 -29
  89. package/dist/round-coordinator.js.map +1 -1
  90. package/dist/round-driver.d.ts +0 -1
  91. package/dist/round-driver.d.ts.map +1 -1
  92. package/dist/round-driver.js +0 -1
  93. package/dist/round-driver.js.map +1 -1
  94. package/dist/round-transaction.d.ts +0 -1
  95. package/dist/round-transaction.d.ts.map +1 -1
  96. package/dist/round-transaction.js +0 -1
  97. package/dist/round-transaction.js.map +1 -1
  98. package/dist/self-eval.d.ts +29 -34
  99. package/dist/self-eval.d.ts.map +1 -1
  100. package/dist/self-eval.js +112 -148
  101. package/dist/self-eval.js.map +1 -1
  102. package/dist/verification-gate.d.ts +53 -12
  103. package/dist/verification-gate.d.ts.map +1 -1
  104. package/dist/verification-gate.js +462 -90
  105. package/dist/verification-gate.js.map +1 -1
  106. package/dist/workspace.d.ts +29 -0
  107. package/dist/workspace.d.ts.map +1 -0
  108. package/dist/workspace.js +61 -0
  109. package/dist/workspace.js.map +1 -0
  110. package/loop_policy.json +5 -13
  111. package/package.json +1 -1
  112. package/skills/perception/SKILL.md +135 -136
@@ -7,6 +7,8 @@ import { createHash } from "node:crypto";
7
7
  import { getPolicy } from "./policy.js";
8
8
  import { AgentStatus, makeLoopCompileResponse, makeLoopHealth, makeLoopObjective, makeConstraintMeta, makeMilestoneSummary, makeRollingSummary, makeSubGoal, makeTaskAlignment, } from "./protocol.js";
9
9
  import { createCanonicalLoopState, renderCanonicalStateMarkdown, } from "./canonical-state.js";
10
+ import { decodeCommittedRound, decodeMergedRound, machineGitMotionSeries, mergedRoundsFromEntries, } from "./committed-round.js";
11
+ import { contractRoundEvaluations, deriveActiveRoundContract, } from "./round-contract.js";
10
12
  import { assemblePromptArtifact } from "./prompt-assembler.js";
11
13
  import { decidePromptLevel, } from "./prompt-policy.js";
12
14
  import { deriveItemId, jaccardSimilarity, unique, entryRound } from "./token-utils.js";
@@ -26,6 +28,35 @@ function loopEntries(loopId, context) {
26
28
  .filter((entry) => entry.loop_id === loopId || lineage(entry).loop_id === loopId)
27
29
  .sort((a, b) => entryRound(a) - entryRound(b));
28
30
  }
31
+ /** v3.3.1: The canonical entry for a round — its compile-time lineage entry
32
+ * (task_type "loop_lineage"; engine hydration merges the committed
33
+ * decision into it, so one round = one merged lineage entry in the
34
+ * compiler's view). Event/journal entries (delegation journals, gate
35
+ * records) share lineage.round with the round but carry none of its
36
+ * compile-time fields; round-level consumers that matched on the round
37
+ * number alone could hit them first (they sort after the lineage entry of
38
+ * the same round), producing an empty goal_id / task / constraints_active
39
+ * and forcing spurious L2 recovery. A direct :feedback entry is accepted
40
+ * when the caller supplies a raw committed view instead of hydration. */
41
+ function roundCanonicalEntry(entries, round) {
42
+ const candidates = entries.filter((entry) => entryRound(entry) === round);
43
+ return (candidates.find((entry) => entry.task_type === "loop_lineage") ??
44
+ candidates.find((entry) => String(entry.task_id ?? "").endsWith(":feedback")) ??
45
+ // Untyped compiler inputs are treated as round views, not events. Known event
46
+ // types (delegation journals, gate records) are excluded explicitly so
47
+ // they can never shadow a round again.
48
+ candidates.find((entry) => {
49
+ const type = String(entry.task_type ?? "");
50
+ return type === "" || (!type.startsWith("delegation") && !type.endsWith("journal"));
51
+ }) ??
52
+ null);
53
+ }
54
+ /** Return the canonical read model when an entry is a committed round.
55
+ * Uncommitted compiler lineage has no durable decision yet and deliberately
56
+ * remains on the local compile path; it cannot be treated as history. */
57
+ function committedView(entry) {
58
+ return decodeMergedRound(entry) ?? decodeCommittedRound(entry);
59
+ }
29
60
  export function computeGoalTextHash(text) {
30
61
  const normalized = text.trim().replace(/\s+/g, " ").toLowerCase();
31
62
  return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
@@ -38,12 +69,10 @@ export function deriveGoalId(loopId, task, explicit = "") {
38
69
  }
39
70
  /** v3.2: Read the previous round's persisted L1 presentation snapshot — the
40
71
  * 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. */
72
+ * lineage entry lacks the three presented_* fields (as on L0/L2 compiles),
73
+ * so callers render the full L1 state. */
44
74
  export function readPresentedBaseline(loopId, round, context) {
45
- const entry = [...loopEntries(loopId, context)].reverse()
46
- .find((candidate) => entryRound(candidate) === round - 1);
75
+ const entry = roundCanonicalEntry(loopEntries(loopId, context), round - 1);
47
76
  if (!entry)
48
77
  return null;
49
78
  const data = lineage(entry);
@@ -65,8 +94,7 @@ export function readPresentedBaseline(loopId, round, context) {
65
94
  };
66
95
  }
67
96
  export function getPreviousRound(loopId, round, context) {
68
- const entry = [...loopEntries(loopId, context)].reverse()
69
- .find((candidate) => entryRound(candidate) === round);
97
+ const entry = roundCanonicalEntry(loopEntries(loopId, context), round);
70
98
  if (!entry)
71
99
  return null;
72
100
  const data = lineage(entry);
@@ -196,6 +224,9 @@ function evolveConstraints(request, objective, previous, context) {
196
224
  /** Extract execution_evidence from a vault entry, handling both direct-field
197
225
  * and lineage-nested shapes. Returns null when absent. */
198
226
  function entryExecutionEvidence(entry) {
227
+ const view = committedView(entry);
228
+ if (view)
229
+ return view.executionEvidence;
199
230
  const direct = entry.execution_evidence;
200
231
  if (direct && typeof direct === "object" && !Array.isArray(direct)) {
201
232
  return direct;
@@ -209,6 +240,10 @@ function entryExecutionEvidence(entry) {
209
240
  }
210
241
  /** Read success_criteria_met from an entry's execution_evidence. */
211
242
  function entryCriteriaMet(entry) {
243
+ const view = committedView(entry);
244
+ if (view)
245
+ return view.executionEvidence?.success_criteria_met
246
+ ?.filter((value) => typeof value === "string") ?? [];
212
247
  const ev = entryExecutionEvidence(entry);
213
248
  if (!ev)
214
249
  return [];
@@ -217,6 +252,10 @@ function entryCriteriaMet(entry) {
217
252
  }
218
253
  /** Read success_criteria_remaining from an entry's execution_evidence. */
219
254
  function entryCriteriaRemaining(entry) {
255
+ const view = committedView(entry);
256
+ if (view)
257
+ return view.executionEvidence?.success_criteria_remaining
258
+ ?.filter((value) => typeof value === "string") ?? [];
220
259
  const ev = entryExecutionEvidence(entry);
221
260
  if (!ev)
222
261
  return [];
@@ -234,17 +273,20 @@ export function deriveLessons(loopId, context, currentRound) {
234
273
  const warns = new Map();
235
274
  for (const entry of entries) {
236
275
  const rnd = entryRound(entry);
237
- const viols = Array.isArray(entry.constraint_violations)
238
- ? entry.constraint_violations.filter((v) => typeof v === "string")
239
- : [];
276
+ const view = committedView(entry);
277
+ const viols = view
278
+ ? view.constraintViolations ?? []
279
+ : Array.isArray(entry.constraint_violations)
280
+ ? entry.constraint_violations.filter((v) => typeof v === "string")
281
+ : [];
240
282
  for (const text of viols) {
241
283
  const list = violations.get(text) ?? [];
242
284
  list.push(rnd);
243
285
  violations.set(text, list);
244
286
  }
245
- const flags = Array.isArray(entry.verification_flags)
246
- ? entry.verification_flags
247
- : [];
287
+ const flags = view
288
+ ? view.verificationFlags
289
+ : Array.isArray(entry.verification_flags) ? entry.verification_flags : [];
248
290
  for (const flag of flags) {
249
291
  const target = flag.severity === "error"
250
292
  ? errors
@@ -274,6 +316,59 @@ export function deriveLessons(loopId, context, currentRound) {
274
316
  }
275
317
  return lessons.sort((a, b) => b.count - a.count);
276
318
  }
319
+ /** v3.3: Derive display-only round statistics over the last `window`
320
+ * committed rounds — files changed, rejected attempts (snapshot.attempt
321
+ * minus one: the successful commit's attempt counts the redo), and the
322
+ * self-reported progress delta vs the previous round. One row per round,
323
+ * preferring the :feedback entry when both lineage and feedback entries
324
+ * exist. Zero persistence — re-derived from committed entries. */
325
+ function deriveRoundStats(rounds, window = 5) {
326
+ const stats = [];
327
+ let prevProgress = null;
328
+ for (const round of rounds.slice(-window)) {
329
+ const ev = round.executionEvidence;
330
+ const files = ev?.files_changed;
331
+ const rawProgress = ev?.progress_estimate;
332
+ const progress = typeof rawProgress === "number" ? rawProgress : null;
333
+ stats.push({
334
+ round: round.round,
335
+ filesChangedCount: Array.isArray(files) ? files.length : null,
336
+ rejectedAttempts: round.attempt >= 1 ? round.attempt - 1 : null,
337
+ progressDelta: progress !== null && prevProgress !== null
338
+ ? Number((progress - prevProgress).toFixed(3))
339
+ : null,
340
+ });
341
+ if (progress !== null)
342
+ prevProgress = progress;
343
+ }
344
+ return stats;
345
+ }
346
+ /** v3.3: Machine git-motion cross-check over the last 3 committed rounds
347
+ * (the R4 window). Undefined when no committed rounds carry git snapshots
348
+ * — the dashboard then shows no machine row. Display-only companion to the
349
+ * R4/R5 exculpatory signal. */
350
+ function deriveMachineStatus(rounds, currentRound) {
351
+ const series = machineGitMotionSeries(rounds, currentRound, 3);
352
+ if (series === null)
353
+ return undefined;
354
+ const motionRounds = series.filter(Boolean).length;
355
+ return { windowRounds: 3, gitMotion: motionRounds > 0, motionRounds };
356
+ }
357
+ /** v3.4: ACTIVE Round Contract for the compile round — derived from the
358
+ * committed evals of earlier rounds only, never from request data. Reads
359
+ * the hydrated merged lineage entries (one per round; a committed decision
360
+ * is merged onto the round's lineage entry with `committed_action` set),
361
+ * skipping rounds whose committed action was "backtrack" (a roll-back
362
+ * directive, not an executed round). Because it runs identically on every
363
+ * compile path — including rejection retries and resume / unpause /
364
+ * backtrack compiles that carry no last_round_result — a contract round's
365
+ * re-compiles keep showing its contract as the Current Task. Null when no
366
+ * active contract: the Current Task falls back to the original task text. */
367
+ function deriveActiveContract(loopId, context, currentRound) {
368
+ const entries = loopEntries(loopId, context);
369
+ const canonical = Array.from({ length: Math.max(0, currentRound - 1) }, (_, index) => roundCanonicalEntry(entries, index + 1)).filter((entry) => entry !== null);
370
+ return deriveActiveRoundContract(contractRoundEvaluations(mergedRoundsFromEntries(canonical, currentRound)));
371
+ }
277
372
  /** v3.2: Derive per-criterion status — the "goal → criteria → evidence"
278
373
  * vertical view. Each objective criterion gets: met/remaining/unknown
279
374
  * (from per-round success_criteria_met/remaining reports, ID-first
@@ -323,8 +418,10 @@ export function deriveCriterionStatuses(loopId, context, objective, currentRound
323
418
  }
324
419
  /** v2.11: Match two criterion references for deduplication.
325
420
  * If either is a criterion ID (cr-XXXXXXXX), uses exact ID comparison.
326
- * Otherwise falls back to Jaccard similarity. */
327
- function criteriaMatch(a, b) {
421
+ * Otherwise falls back to Jaccard similarity.
422
+ * v3.3: exported for the verification gate's windowed criteria-completion
423
+ * scan (R4/R5 exculpatory cross-check). */
424
+ export function criteriaMatch(a, b) {
328
425
  const aIsId = isCriterionId(a);
329
426
  const bIsId = isCriterionId(b);
330
427
  // Both are IDs — exact string comparison
@@ -341,7 +438,7 @@ function criteriaMatch(a, b) {
341
438
  /** Detect newly met criteria by comparing the current entry's
342
439
  * success_criteria_met against the previous entry's.
343
440
  * v2.11: ID-first matching (cr-XXXXXXXX) with Jaccard similarity
344
- * fallback for backward compatibility.
441
+ * fallback for natural-language references.
345
442
  * Returns empty array when there is no previous entry — the first
346
443
  * entry's criteria are the baseline, not a "new" event. */
347
444
  function detectNewCriteria(current, previous) {
@@ -359,12 +456,18 @@ function detectNewCriteria(current, previous) {
359
456
  }
360
457
  /** Read constraints_active from an entry's lineage. */
361
458
  function entryActiveConstraints(entry) {
459
+ const view = committedView(entry);
460
+ if (view)
461
+ return view.activeConstraints ?? [];
362
462
  const lin = lineage(entry);
363
463
  const arr = lin.constraints_active;
364
464
  return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
365
465
  }
366
466
  /** Read retracted_constraints from an entry. */
367
467
  function entryRetractedConstraints(entry) {
468
+ const view = committedView(entry);
469
+ if (view)
470
+ return view.retractedConstraints ?? [];
368
471
  const direct = entry.retracted_constraints;
369
472
  if (Array.isArray(direct))
370
473
  return direct.filter((v) => typeof v === "string");
@@ -385,9 +488,10 @@ function entryProgressEstimate(entry) {
385
488
  /** Build a single MilestoneSummary from a range of completed round entries. */
386
489
  function buildMilestoneFromEntries(phaseEntries, startRound, endRound, label, kind) {
387
490
  const last = phaseEntries.at(-1);
388
- const outcomeRaw = last
389
- ? (typeof last.output_summary === "string" ? last.output_summary : "")
390
- : "";
491
+ const lastView = last ? committedView(last) : null;
492
+ const outcomeRaw = lastView
493
+ ? (lastView.outputSummary ?? "")
494
+ : last && typeof last.output_summary === "string" ? last.output_summary : "";
391
495
  const outcomeMax = getPolicy().checkpoint.outcome_max_chars;
392
496
  const outcome = outcomeRaw.length > outcomeMax
393
497
  ? outcomeRaw.slice(0, outcomeMax) + "…"
@@ -468,24 +572,29 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
468
572
  const outcomes = [];
469
573
  const issues = [];
470
574
  for (const entry of windowEntries) {
575
+ const view = committedView(entry);
471
576
  const data = lineage(entry);
472
577
  const round = entryRound(entry);
473
- const success = entry.success ?? data.success;
474
- const summary = typeof entry.output_summary === "string"
475
- ? entry.output_summary
476
- : typeof data.output_summary === "string" ? data.output_summary : "";
578
+ const success = view ? view.success : entry.success ?? data.success;
579
+ const summary = view
580
+ ? view.outputSummary ?? ""
581
+ : typeof entry.output_summary === "string"
582
+ ? entry.output_summary
583
+ : typeof data.output_summary === "string" ? data.output_summary : "";
477
584
  // v2.12: Declared outcome wins the label; the default wording stays
478
585
  // "accepted" for zero regression on existing loop output.
479
- const declared = entry.outcome;
586
+ const declared = view ? view.outcome : entry.outcome;
480
587
  const outcomeLabel = declared === "success" || declared === "partial" ||
481
588
  declared === "failed" || declared === "blocked"
482
589
  ? declared
483
590
  : success === false ? "failed" : "accepted";
484
591
  if (summary)
485
592
  outcomes.push(`[R${round}] ${outcomeLabel}: ${summary}`);
486
- const violations = Array.isArray(entry.constraint_violations)
487
- ? entry.constraint_violations
488
- : Array.isArray(data.constraint_violations) ? data.constraint_violations : [];
593
+ const violations = view
594
+ ? view.constraintViolations ?? []
595
+ : Array.isArray(entry.constraint_violations)
596
+ ? entry.constraint_violations
597
+ : Array.isArray(data.constraint_violations) ? data.constraint_violations : [];
489
598
  issues.push(...violations.filter((item) => typeof item === "string"));
490
599
  }
491
600
  // ── Phase 2: Milestone Accumulation ──
@@ -521,10 +630,17 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
521
630
  lastMilestoneRound = rnd;
522
631
  }
523
632
  }
524
- // Signal 3: Safety net — auto milestone when gap exceeds milestone_interval
633
+ // Signal 3: Safety net — auto milestone when gap exceeds milestone_interval.
634
+ // v3.3.1: the policy contract (milestone_interval: "only fires when no
635
+ // agent_declared or criteria_milestone has been created for this many
636
+ // rounds") includes loops that NEVER created a milestone — but the guard
637
+ // `lastMilestoneRound > 0` made the gap start from the (nonexistent) first
638
+ // milestone, so a checkpoint-less loop could run forever with an empty
639
+ // Phase History and no memory skeleton. With lastMilestoneRound == 0 the
640
+ // gap is measured from round 1 and the safety net fires from the very
641
+ // first interval.
525
642
  const gapSinceLast = (currentRound - 1) - lastMilestoneRound;
526
- if (lastMilestoneRound > 0 &&
527
- gapSinceLast >= policy.milestone_interval &&
643
+ if (gapSinceLast >= policy.milestone_interval &&
528
644
  completedEntries.length > 0) {
529
645
  const startRound = lastMilestoneRound + 1;
530
646
  const endRound = currentRound - 1;
@@ -540,23 +656,6 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
540
656
  // L2 (full rehydration) keeps every milestone — the recovery view is the
541
657
  // loop's memory skeleton and must not be truncated.
542
658
  const cappedMilestones = sampleMilestones(milestones, policy, level);
543
- // ── Phase 3: Loop Synthesis (formulaic) ──
544
- let loopSynthesis = "";
545
- if (policy.enable_loop_synthesis && currentRound > 1) {
546
- const totalRounds = currentRound - 1;
547
- const phaseCount = cappedMilestones.length;
548
- const lastProgress = completedEntries.length > 0
549
- ? entryProgressEstimate(completedEntries[completedEntries.length - 1])
550
- : 0;
551
- const activeCount = completedEntries.length > 0
552
- ? entryActiveConstraints(completedEntries[completedEntries.length - 1]).length
553
- : 0;
554
- const totalResolved = cappedMilestones.reduce((sum, m) => sum + m.resolved_constraints.length, 0);
555
- loopSynthesis =
556
- `Loop spans ${totalRounds} rounds across ${phaseCount} phases. ` +
557
- `Overall progress: ${(lastProgress * 100).toFixed(0)}%. ` +
558
- `${activeCount} active constraints, ${totalResolved} resolved across all phases.`;
559
- }
560
659
  if (windowEntries.length === 0 && cappedMilestones.length === 0)
561
660
  return null;
562
661
  return makeRollingSummary({
@@ -566,7 +665,6 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
566
665
  generated_at_round: currentRound,
567
666
  failed_patterns: [],
568
667
  milestones: cappedMilestones,
569
- loop_synthesis: loopSynthesis || undefined,
570
668
  });
571
669
  }
572
670
  // ═══════════════════════════════════════════════════════════════════════════
@@ -599,6 +697,9 @@ export function isCriterionId(ref) {
599
697
  }
600
698
  /** Read emerged_subtasks from a vault entry (handles direct + lineage nesting). */
601
699
  function entryEmergedSubtasks(entry) {
700
+ const view = committedView(entry);
701
+ if (view)
702
+ return view.emergedSubtasks ?? [];
602
703
  const direct = entry.emerged_subtasks;
603
704
  if (Array.isArray(direct))
604
705
  return direct.filter((v) => typeof v === "string");
@@ -610,6 +711,9 @@ function entryEmergedSubtasks(entry) {
610
711
  }
611
712
  /** Read completed_subtasks from an entry. */
612
713
  function entryCompletedSubtasks(entry) {
714
+ const view = committedView(entry);
715
+ if (view)
716
+ return view.completedSubtasks ?? [];
613
717
  const direct = entry.completed_subtasks;
614
718
  if (Array.isArray(direct))
615
719
  return direct.filter((v) => typeof v === "string");
@@ -617,6 +721,9 @@ function entryCompletedSubtasks(entry) {
617
721
  }
618
722
  /** Read blocked_subtasks from an entry. */
619
723
  function entryBlockedSubtasks(entry) {
724
+ const view = committedView(entry);
725
+ if (view)
726
+ return view.blockedSubtasks ?? [];
620
727
  const direct = entry.blocked_subtasks;
621
728
  if (Array.isArray(direct))
622
729
  return direct.filter((v) => typeof v === "string");
@@ -624,6 +731,9 @@ function entryBlockedSubtasks(entry) {
624
731
  }
625
732
  /** Read canceled_subtasks from an entry. */
626
733
  function entryCanceledSubtasks(entry) {
734
+ const view = committedView(entry);
735
+ if (view)
736
+ return view.canceledSubtasks ?? [];
627
737
  const direct = entry.canceled_subtasks;
628
738
  if (Array.isArray(direct))
629
739
  return direct.filter((v) => typeof v === "string");
@@ -816,9 +926,12 @@ function findDiscoveredRound(text, vaultEntries) {
816
926
  const rnd = entryRound(entry);
817
927
  if (rnd < 1)
818
928
  continue;
819
- const discovered = Array.isArray(entry.discovered_constraints)
820
- ? entry.discovered_constraints.filter((v) => typeof v === "string")
821
- : [];
929
+ const view = committedView(entry);
930
+ const discovered = view
931
+ ? view.discoveredConstraints ?? []
932
+ : Array.isArray(entry.discovered_constraints)
933
+ ? entry.discovered_constraints.filter((v) => typeof v === "string")
934
+ : [];
822
935
  for (const d of discovered) {
823
936
  // Phase 1: exact ID match (v2.11)
824
937
  if (d === targetId || deriveConstraintId(d) === targetId) {
@@ -826,7 +939,7 @@ function findDiscoveredRound(text, vaultEntries) {
826
939
  earliest = rnd;
827
940
  break;
828
941
  }
829
- // Phase 2: Jaccard fallback (backward compatibility)
942
+ // Phase 2: Jaccard fallback for natural-language references.
830
943
  if (jaccardSimilarity(text, d) >= getPolicy().evolution.constraint_match_threshold) {
831
944
  if (earliest === 0 || rnd < earliest)
832
945
  earliest = rnd;
@@ -856,9 +969,12 @@ function findLastViolatedRound(text, vaultEntries) {
856
969
  const rnd = entryRound(entry);
857
970
  if (rnd < 1)
858
971
  continue;
859
- const violations = Array.isArray(entry.constraint_violations)
860
- ? entry.constraint_violations.filter((v) => typeof v === "string")
861
- : [];
972
+ const view = committedView(entry);
973
+ const violations = view
974
+ ? view.constraintViolations ?? []
975
+ : Array.isArray(entry.constraint_violations)
976
+ ? entry.constraint_violations.filter((v) => typeof v === "string")
977
+ : [];
862
978
  for (const v of violations) {
863
979
  if (matchesConstraintText(text, v)) {
864
980
  if (rnd > latest)
@@ -893,8 +1009,15 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
893
1009
  const violatedThisRound = lastRoundViolations.some((v) => matchesConstraintText(text, v));
894
1010
  // Structural constraints never decay
895
1011
  if (source === "hard" || source === "plan" || source === "criteria") {
1012
+ // v3.3.1: one text, one identity — a success-criterion text that also
1013
+ // lives in the merged active set belongs to the cr-XXXXXXXX namespace
1014
+ // (its own Success Criteria section and the verification gate's
1015
+ // criteriaMatch are ID-first on cr-). Rendering it under c-XXXXXXXX in
1016
+ // the active list meant the same text carried two IDs in one prompt,
1017
+ // and an agent echoing the active-list ID into success_criteria_met
1018
+ // could never match.
896
1019
  metadata.push(makeConstraintMeta({
897
- id: deriveConstraintId(text),
1020
+ id: source === "criteria" ? deriveCriterionId(text) : deriveConstraintId(text),
898
1021
  text,
899
1022
  discovered_at_round: objective.created_at_round || 1,
900
1023
  last_violated_at_round: violatedThisRound
@@ -1011,11 +1134,24 @@ export function buildSelfEvalBlock(round, prevDriftFlags,
1011
1134
  /** v2.12: L0 is the minimal retry template — the v2.12 declarative fields
1012
1135
  * (outcome/blocker/retroactiveClaims) are L1/L2 additions so the retry
1013
1136
  * prompt stays within its tight budget. */
1014
- level) {
1137
+ level,
1138
+ /** v3.3/v3.4: Whether this round's Current Task IS the ACTIVE Round
1139
+ * Contract (derived from committed rounds — compileLoop passes
1140
+ * `activeContract != null`). Only then does the template ask the agent
1141
+ * to restate/propose it — a generic empty contract template would invite
1142
+ * placeholder submissions that trigger round_underspecified noise. */
1143
+ hasContract = false,
1144
+ /** v3.5: L2-only prose suggesting a Round Contract declaration when the
1145
+ * Current Task is NOT one (contract_nudge_on_l2 policy, computed at the
1146
+ * compileLoop call site). Mutually exclusive with hasContract. The prose
1147
+ * must never contain the JSON key name `round_contract` — contract-less
1148
+ * L2 tests assert its lowercase absence. */
1149
+ proposalNudge = false) {
1015
1150
  const hasIntentDrift = prevDriftFlags?.some(f => f.check === "intent_drift") ?? false;
1016
1151
  const hasSubGoalDrift = prevDriftFlags?.some(f => f.check === "subgoal_drift") ?? false;
1017
1152
  const needsClarification = hasIntentDrift || hasSubGoalDrift;
1018
1153
  const declareOutcome = level !== "l0";
1154
+ const restateContract = hasContract && declareOutcome;
1019
1155
  const evalObj = {
1020
1156
  success: false,
1021
1157
  output_summary: `<verified result of round ${round}>`,
@@ -1042,6 +1178,20 @@ level) {
1042
1178
  evalObj.blocker = "<required only when outcome=blocked>";
1043
1179
  evalObj.retroactiveClaims = [];
1044
1180
  }
1181
+ // v3.3/v3.4: Round Contract proposal template (L1/L2, active-contract
1182
+ // rounds only — L0 retry stays lean, and rounds without an active
1183
+ // contract never see the template). When the prompt's Current Task IS the
1184
+ // ACTIVE contract, the agent restates it unchanged as its proposal; when
1185
+ // the active contract's done_when are all satisfied (or the round is
1186
+ // blocked), a NEW contract is declared for the next round instead.
1187
+ if (restateContract) {
1188
+ evalObj.round_contract = {
1189
+ work_item: "<this round's focus, or empty>",
1190
+ done_when: [],
1191
+ verification_plan: [],
1192
+ scope: [],
1193
+ };
1194
+ }
1045
1195
  // v2.8: Inject drift clarification field when previous round had drift flags
1046
1196
  if (needsClarification) {
1047
1197
  const flagTypes = [
@@ -1057,19 +1207,27 @@ level) {
1057
1207
  "### LoopForge Evaluation (Required)",
1058
1208
  "",
1059
1209
  `After completing Round ${round}, replace the placeholder values below`,
1060
- "with your actual results and submit via `loopforge_next`.",
1210
+ "with your actual results and pass this object as `loopforge_next.evaluation`.",
1061
1211
  ];
1062
1212
  if (needsClarification) {
1063
1213
  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.");
1064
1214
  }
1065
- lines.push("---loopforge-eval");
1215
+ lines.push("```json");
1066
1216
  lines.push(JSON.stringify(evalObj, null, 2));
1067
- lines.push("---end-loopforge-eval");
1217
+ lines.push("```");
1068
1218
  lines.push("");
1069
1219
  lines.push("IMPORTANT: Replace every <placeholder> with your actual data.");
1070
1220
  lines.push("Set success=true only when the full goal and ALL hard constraints are verified.");
1071
1221
  lines.push("Use IDs for exact matching: c-XXXXXXXX, cr-XXXXXXXX, sg-XXXXXXXX.");
1072
1222
  lines.push("For subtasks, use sub-goal IDs from the Sub-Goal Dashboard above.");
1223
+ // v3.5: L2 contract-less nudge (prose only — never the JSON key name, so
1224
+ // contract-less rounds keep their template-lean assertions).
1225
+ if (proposalNudge) {
1226
+ 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.");
1227
+ }
1228
+ if (restateContract) {
1229
+ 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).");
1230
+ }
1073
1231
  return lines.join("\n");
1074
1232
  }
1075
1233
  // v2.5: checkpoint() removed — agent_declared milestones from
@@ -1104,15 +1262,22 @@ export function compileLoop(request, context) {
1104
1262
  // No new persistence; reconstructed same as milestones/sub-goals.
1105
1263
  const completedEntries = loopEntries(request.loop_id, context)
1106
1264
  .filter((e) => entryRound(e) >= 1 && entryRound(e) < request.round);
1265
+ const committedRounds = mergedRoundsFromEntries(completedEntries, request.round);
1107
1266
  const trend = [];
1108
- for (const entry of completedEntries.slice(-10)) {
1109
- const viols = Array.isArray(entry.constraint_violations)
1110
- ? entry.constraint_violations.filter((v) => typeof v === "string")
1111
- : [];
1267
+ for (const round of committedRounds.slice(-10)) {
1268
+ const viols = round.evaluation?.constraint_violations ?? [];
1112
1269
  // Simple proxy: no violations = perfect trust, each violation = -0.1
1113
1270
  const score = Math.max(0, 1.0 - viols.length * 0.1);
1114
1271
  trend.push(Number(score.toFixed(2)));
1115
1272
  }
1273
+ // v3.3: Round stats + machine git-motion status (display-only, derived).
1274
+ const roundStats = deriveRoundStats(committedRounds);
1275
+ const machineStatus = deriveMachineStatus(committedRounds, request.round);
1276
+ // v3.4: ACTIVE Round Contract for this compile — derived from committed
1277
+ // rounds, so rejection retries / resume / unpause / backtrack compiles
1278
+ // (no last_round_result) keep showing the contract as Current Task, and a
1279
+ // satisfied or blocked contract stops being shown.
1280
+ const activeContract = deriveActiveContract(request.loop_id, context, request.round);
1116
1281
  const response = makeLoopCompileResponse({
1117
1282
  status: AgentStatus.OK,
1118
1283
  recompile_level: decision.level,
@@ -1142,7 +1307,12 @@ export function compileLoop(request, context) {
1142
1307
  });
1143
1308
  const policy = getPolicy();
1144
1309
  const statePath = `${policy.state_file.directory}/${request.loop_id}-state.md`;
1145
- const state = createCanonicalLoopState(request, response, statePath);
1310
+ const state = createCanonicalLoopState(request, response, statePath, {
1311
+ roundStats,
1312
+ machineStatus,
1313
+ // v3.4: derived active contract — the Current Task's source of truth.
1314
+ roundContract: activeContract,
1315
+ });
1146
1316
  const markdown = renderCanonicalStateMarkdown(state);
1147
1317
  // v2.4–v2.5: Adaptive L2 budget — scales with loop complexity
1148
1318
  const adaptiveL2 = policy.prompt.l2_adaptive_enabled
@@ -1166,7 +1336,15 @@ export function compileLoop(request, context) {
1166
1336
  l2: adaptiveL2,
1167
1337
  },
1168
1338
  attempt: request.attempt,
1169
- selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags, decision.level),
1339
+ selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags, decision.level,
1340
+ // v3.4: restate the contract only when this prompt's Current Task IS
1341
+ // the ACTIVE contract (derived — not the previous submission's field,
1342
+ // which is a proposal and may differ from what this round executes).
1343
+ activeContract != null,
1344
+ // v3.5: L2-only declaration nudge when nothing is active (policy-gated;
1345
+ // mutually exclusive with the restate template above).
1346
+ decision.level === "l2" && activeContract === null &&
1347
+ getPolicy().prompt.contract_nudge_on_l2),
1170
1348
  fullStateMarkdown: l2Pointer ? undefined : markdown,
1171
1349
  // v2.9: Model's information needs from the previous round's SelfEvaluation
1172
1350
  promptRequests: request.last_round_result?.prompt_requests,