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