loopforge 3.2.1 → 3.7.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 (122) hide show
  1. package/README.md +74 -71
  2. package/dist/audit.d.ts.map +1 -1
  3. package/dist/audit.js +6 -38
  4. package/dist/audit.js.map +1 -1
  5. package/dist/canonical-state.d.ts +71 -6
  6. package/dist/canonical-state.d.ts.map +1 -1
  7. package/dist/canonical-state.js +176 -29
  8. package/dist/canonical-state.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +16 -26
  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/cognitive-governance.d.ts +0 -1
  17. package/dist/cognitive-governance.d.ts.map +1 -1
  18. package/dist/cognitive-governance.js +1 -4
  19. package/dist/cognitive-governance.js.map +1 -1
  20. package/dist/committed-round.d.ts +100 -0
  21. package/dist/committed-round.d.ts.map +1 -0
  22. package/dist/committed-round.js +349 -0
  23. package/dist/committed-round.js.map +1 -0
  24. package/dist/enforcement-gate.d.ts +4 -2
  25. package/dist/enforcement-gate.d.ts.map +1 -1
  26. package/dist/enforcement-gate.js +385 -357
  27. package/dist/enforcement-gate.js.map +1 -1
  28. package/dist/engine.d.ts +16 -4
  29. package/dist/engine.d.ts.map +1 -1
  30. package/dist/engine.js +65 -16
  31. package/dist/engine.js.map +1 -1
  32. package/dist/evidence-claims.d.ts.map +1 -1
  33. package/dist/evidence-claims.js +15 -39
  34. package/dist/evidence-claims.js.map +1 -1
  35. package/dist/evidence-provider.d.ts +35 -17
  36. package/dist/evidence-provider.d.ts.map +1 -1
  37. package/dist/evidence-provider.js +105 -108
  38. package/dist/evidence-provider.js.map +1 -1
  39. package/dist/index.d.ts +4 -4
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +3 -3
  42. package/dist/index.js.map +1 -1
  43. package/dist/loop-compiler.d.ts +21 -10
  44. package/dist/loop-compiler.d.ts.map +1 -1
  45. package/dist/loop-compiler.js +232 -276
  46. package/dist/loop-compiler.js.map +1 -1
  47. package/dist/loop-projection.d.ts +5 -25
  48. package/dist/loop-projection.d.ts.map +1 -1
  49. package/dist/loop-projection.js +18 -134
  50. package/dist/loop-projection.js.map +1 -1
  51. package/dist/loop-store.d.ts +16 -18
  52. package/dist/loop-store.d.ts.map +1 -1
  53. package/dist/loop-store.js +21 -63
  54. package/dist/loop-store.js.map +1 -1
  55. package/dist/mcp/round-lifecycle.d.ts +13 -10
  56. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  57. package/dist/mcp/round-lifecycle.js +95 -68
  58. package/dist/mcp/round-lifecycle.js.map +1 -1
  59. package/dist/mcp/server.js +3 -3
  60. package/dist/mcp/server.js.map +1 -1
  61. package/dist/mcp/session.d.ts +17 -5
  62. package/dist/mcp/session.d.ts.map +1 -1
  63. package/dist/mcp/session.js +177 -54
  64. package/dist/mcp/session.js.map +1 -1
  65. package/dist/mcp/tools.d.ts +12 -0
  66. package/dist/mcp/tools.d.ts.map +1 -1
  67. package/dist/mcp/tools.js +161 -53
  68. package/dist/mcp/tools.js.map +1 -1
  69. package/dist/policy-metrics.d.ts +0 -7
  70. package/dist/policy-metrics.d.ts.map +1 -1
  71. package/dist/policy-metrics.js +15 -55
  72. package/dist/policy-metrics.js.map +1 -1
  73. package/dist/policy.d.ts +22 -31
  74. package/dist/policy.d.ts.map +1 -1
  75. package/dist/policy.js +24 -42
  76. package/dist/policy.js.map +1 -1
  77. package/dist/prompt-assembler.d.ts +0 -3
  78. package/dist/prompt-assembler.d.ts.map +1 -1
  79. package/dist/prompt-assembler.js +124 -191
  80. package/dist/prompt-assembler.js.map +1 -1
  81. package/dist/protocol.d.ts +61 -46
  82. package/dist/protocol.d.ts.map +1 -1
  83. package/dist/protocol.js +2 -18
  84. package/dist/protocol.js.map +1 -1
  85. package/dist/replay.d.ts.map +1 -1
  86. package/dist/replay.js +21 -22
  87. package/dist/replay.js.map +1 -1
  88. package/dist/round-contract.d.ts +64 -0
  89. package/dist/round-contract.d.ts.map +1 -0
  90. package/dist/round-contract.js +80 -0
  91. package/dist/round-contract.js.map +1 -0
  92. package/dist/round-coordinator.d.ts +5 -3
  93. package/dist/round-coordinator.d.ts.map +1 -1
  94. package/dist/round-coordinator.js +34 -29
  95. package/dist/round-coordinator.js.map +1 -1
  96. package/dist/round-driver.d.ts +0 -4
  97. package/dist/round-driver.d.ts.map +1 -1
  98. package/dist/round-driver.js +1 -11
  99. package/dist/round-driver.js.map +1 -1
  100. package/dist/round-transaction.d.ts +0 -1
  101. package/dist/round-transaction.d.ts.map +1 -1
  102. package/dist/round-transaction.js +0 -1
  103. package/dist/round-transaction.js.map +1 -1
  104. package/dist/self-eval.d.ts +29 -34
  105. package/dist/self-eval.d.ts.map +1 -1
  106. package/dist/self-eval.js +112 -148
  107. package/dist/self-eval.js.map +1 -1
  108. package/dist/token-utils.d.ts +7 -3
  109. package/dist/token-utils.d.ts.map +1 -1
  110. package/dist/token-utils.js +18 -3
  111. package/dist/token-utils.js.map +1 -1
  112. package/dist/verification-gate.d.ts +72 -21
  113. package/dist/verification-gate.d.ts.map +1 -1
  114. package/dist/verification-gate.js +544 -157
  115. package/dist/verification-gate.js.map +1 -1
  116. package/dist/workspace.d.ts +29 -0
  117. package/dist/workspace.d.ts.map +1 -0
  118. package/dist/workspace.js +61 -0
  119. package/dist/workspace.js.map +1 -0
  120. package/loop_policy.json +5 -17
  121. package/package.json +2 -2
  122. package/skills/perception/SKILL.md +137 -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 { decodeRound, entryLineage, entryCriteriaMet, entryCriteriaRemaining, entryActiveConstraints, entryRetractedConstraints, entryProgressEstimate, entryEmergedSubtasks, 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";
@@ -15,17 +17,34 @@ function contextEntries(context) {
15
17
  return [];
16
18
  return context.results.filter((value) => value !== null && typeof value === "object" && !Array.isArray(value));
17
19
  }
18
- function lineage(entry) {
19
- const value = entry.loop_lineage ?? entry.lineage;
20
- return value !== null && typeof value === "object" && !Array.isArray(value)
21
- ? value
22
- : {};
23
- }
24
20
  function loopEntries(loopId, context) {
25
21
  return contextEntries(context)
26
- .filter((entry) => entry.loop_id === loopId || lineage(entry).loop_id === loopId)
22
+ .filter((entry) => entry.loop_id === loopId || entryLineage(entry).loop_id === loopId)
27
23
  .sort((a, b) => entryRound(a) - entryRound(b));
28
24
  }
25
+ /** v3.3.1: The canonical entry for a round — its compile-time lineage entry
26
+ * (task_type "loop_lineage"; engine hydration merges the committed
27
+ * decision into it, so one round = one merged lineage entry in the
28
+ * compiler's view). Event/journal entries (delegation journals, gate
29
+ * records) share lineage.round with the round but carry none of its
30
+ * compile-time fields; round-level consumers that matched on the round
31
+ * number alone could hit them first (they sort after the lineage entry of
32
+ * the same round), producing an empty goal_id / task / constraints_active
33
+ * and forcing spurious L2 recovery. A direct :feedback entry is accepted
34
+ * when the caller supplies a raw committed view instead of hydration. */
35
+ function roundCanonicalEntry(entries, round) {
36
+ const candidates = entries.filter((entry) => entryRound(entry) === round);
37
+ return (candidates.find((entry) => entry.task_type === "loop_lineage") ??
38
+ candidates.find((entry) => String(entry.task_id ?? "").endsWith(":feedback")) ??
39
+ // Untyped compiler inputs are treated as round views, not events. Known event
40
+ // types (delegation journals, gate records) are excluded explicitly so
41
+ // they can never shadow a round again.
42
+ candidates.find((entry) => {
43
+ const type = String(entry.task_type ?? "");
44
+ return type === "" || (!type.startsWith("delegation") && !type.endsWith("journal"));
45
+ }) ??
46
+ null);
47
+ }
29
48
  export function computeGoalTextHash(text) {
30
49
  const normalized = text.trim().replace(/\s+/g, " ").toLowerCase();
31
50
  return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
@@ -38,15 +57,13 @@ export function deriveGoalId(loopId, task, explicit = "") {
38
57
  }
39
58
  /** v3.2: Read the previous round's persisted L1 presentation snapshot — the
40
59
  * 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. */
60
+ * lineage entry lacks the three presented_* fields (as on L0/L2 compiles),
61
+ * so callers render the full L1 state. */
44
62
  export function readPresentedBaseline(loopId, round, context) {
45
- const entry = [...loopEntries(loopId, context)].reverse()
46
- .find((candidate) => entryRound(candidate) === round - 1);
63
+ const entry = roundCanonicalEntry(loopEntries(loopId, context), round - 1);
47
64
  if (!entry)
48
65
  return null;
49
- const data = lineage(entry);
66
+ const data = entryLineage(entry);
50
67
  const constraintIds = data.presented_constraint_ids;
51
68
  const subGoals = data.presented_subgoals;
52
69
  const milestoneRanges = data.presented_milestone_ranges;
@@ -65,11 +82,10 @@ export function readPresentedBaseline(loopId, round, context) {
65
82
  };
66
83
  }
67
84
  export function getPreviousRound(loopId, round, context) {
68
- const entry = [...loopEntries(loopId, context)].reverse()
69
- .find((candidate) => entryRound(candidate) === round);
85
+ const entry = roundCanonicalEntry(loopEntries(loopId, context), round);
70
86
  if (!entry)
71
87
  return null;
72
- const data = lineage(entry);
88
+ const data = entryLineage(entry);
73
89
  return {
74
90
  round,
75
91
  goal_id: typeof data.goal_id === "string" ? data.goal_id : "",
@@ -160,7 +176,7 @@ function evolveConstraints(request, objective, previous, context) {
160
176
  const lastRetractedRound = new Map();
161
177
  if (window > 0) {
162
178
  for (const entry of loopEntries(request.loop_id, context)) {
163
- const retractedList = lineage(entry).retracted_constraints;
179
+ const retractedList = entryLineage(entry).retracted_constraints;
164
180
  if (!Array.isArray(retractedList))
165
181
  continue;
166
182
  const rnd = entryRound(entry);
@@ -193,36 +209,6 @@ function evolveConstraints(request, objective, previous, context) {
193
209
  // ═══════════════════════════════════════════════════════════════════════════
194
210
  // Hierarchical Summary (v2.1)
195
211
  // ═══════════════════════════════════════════════════════════════════════════
196
- /** Extract execution_evidence from a vault entry, handling both direct-field
197
- * and lineage-nested shapes. Returns null when absent. */
198
- function entryExecutionEvidence(entry) {
199
- const direct = entry.execution_evidence;
200
- if (direct && typeof direct === "object" && !Array.isArray(direct)) {
201
- return direct;
202
- }
203
- const lin = lineage(entry);
204
- const nested = lin.execution_evidence;
205
- if (nested && typeof nested === "object" && !Array.isArray(nested)) {
206
- return nested;
207
- }
208
- return null;
209
- }
210
- /** Read success_criteria_met from an entry's execution_evidence. */
211
- function entryCriteriaMet(entry) {
212
- const ev = entryExecutionEvidence(entry);
213
- if (!ev)
214
- return [];
215
- const arr = ev.success_criteria_met;
216
- return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
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
212
  /** v3.2: Deterministic lessons learned — constraints violated repeatedly or
227
213
  * verification checks failing repeatedly across rounds (full history, unlike
228
214
  * the enforcement gate's R2 3-round window). Presentation only: the output
@@ -234,17 +220,20 @@ export function deriveLessons(loopId, context, currentRound) {
234
220
  const warns = new Map();
235
221
  for (const entry of entries) {
236
222
  const rnd = entryRound(entry);
237
- const viols = Array.isArray(entry.constraint_violations)
238
- ? entry.constraint_violations.filter((v) => typeof v === "string")
239
- : [];
223
+ const view = decodeRound(entry);
224
+ const viols = view
225
+ ? view.constraintViolations ?? []
226
+ : Array.isArray(entry.constraint_violations)
227
+ ? entry.constraint_violations.filter((v) => typeof v === "string")
228
+ : [];
240
229
  for (const text of viols) {
241
230
  const list = violations.get(text) ?? [];
242
231
  list.push(rnd);
243
232
  violations.set(text, list);
244
233
  }
245
- const flags = Array.isArray(entry.verification_flags)
246
- ? entry.verification_flags
247
- : [];
234
+ const flags = view
235
+ ? view.verificationFlags
236
+ : Array.isArray(entry.verification_flags) ? entry.verification_flags : [];
248
237
  for (const flag of flags) {
249
238
  const target = flag.severity === "error"
250
239
  ? errors
@@ -274,6 +263,59 @@ export function deriveLessons(loopId, context, currentRound) {
274
263
  }
275
264
  return lessons.sort((a, b) => b.count - a.count);
276
265
  }
266
+ /** v3.3: Derive display-only round statistics over the last `window`
267
+ * committed rounds — files changed, rejected attempts (snapshot.attempt
268
+ * minus one: the successful commit's attempt counts the redo), and the
269
+ * self-reported progress delta vs the previous round. One row per round,
270
+ * preferring the :feedback entry when both lineage and feedback entries
271
+ * exist. Zero persistence — re-derived from committed entries. */
272
+ function deriveRoundStats(rounds, window = 5) {
273
+ const stats = [];
274
+ let prevProgress = null;
275
+ for (const round of rounds.slice(-window)) {
276
+ const ev = round.executionEvidence;
277
+ const files = ev?.files_changed;
278
+ const rawProgress = ev?.progress_estimate;
279
+ const progress = typeof rawProgress === "number" ? rawProgress : null;
280
+ stats.push({
281
+ round: round.round,
282
+ filesChangedCount: Array.isArray(files) ? files.length : null,
283
+ rejectedAttempts: round.attempt >= 1 ? round.attempt - 1 : null,
284
+ progressDelta: progress !== null && prevProgress !== null
285
+ ? Number((progress - prevProgress).toFixed(3))
286
+ : null,
287
+ });
288
+ if (progress !== null)
289
+ prevProgress = progress;
290
+ }
291
+ return stats;
292
+ }
293
+ /** v3.3: Machine git-motion cross-check over the last 3 committed rounds
294
+ * (the R4 window). Undefined when no committed rounds carry git snapshots
295
+ * — the dashboard then shows no machine row. Display-only companion to the
296
+ * R4/R5 exculpatory signal. */
297
+ function deriveMachineStatus(rounds, currentRound) {
298
+ const series = machineGitMotionSeries(rounds, currentRound, 3);
299
+ if (series === null)
300
+ return undefined;
301
+ const motionRounds = series.filter(Boolean).length;
302
+ return { windowRounds: 3, gitMotion: motionRounds > 0, motionRounds };
303
+ }
304
+ /** v3.4: ACTIVE Round Contract for the compile round — derived from the
305
+ * committed evals of earlier rounds only, never from request data. Reads
306
+ * the hydrated merged lineage entries (one per round; a committed decision
307
+ * is merged onto the round's lineage entry with `committed_action` set),
308
+ * skipping rounds whose committed action was "backtrack" (a roll-back
309
+ * directive, not an executed round). Because it runs identically on every
310
+ * compile path — including rejection retries and resume / unpause /
311
+ * backtrack compiles that carry no last_round_result — a contract round's
312
+ * re-compiles keep showing its contract as the Current Task. Null when no
313
+ * active contract: the Current Task falls back to the original task text. */
314
+ function deriveActiveContract(loopId, context, currentRound) {
315
+ const entries = loopEntries(loopId, context);
316
+ const canonical = Array.from({ length: Math.max(0, currentRound - 1) }, (_, index) => roundCanonicalEntry(entries, index + 1)).filter((entry) => entry !== null);
317
+ return deriveActiveRoundContract(contractRoundEvaluations(mergedRoundsFromEntries(canonical, currentRound)));
318
+ }
277
319
  /** v3.2: Derive per-criterion status — the "goal → criteria → evidence"
278
320
  * vertical view. Each objective criterion gets: met/remaining/unknown
279
321
  * (from per-round success_criteria_met/remaining reports, ID-first
@@ -323,8 +365,10 @@ export function deriveCriterionStatuses(loopId, context, objective, currentRound
323
365
  }
324
366
  /** v2.11: Match two criterion references for deduplication.
325
367
  * If either is a criterion ID (cr-XXXXXXXX), uses exact ID comparison.
326
- * Otherwise falls back to Jaccard similarity. */
327
- function criteriaMatch(a, b) {
368
+ * Otherwise falls back to Jaccard similarity.
369
+ * v3.3: exported for the verification gate's windowed criteria-completion
370
+ * scan (R4/R5 exculpatory cross-check). */
371
+ export function criteriaMatch(a, b) {
328
372
  const aIsId = isCriterionId(a);
329
373
  const bIsId = isCriterionId(b);
330
374
  // Both are IDs — exact string comparison
@@ -341,7 +385,7 @@ function criteriaMatch(a, b) {
341
385
  /** Detect newly met criteria by comparing the current entry's
342
386
  * success_criteria_met against the previous entry's.
343
387
  * v2.11: ID-first matching (cr-XXXXXXXX) with Jaccard similarity
344
- * fallback for backward compatibility.
388
+ * fallback for natural-language references.
345
389
  * Returns empty array when there is no previous entry — the first
346
390
  * entry's criteria are the baseline, not a "new" event. */
347
391
  function detectNewCriteria(current, previous) {
@@ -357,37 +401,13 @@ function detectNewCriteria(current, previous) {
357
401
  return currMet;
358
402
  return currMet.filter((curr) => prevMet.every((prev) => !criteriaMatch(curr, prev)));
359
403
  }
360
- /** Read constraints_active from an entry's lineage. */
361
- function entryActiveConstraints(entry) {
362
- const lin = lineage(entry);
363
- const arr = lin.constraints_active;
364
- return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
365
- }
366
- /** Read retracted_constraints from an entry. */
367
- function entryRetractedConstraints(entry) {
368
- const direct = entry.retracted_constraints;
369
- if (Array.isArray(direct))
370
- return direct.filter((v) => typeof v === "string");
371
- const lin = lineage(entry);
372
- const nested = lin.retracted_constraints;
373
- if (Array.isArray(nested))
374
- return nested.filter((v) => typeof v === "string");
375
- return [];
376
- }
377
- /** Read progress_estimate from an entry's execution_evidence. */
378
- function entryProgressEstimate(entry) {
379
- const ev = entryExecutionEvidence(entry);
380
- if (!ev)
381
- return 0;
382
- const pe = ev.progress_estimate;
383
- return typeof pe === "number" ? pe : 0;
384
- }
385
404
  /** Build a single MilestoneSummary from a range of completed round entries. */
386
405
  function buildMilestoneFromEntries(phaseEntries, startRound, endRound, label, kind) {
387
406
  const last = phaseEntries.at(-1);
388
- const outcomeRaw = last
389
- ? (typeof last.output_summary === "string" ? last.output_summary : "")
390
- : "";
407
+ const lastView = last ? decodeRound(last) : null;
408
+ const outcomeRaw = lastView
409
+ ? (lastView.outputSummary ?? "")
410
+ : last && typeof last.output_summary === "string" ? last.output_summary : "";
391
411
  const outcomeMax = getPolicy().checkpoint.outcome_max_chars;
392
412
  const outcome = outcomeRaw.length > outcomeMax
393
413
  ? outcomeRaw.slice(0, outcomeMax) + "…"
@@ -468,24 +488,29 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
468
488
  const outcomes = [];
469
489
  const issues = [];
470
490
  for (const entry of windowEntries) {
471
- const data = lineage(entry);
491
+ const view = decodeRound(entry);
492
+ const data = entryLineage(entry);
472
493
  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 : "";
494
+ const success = view ? view.success : entry.success ?? data.success;
495
+ const summary = view
496
+ ? view.outputSummary ?? ""
497
+ : typeof entry.output_summary === "string"
498
+ ? entry.output_summary
499
+ : typeof data.output_summary === "string" ? data.output_summary : "";
477
500
  // v2.12: Declared outcome wins the label; the default wording stays
478
501
  // "accepted" for zero regression on existing loop output.
479
- const declared = entry.outcome;
502
+ const declared = view ? view.outcome : entry.outcome;
480
503
  const outcomeLabel = declared === "success" || declared === "partial" ||
481
504
  declared === "failed" || declared === "blocked"
482
505
  ? declared
483
506
  : success === false ? "failed" : "accepted";
484
507
  if (summary)
485
508
  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 : [];
509
+ const violations = view
510
+ ? view.constraintViolations ?? []
511
+ : Array.isArray(entry.constraint_violations)
512
+ ? entry.constraint_violations
513
+ : Array.isArray(data.constraint_violations) ? data.constraint_violations : [];
489
514
  issues.push(...violations.filter((item) => typeof item === "string"));
490
515
  }
491
516
  // ── Phase 2: Milestone Accumulation ──
@@ -497,7 +522,7 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
497
522
  for (let i = 0; i < completedEntries.length; i++) {
498
523
  const entry = completedEntries[i];
499
524
  const rnd = entryRound(entry);
500
- const lin = lineage(entry);
525
+ const lin = entryLineage(entry);
501
526
  // Signal 1: Agent-declared checkpoint
502
527
  const isAgentCheckpoint = lin.compression_checkpoint === true;
503
528
  // Signal 2: New criteria met (compare with previous vault entry)
@@ -521,10 +546,17 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
521
546
  lastMilestoneRound = rnd;
522
547
  }
523
548
  }
524
- // Signal 3: Safety net — auto milestone when gap exceeds milestone_interval
549
+ // Signal 3: Safety net — auto milestone when gap exceeds milestone_interval.
550
+ // v3.3.1: the policy contract (milestone_interval: "only fires when no
551
+ // agent_declared or criteria_milestone has been created for this many
552
+ // rounds") includes loops that NEVER created a milestone — but the guard
553
+ // `lastMilestoneRound > 0` made the gap start from the (nonexistent) first
554
+ // milestone, so a checkpoint-less loop could run forever with an empty
555
+ // Phase History and no memory skeleton. With lastMilestoneRound == 0 the
556
+ // gap is measured from round 1 and the safety net fires from the very
557
+ // first interval.
525
558
  const gapSinceLast = (currentRound - 1) - lastMilestoneRound;
526
- if (lastMilestoneRound > 0 &&
527
- gapSinceLast >= policy.milestone_interval &&
559
+ if (gapSinceLast >= policy.milestone_interval &&
528
560
  completedEntries.length > 0) {
529
561
  const startRound = lastMilestoneRound + 1;
530
562
  const endRound = currentRound - 1;
@@ -540,23 +572,6 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
540
572
  // L2 (full rehydration) keeps every milestone — the recovery view is the
541
573
  // loop's memory skeleton and must not be truncated.
542
574
  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
575
  if (windowEntries.length === 0 && cappedMilestones.length === 0)
561
576
  return null;
562
577
  return makeRollingSummary({
@@ -566,7 +581,6 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
566
581
  generated_at_round: currentRound,
567
582
  failed_patterns: [],
568
583
  milestones: cappedMilestones,
569
- loop_synthesis: loopSynthesis || undefined,
570
584
  });
571
585
  }
572
586
  // ═══════════════════════════════════════════════════════════════════════════
@@ -587,48 +601,13 @@ export function deriveConstraintId(text) {
587
601
  export function deriveCriterionId(text) {
588
602
  return "cr-" + deriveItemId(text);
589
603
  }
590
- /** v2.11: Check whether a user-provided reference looks like a constraint ID.
591
- * Matches the pattern c-XXXXXXXX where X is a hex digit. */
592
- export function isConstraintId(ref) {
593
- return /^c-[a-f0-9]{8}$/.test(ref);
594
- }
595
604
  /** v2.11: Check whether a user-provided reference looks like a criterion ID.
596
- * Matches the pattern cr-XXXXXXXX where X is a hex digit. */
597
- export function isCriterionId(ref) {
605
+ * Matches the pattern cr-XXXXXXXX where X is a hex digit. Module-local:
606
+ * consumers with the same need (round-contract.ts) keep their own copy to
607
+ * avoid an import cycle. */
608
+ function isCriterionId(ref) {
598
609
  return /^cr-[a-f0-9]{8}$/.test(ref);
599
610
  }
600
- /** Read emerged_subtasks from a vault entry (handles direct + lineage nesting). */
601
- function entryEmergedSubtasks(entry) {
602
- const direct = entry.emerged_subtasks;
603
- if (Array.isArray(direct))
604
- return direct.filter((v) => typeof v === "string");
605
- const lin = lineage(entry);
606
- const nested = lin.emerged_subtasks;
607
- if (Array.isArray(nested))
608
- return nested.filter((v) => typeof v === "string");
609
- return [];
610
- }
611
- /** Read completed_subtasks from an entry. */
612
- function entryCompletedSubtasks(entry) {
613
- const direct = entry.completed_subtasks;
614
- if (Array.isArray(direct))
615
- return direct.filter((v) => typeof v === "string");
616
- return [];
617
- }
618
- /** Read blocked_subtasks from an entry. */
619
- function entryBlockedSubtasks(entry) {
620
- const direct = entry.blocked_subtasks;
621
- if (Array.isArray(direct))
622
- return direct.filter((v) => typeof v === "string");
623
- return [];
624
- }
625
- /** Read canceled_subtasks from an entry. */
626
- function entryCanceledSubtasks(entry) {
627
- const direct = entry.canceled_subtasks;
628
- if (Array.isArray(direct))
629
- return direct.filter((v) => typeof v === "string");
630
- return [];
631
- }
632
611
  /**
633
612
  * Match a user-provided sub-task reference against existing SubGoals.
634
613
  *
@@ -736,35 +715,10 @@ function manageSubGoals(loopId, currentRound, lastRoundResult, vaultContext) {
736
715
  subGoals[idx].status_changed_at_round = rnd;
737
716
  }
738
717
  }
739
- // Phase 3 — Auto-derive: next_action match → in_progress
740
- const nextAction = lastRoundResult.next_action?.trim();
741
- if (nextAction) {
742
- for (const sg of subGoals) {
743
- if (sg.status === "pending") {
744
- if (jaccardSimilarity(nextAction, sg.description) >= policy.subgoal_auto_in_progress_threshold) {
745
- sg.status = "in_progress";
746
- sg.status_changed_at_round = rnd;
747
- break; // Only one sub-goal can be in_progress at a time
748
- }
749
- }
750
- }
751
- }
752
- // Phase 4 — Auto-complete: new criteria match → done
753
- const newCriteria = lastRoundResult.execution_evidence?.success_criteria_met ?? [];
754
- if (newCriteria.length > 0) {
755
- for (const sg of subGoals) {
756
- if (sg.status === "pending" || sg.status === "in_progress") {
757
- for (const crit of newCriteria) {
758
- if (jaccardSimilarity(crit, sg.description) >= policy.subgoal_auto_complete_threshold) {
759
- sg.status = "done";
760
- sg.status_changed_at_round = rnd;
761
- sg.completed_at_round = rnd;
762
- break;
763
- }
764
- }
765
- }
766
- }
767
- }
718
+ // v3.7: no auto status inference. The v2.3 fuzzy heuristics
719
+ // (next_action Jaccard → in_progress, criteria Jaccard → done) were
720
+ // removed — they fabricated dashboard motion the machine never
721
+ // verified. Statuses change only via the agent declarations above.
768
722
  }
769
723
  // Sort for display: in_progress first, then pending by age desc, then blocked, then done, then canceled
770
724
  subGoals.sort((a, b) => {
@@ -805,37 +759,6 @@ function constraintSource(text, objective, constraintsFromPlan) {
805
759
  return "criteria";
806
760
  return "discovered";
807
761
  }
808
- /** Find the earliest round a constraint was discovered by scanning vault entries.
809
- * v2.11: ID-first matching. Checks exact constraint ID match (c-XXXXXXXX)
810
- * against the derived ID of each vault entry's discovered_constraints, then
811
- * falls back to Jaccard similarity on text. */
812
- function findDiscoveredRound(text, vaultEntries) {
813
- const targetId = deriveConstraintId(text);
814
- let earliest = 0;
815
- for (const entry of vaultEntries) {
816
- const rnd = entryRound(entry);
817
- if (rnd < 1)
818
- continue;
819
- const discovered = Array.isArray(entry.discovered_constraints)
820
- ? entry.discovered_constraints.filter((v) => typeof v === "string")
821
- : [];
822
- for (const d of discovered) {
823
- // Phase 1: exact ID match (v2.11)
824
- if (d === targetId || deriveConstraintId(d) === targetId) {
825
- if (earliest === 0 || rnd < earliest)
826
- earliest = rnd;
827
- break;
828
- }
829
- // Phase 2: Jaccard fallback (backward compatibility)
830
- if (jaccardSimilarity(text, d) >= getPolicy().evolution.constraint_match_threshold) {
831
- if (earliest === 0 || rnd < earliest)
832
- earliest = rnd;
833
- break;
834
- }
835
- }
836
- }
837
- return earliest;
838
- }
839
762
  /** Find the latest round a constraint was violated by scanning vault entries.
840
763
  * v2.11: ID-first matching. Checks exact constraint ID match (c-XXXXXXXX)
841
764
  * against the derived ID of each vault entry's constraint_violations, then
@@ -856,9 +779,12 @@ function findLastViolatedRound(text, vaultEntries) {
856
779
  const rnd = entryRound(entry);
857
780
  if (rnd < 1)
858
781
  continue;
859
- const violations = Array.isArray(entry.constraint_violations)
860
- ? entry.constraint_violations.filter((v) => typeof v === "string")
861
- : [];
782
+ const view = decodeRound(entry);
783
+ const violations = view
784
+ ? view.constraintViolations ?? []
785
+ : Array.isArray(entry.constraint_violations)
786
+ ? entry.constraint_violations.filter((v) => typeof v === "string")
787
+ : [];
862
788
  for (const v of violations) {
863
789
  if (matchesConstraintText(text, v)) {
864
790
  if (rnd > latest)
@@ -870,15 +796,17 @@ function findLastViolatedRound(text, vaultEntries) {
870
796
  return latest;
871
797
  }
872
798
  /**
873
- * Manage constraint lifecycle with time-aware decay.
799
+ * Manage constraint lifecycle metadata for the active set.
874
800
  *
875
- * Discovered constraints that haven't been violated for N rounds are demoted
876
- * to inactive — removed from prompts but kept in state. Hard/plan/criteria
877
- * constraints never auto-decay. Inactive constraints are auto-reactivated
878
- * if violated again in the current round.
801
+ * v3.7: the v2.3 time-aware decay (discovered constraints demoted to
802
+ * inactive after N quiet rounds, auto-reactivation on a fresh violation)
803
+ * is gone — every active constraint stays active. What remains is the
804
+ * display metadata the v3 prompt surface needs: per-text source
805
+ * classification (v3.3.1: criteria texts carry the cr-XXXXXXXX namespace),
806
+ * and last_violated_at_round for the L1 collapse "(violated this round)"
807
+ * annotation (diffConstraints compares it to the current round).
879
808
  */
880
809
  function manageConstraintLifecycle(active, objective, constraintsFromPlan, vaultContext, currentRound, lastRoundViolations = []) {
881
- const policy = getPolicy().evolution;
882
810
  const allEntries = loopEntries(objective.loop_id || "", vaultContext);
883
811
  const completedEntries = allEntries.filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound);
884
812
  const metadata = [];
@@ -888,50 +816,25 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
888
816
  // as a violation at currentRound — the previous rounds' scan cannot see
889
817
  // it (this round is not committed yet). This makes the L1 collapse
890
818
  // render "(violated this round)" (diffConstraints compares
891
- // last_violated_at_round === round) and re-activates decayed
892
- // discovered constraints.
819
+ // last_violated_at_round === round).
893
820
  const violatedThisRound = lastRoundViolations.some((v) => matchesConstraintText(text, v));
894
- // Structural constraints never decay
895
- if (source === "hard" || source === "plan" || source === "criteria") {
896
- metadata.push(makeConstraintMeta({
897
- id: deriveConstraintId(text),
898
- text,
899
- discovered_at_round: objective.created_at_round || 1,
900
- last_violated_at_round: violatedThisRound
901
- ? currentRound
902
- : findLastViolatedRound(text, completedEntries),
903
- source,
904
- status: "active",
905
- }));
906
- continue;
907
- }
908
- // Discovered constraints: check for inactivity
909
- const discoveredAt = findDiscoveredRound(text, completedEntries);
910
- const lastViolated = violatedThisRound
911
- ? currentRound
912
- : findLastViolatedRound(text, completedEntries);
913
- const roundsSinceRelevant = lastViolated > 0
914
- ? currentRound - lastViolated
915
- : currentRound - (discoveredAt || currentRound);
916
- const status = roundsSinceRelevant > policy.constraint_inactive_rounds
917
- ? "inactive"
918
- : "active";
821
+ // v3.3.1: one text, one identity — a success-criterion text that also
822
+ // lives in the merged active set belongs to the cr-XXXXXXXX namespace
823
+ // (its own Success Criteria section and the verification gate's
824
+ // criteriaMatch are ID-first on cr-). Rendering it under c-XXXXXXXX in
825
+ // the active list meant the same text carried two IDs in one prompt,
826
+ // and an agent echoing the active-list ID into success_criteria_met
827
+ // could never match.
919
828
  metadata.push(makeConstraintMeta({
920
- id: deriveConstraintId(text),
829
+ id: source === "criteria" ? deriveCriterionId(text) : deriveConstraintId(text),
921
830
  text,
922
- discovered_at_round: discoveredAt || 0,
923
- last_violated_at_round: lastViolated,
924
- source: "discovered",
925
- status,
831
+ last_violated_at_round: violatedThisRound
832
+ ? currentRound
833
+ : findLastViolatedRound(text, completedEntries),
834
+ source,
926
835
  }));
927
836
  }
928
- const newActive = metadata
929
- .filter((m) => m.status === "active")
930
- .map((m) => m.text);
931
- const inactive = metadata
932
- .filter((m) => m.status === "inactive")
933
- .map((m) => m.text);
934
- return { active: newActive, inactive, metadata };
837
+ return { active, metadata };
935
838
  }
936
839
  export function alignTask(proposedTask, request, context) {
937
840
  const objective = request.loop_objective ?? latestObjective(request.loop_id, context);
@@ -976,7 +879,7 @@ function levelDecision(request, context) {
976
879
  last?.revised_success_criteria?.length ||
977
880
  last?.wrong_assumptions?.length);
978
881
  const lastFullRound = loopEntries(request.loop_id, context)
979
- .filter((entry) => lineage(entry).recompile_level === "l2")
882
+ .filter((entry) => entryLineage(entry).recompile_level === "l2")
980
883
  .map(entryRound)
981
884
  .filter((round) => round < request.round)
982
885
  .at(-1) ?? 1;
@@ -985,7 +888,7 @@ function levelDecision(request, context) {
985
888
  // rehydration (the decidePromptLevel recovery_boundary branch existed
986
889
  // but no caller ever set it).
987
890
  const recoveryBoundary = loopEntries(request.loop_id, context)
988
- .some((entry) => lineage(entry).committed_action === "backtrack" &&
891
+ .some((entry) => entryLineage(entry).committed_action === "backtrack" &&
989
892
  entryRound(entry) === request.round);
990
893
  return decidePromptLevel({
991
894
  round: request.round,
@@ -1011,11 +914,24 @@ export function buildSelfEvalBlock(round, prevDriftFlags,
1011
914
  /** v2.12: L0 is the minimal retry template — the v2.12 declarative fields
1012
915
  * (outcome/blocker/retroactiveClaims) are L1/L2 additions so the retry
1013
916
  * prompt stays within its tight budget. */
1014
- level) {
917
+ level,
918
+ /** v3.3/v3.4: Whether this round's Current Task IS the ACTIVE Round
919
+ * Contract (derived from committed rounds — compileLoop passes
920
+ * `activeContract != null`). Only then does the template ask the agent
921
+ * to restate/propose it — a generic empty contract template would invite
922
+ * placeholder submissions that trigger round_underspecified noise. */
923
+ hasContract = false,
924
+ /** v3.5: L2-only prose suggesting a Round Contract declaration when the
925
+ * Current Task is NOT one (contract_nudge_on_l2 policy, computed at the
926
+ * compileLoop call site). Mutually exclusive with hasContract. The prose
927
+ * must never contain the JSON key name `round_contract` — contract-less
928
+ * L2 tests assert its lowercase absence. */
929
+ proposalNudge = false) {
1015
930
  const hasIntentDrift = prevDriftFlags?.some(f => f.check === "intent_drift") ?? false;
1016
931
  const hasSubGoalDrift = prevDriftFlags?.some(f => f.check === "subgoal_drift") ?? false;
1017
932
  const needsClarification = hasIntentDrift || hasSubGoalDrift;
1018
933
  const declareOutcome = level !== "l0";
934
+ const restateContract = hasContract && declareOutcome;
1019
935
  const evalObj = {
1020
936
  success: false,
1021
937
  output_summary: `<verified result of round ${round}>`,
@@ -1042,6 +958,20 @@ level) {
1042
958
  evalObj.blocker = "<required only when outcome=blocked>";
1043
959
  evalObj.retroactiveClaims = [];
1044
960
  }
961
+ // v3.3/v3.4: Round Contract proposal template (L1/L2, active-contract
962
+ // rounds only — L0 retry stays lean, and rounds without an active
963
+ // contract never see the template). When the prompt's Current Task IS the
964
+ // ACTIVE contract, the agent restates it unchanged as its proposal; when
965
+ // the active contract's done_when are all satisfied (or the round is
966
+ // blocked), a NEW contract is declared for the next round instead.
967
+ if (restateContract) {
968
+ evalObj.round_contract = {
969
+ work_item: "<this round's focus, or empty>",
970
+ done_when: [],
971
+ verification_plan: [],
972
+ scope: [],
973
+ };
974
+ }
1045
975
  // v2.8: Inject drift clarification field when previous round had drift flags
1046
976
  if (needsClarification) {
1047
977
  const flagTypes = [
@@ -1057,19 +987,27 @@ level) {
1057
987
  "### LoopForge Evaluation (Required)",
1058
988
  "",
1059
989
  `After completing Round ${round}, replace the placeholder values below`,
1060
- "with your actual results and submit via `loopforge_next`.",
990
+ "with your actual results and pass this object as `loopforge_next.evaluation`.",
1061
991
  ];
1062
992
  if (needsClarification) {
1063
993
  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
994
  }
1065
- lines.push("---loopforge-eval");
995
+ lines.push("```json");
1066
996
  lines.push(JSON.stringify(evalObj, null, 2));
1067
- lines.push("---end-loopforge-eval");
997
+ lines.push("```");
1068
998
  lines.push("");
1069
999
  lines.push("IMPORTANT: Replace every <placeholder> with your actual data.");
1070
1000
  lines.push("Set success=true only when the full goal and ALL hard constraints are verified.");
1071
1001
  lines.push("Use IDs for exact matching: c-XXXXXXXX, cr-XXXXXXXX, sg-XXXXXXXX.");
1072
1002
  lines.push("For subtasks, use sub-goal IDs from the Sub-Goal Dashboard above.");
1003
+ // v3.5: L2 contract-less nudge (prose only — never the JSON key name, so
1004
+ // contract-less rounds keep their template-lean assertions).
1005
+ if (proposalNudge) {
1006
+ 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.");
1007
+ }
1008
+ if (restateContract) {
1009
+ 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).");
1010
+ }
1073
1011
  return lines.join("\n");
1074
1012
  }
1075
1013
  // v2.5: checkpoint() removed — agent_declared milestones from
@@ -1104,15 +1042,22 @@ export function compileLoop(request, context) {
1104
1042
  // No new persistence; reconstructed same as milestones/sub-goals.
1105
1043
  const completedEntries = loopEntries(request.loop_id, context)
1106
1044
  .filter((e) => entryRound(e) >= 1 && entryRound(e) < request.round);
1045
+ const committedRounds = mergedRoundsFromEntries(completedEntries, request.round);
1107
1046
  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
- : [];
1047
+ for (const round of committedRounds.slice(-10)) {
1048
+ const viols = round.evaluation?.constraint_violations ?? [];
1112
1049
  // Simple proxy: no violations = perfect trust, each violation = -0.1
1113
1050
  const score = Math.max(0, 1.0 - viols.length * 0.1);
1114
1051
  trend.push(Number(score.toFixed(2)));
1115
1052
  }
1053
+ // v3.3: Round stats + machine git-motion status (display-only, derived).
1054
+ const roundStats = deriveRoundStats(committedRounds);
1055
+ const machineStatus = deriveMachineStatus(committedRounds, request.round);
1056
+ // v3.4: ACTIVE Round Contract for this compile — derived from committed
1057
+ // rounds, so rejection retries / resume / unpause / backtrack compiles
1058
+ // (no last_round_result) keep showing the contract as Current Task, and a
1059
+ // satisfied or blocked contract stops being shown.
1060
+ const activeContract = deriveActiveContract(request.loop_id, context, request.round);
1116
1061
  const response = makeLoopCompileResponse({
1117
1062
  status: AgentStatus.OK,
1118
1063
  recompile_level: decision.level,
@@ -1120,7 +1065,6 @@ export function compileLoop(request, context) {
1120
1065
  lineage: [`${request.loop_id}:r${request.round}`],
1121
1066
  constraints_active: constraintLifecycle.active,
1122
1067
  constraints_retired: constraints.retired,
1123
- constraints_inactive: constraintLifecycle.inactive,
1124
1068
  constraint_metadata: constraintLifecycle.metadata,
1125
1069
  loop_id: request.loop_id,
1126
1070
  round: request.round,
@@ -1142,7 +1086,12 @@ export function compileLoop(request, context) {
1142
1086
  });
1143
1087
  const policy = getPolicy();
1144
1088
  const statePath = `${policy.state_file.directory}/${request.loop_id}-state.md`;
1145
- const state = createCanonicalLoopState(request, response, statePath);
1089
+ const state = createCanonicalLoopState(request, response, statePath, {
1090
+ roundStats,
1091
+ machineStatus,
1092
+ // v3.4: derived active contract — the Current Task's source of truth.
1093
+ roundContract: activeContract,
1094
+ });
1146
1095
  const markdown = renderCanonicalStateMarkdown(state);
1147
1096
  // v2.4–v2.5: Adaptive L2 budget — scales with loop complexity
1148
1097
  const adaptiveL2 = policy.prompt.l2_adaptive_enabled
@@ -1159,14 +1108,21 @@ export function compileLoop(request, context) {
1159
1108
  state,
1160
1109
  level: decision.level,
1161
1110
  reasons: decision.reasons,
1162
- mode: policy.prompt.injection_mode,
1163
1111
  budgets: {
1164
1112
  l0: policy.prompt.l0_max_chars,
1165
1113
  l1: policy.prompt.l1_max_chars,
1166
1114
  l2: adaptiveL2,
1167
1115
  },
1168
1116
  attempt: request.attempt,
1169
- selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags, decision.level),
1117
+ selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags, decision.level,
1118
+ // v3.4: restate the contract only when this prompt's Current Task IS
1119
+ // the ACTIVE contract (derived — not the previous submission's field,
1120
+ // which is a proposal and may differ from what this round executes).
1121
+ activeContract != null,
1122
+ // v3.5: L2-only declaration nudge when nothing is active (policy-gated;
1123
+ // mutually exclusive with the restate template above).
1124
+ decision.level === "l2" && activeContract === null &&
1125
+ getPolicy().prompt.contract_nudge_on_l2),
1170
1126
  fullStateMarkdown: l2Pointer ? undefined : markdown,
1171
1127
  // v2.9: Model's information needs from the previous round's SelfEvaluation
1172
1128
  promptRequests: request.last_round_result?.prompt_requests,