loopforge 3.0.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +126 -143
  2. package/dist/audit.d.ts +50 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +131 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/canonical-state.d.ts +98 -23
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +257 -181
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +40 -33
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts +17 -0
  14. package/dist/cognitive-facts.d.ts.map +1 -0
  15. package/dist/cognitive-facts.js +99 -0
  16. package/dist/cognitive-facts.js.map +1 -0
  17. package/dist/cognitive-governance.d.ts +25 -33
  18. package/dist/cognitive-governance.d.ts.map +1 -1
  19. package/dist/cognitive-governance.js +38 -138
  20. package/dist/cognitive-governance.js.map +1 -1
  21. package/dist/committed-round.d.ts +59 -0
  22. package/dist/committed-round.d.ts.map +1 -0
  23. package/dist/committed-round.js +222 -0
  24. package/dist/committed-round.js.map +1 -0
  25. package/dist/enforcement-gate.d.ts +78 -0
  26. package/dist/enforcement-gate.d.ts.map +1 -0
  27. package/dist/enforcement-gate.js +1109 -0
  28. package/dist/enforcement-gate.js.map +1 -0
  29. package/dist/engine.d.ts +111 -0
  30. package/dist/engine.d.ts.map +1 -0
  31. package/dist/engine.js +887 -0
  32. package/dist/engine.js.map +1 -0
  33. package/dist/evidence-claims.d.ts +50 -0
  34. package/dist/evidence-claims.d.ts.map +1 -0
  35. package/dist/evidence-claims.js +116 -0
  36. package/dist/evidence-claims.js.map +1 -0
  37. package/dist/evidence-provider.d.ts +50 -23
  38. package/dist/evidence-provider.d.ts.map +1 -1
  39. package/dist/evidence-provider.js +167 -50
  40. package/dist/evidence-provider.js.map +1 -1
  41. package/dist/generate-schema.js +1 -84
  42. package/dist/generate-schema.js.map +1 -1
  43. package/dist/index.d.ts +19 -22
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +16 -15
  46. package/dist/index.js.map +1 -1
  47. package/dist/loop-compiler.d.ts +58 -3
  48. package/dist/loop-compiler.d.ts.map +1 -1
  49. package/dist/loop-compiler.js +841 -304
  50. package/dist/loop-compiler.js.map +1 -1
  51. package/dist/loop-extras-parser.d.ts +2 -2
  52. package/dist/loop-extras-parser.d.ts.map +1 -1
  53. package/dist/loop-extras-parser.js +2 -2
  54. package/dist/loop-extras-parser.js.map +1 -1
  55. package/dist/loop-projection.d.ts +6 -0
  56. package/dist/loop-projection.d.ts.map +1 -0
  57. package/dist/loop-projection.js +26 -0
  58. package/dist/loop-projection.js.map +1 -0
  59. package/dist/loop-store.d.ts +115 -24
  60. package/dist/loop-store.d.ts.map +1 -1
  61. package/dist/loop-store.js +365 -218
  62. package/dist/loop-store.js.map +1 -1
  63. package/dist/mcp/round-lifecycle.d.ts +214 -0
  64. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  65. package/dist/mcp/round-lifecycle.js +935 -0
  66. package/dist/mcp/round-lifecycle.js.map +1 -0
  67. package/dist/mcp/server.d.ts +2 -1
  68. package/dist/mcp/server.d.ts.map +1 -1
  69. package/dist/mcp/server.js +29 -43
  70. package/dist/mcp/server.js.map +1 -1
  71. package/dist/mcp/session.d.ts +64 -101
  72. package/dist/mcp/session.d.ts.map +1 -1
  73. package/dist/mcp/session.js +433 -1110
  74. package/dist/mcp/session.js.map +1 -1
  75. package/dist/mcp/tools.d.ts +20 -11
  76. package/dist/mcp/tools.d.ts.map +1 -1
  77. package/dist/mcp/tools.js +587 -413
  78. package/dist/mcp/tools.js.map +1 -1
  79. package/dist/policy-metrics.d.ts +12 -24
  80. package/dist/policy-metrics.d.ts.map +1 -1
  81. package/dist/policy-metrics.js +78 -184
  82. package/dist/policy-metrics.js.map +1 -1
  83. package/dist/policy.d.ts +61 -50
  84. package/dist/policy.d.ts.map +1 -1
  85. package/dist/policy.js +48 -146
  86. package/dist/policy.js.map +1 -1
  87. package/dist/prompt-assembler.d.ts +37 -6
  88. package/dist/prompt-assembler.d.ts.map +1 -1
  89. package/dist/prompt-assembler.js +487 -278
  90. package/dist/prompt-assembler.js.map +1 -1
  91. package/dist/prompt-policy.d.ts +1 -8
  92. package/dist/prompt-policy.d.ts.map +1 -1
  93. package/dist/prompt-policy.js +8 -26
  94. package/dist/prompt-policy.js.map +1 -1
  95. package/dist/protocol.d.ts +398 -236
  96. package/dist/protocol.d.ts.map +1 -1
  97. package/dist/protocol.js +147 -17
  98. package/dist/protocol.js.map +1 -1
  99. package/dist/replay.d.ts +5 -13
  100. package/dist/replay.d.ts.map +1 -1
  101. package/dist/replay.js +32 -69
  102. package/dist/replay.js.map +1 -1
  103. package/dist/round-contract.d.ts +64 -0
  104. package/dist/round-contract.d.ts.map +1 -0
  105. package/dist/round-contract.js +80 -0
  106. package/dist/round-contract.js.map +1 -0
  107. package/dist/round-coordinator.d.ts +23 -25
  108. package/dist/round-coordinator.d.ts.map +1 -1
  109. package/dist/round-coordinator.js +103 -92
  110. package/dist/round-coordinator.js.map +1 -1
  111. package/dist/round-driver.d.ts +13 -13
  112. package/dist/round-driver.d.ts.map +1 -1
  113. package/dist/round-driver.js +25 -14
  114. package/dist/round-driver.js.map +1 -1
  115. package/dist/round-transaction.d.ts +14 -18
  116. package/dist/round-transaction.d.ts.map +1 -1
  117. package/dist/round-transaction.js +37 -47
  118. package/dist/round-transaction.js.map +1 -1
  119. package/dist/self-eval.d.ts +48 -0
  120. package/dist/self-eval.d.ts.map +1 -0
  121. package/dist/self-eval.js +248 -0
  122. package/dist/self-eval.js.map +1 -0
  123. package/dist/storage.d.ts +9 -4
  124. package/dist/storage.d.ts.map +1 -1
  125. package/dist/storage.js +18 -6
  126. package/dist/storage.js.map +1 -1
  127. package/dist/token-utils.d.ts +15 -3
  128. package/dist/token-utils.d.ts.map +1 -1
  129. package/dist/token-utils.js +24 -5
  130. package/dist/token-utils.js.map +1 -1
  131. package/dist/verification-gate.d.ts +149 -0
  132. package/dist/verification-gate.d.ts.map +1 -0
  133. package/dist/verification-gate.js +1343 -0
  134. package/dist/verification-gate.js.map +1 -0
  135. package/dist/workspace.d.ts +29 -0
  136. package/dist/workspace.d.ts.map +1 -0
  137. package/dist/workspace.js +61 -0
  138. package/dist/workspace.js.map +1 -0
  139. package/loop_policy.json +37 -16
  140. package/package.json +5 -10
  141. package/skills/perception/SKILL.md +184 -202
  142. package/dist/canonical-event-writer.d.ts +0 -20
  143. package/dist/canonical-event-writer.d.ts.map +0 -1
  144. package/dist/canonical-event-writer.js +0 -131
  145. package/dist/canonical-event-writer.js.map +0 -1
  146. package/dist/compiler-subgoals.d.ts +0 -7
  147. package/dist/compiler-subgoals.d.ts.map +0 -1
  148. package/dist/compiler-subgoals.js +0 -93
  149. package/dist/compiler-subgoals.js.map +0 -1
  150. package/dist/derive-id.d.ts +0 -12
  151. package/dist/derive-id.d.ts.map +0 -1
  152. package/dist/derive-id.js +0 -31
  153. package/dist/derive-id.js.map +0 -1
  154. package/dist/mcp/session-projection.d.ts +0 -5
  155. package/dist/mcp/session-projection.d.ts.map +0 -1
  156. package/dist/mcp/session-projection.js +0 -18
  157. package/dist/mcp/session-projection.js.map +0 -1
  158. package/dist/mcp/session-queue.d.ts +0 -3
  159. package/dist/mcp/session-queue.d.ts.map +0 -1
  160. package/dist/mcp/session-queue.js +0 -18
  161. package/dist/mcp/session-queue.js.map +0 -1
  162. package/dist/mcp/session-types.d.ts +0 -72
  163. package/dist/mcp/session-types.d.ts.map +0 -1
  164. package/dist/mcp/session-types.js +0 -2
  165. package/dist/mcp/session-types.js.map +0 -1
  166. package/dist/round-commit-store.d.ts +0 -12
  167. package/dist/round-commit-store.d.ts.map +0 -1
  168. package/dist/round-commit-store.js +0 -13
  169. package/dist/round-commit-store.js.map +0 -1
  170. package/dist/round-gate.d.ts +0 -31
  171. package/dist/round-gate.d.ts.map +0 -1
  172. package/dist/round-gate.js +0 -259
  173. package/dist/round-gate.js.map +0 -1
  174. package/dist/round-recovery.d.ts +0 -17
  175. package/dist/round-recovery.d.ts.map +0 -1
  176. package/dist/round-recovery.js +0 -117
  177. package/dist/round-recovery.js.map +0 -1
  178. package/dist/round-report.d.ts +0 -16
  179. package/dist/round-report.d.ts.map +0 -1
  180. package/dist/round-report.js +0 -401
  181. package/dist/round-report.js.map +0 -1
  182. package/dist/runtime-services.d.ts +0 -59
  183. package/dist/runtime-services.d.ts.map +0 -1
  184. package/dist/runtime-services.js +0 -460
  185. package/dist/runtime-services.js.map +0 -1
  186. package/dist/vault-format.d.ts +0 -4
  187. package/dist/vault-format.d.ts.map +0 -1
  188. package/dist/vault-format.js +0 -4
  189. package/dist/vault-format.js.map +0 -1
  190. package/dist/vault-types.d.ts +0 -24
  191. package/dist/vault-types.d.ts.map +0 -1
  192. package/dist/vault-types.js +0 -2
  193. package/dist/vault-types.js.map +0 -1
  194. package/loopforge-policy.json +0 -340
@@ -3,23 +3,22 @@
3
3
  * The compiler evolves structured state and renders one prompt artifact.
4
4
  * L0/L1/L2 control state density only; the external Agent owns reasoning.
5
5
  */
6
- import { deriveConstraintId, deriveCriterionId, deriveTextHash, } from "./derive-id.js";
6
+ import { createHash } from "node:crypto";
7
7
  import { getPolicy } from "./policy.js";
8
- import { AgentStatus, makeLoopCompileResponse, makeLoopCompileProjection, makeLoopHealth, makeLoopObjective, makeConstraintMeta, makeMilestoneSummary, makeRollingSummary, makeTaskAlignment, } from "./protocol.js";
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 { auditEntries, deriveGate, deriveStructuredGate } from "./cognitive-governance.js";
11
- import { parseRoundReport, deriveCompilerProjection } from "./round-report.js";
10
+ import { decodeCommittedRound, decodeMergedRound, machineGitMotionSeries, mergedRoundsFromEntries, } from "./committed-round.js";
11
+ import { contractRoundEvaluations, deriveActiveRoundContract, } from "./round-contract.js";
12
12
  import { assemblePromptArtifact } from "./prompt-assembler.js";
13
13
  import { decidePromptLevel, } from "./prompt-policy.js";
14
- import { jaccardSimilarity, unique, entryRound } from "./token-utils.js";
15
- import { manageSubGoals as deriveSubGoals } from "./compiler-subgoals.js";
14
+ import { deriveItemId, jaccardSimilarity, unique, entryRound } from "./token-utils.js";
16
15
  function contextEntries(context) {
17
16
  if (!context || !Array.isArray(context.results))
18
17
  return [];
19
18
  return context.results.filter((value) => value !== null && typeof value === "object" && !Array.isArray(value));
20
19
  }
21
20
  function lineage(entry) {
22
- const value = entry.loop_lineage;
21
+ const value = entry.loop_lineage ?? entry.lineage;
23
22
  return value !== null && typeof value === "object" && !Array.isArray(value)
24
23
  ? value
25
24
  : {};
@@ -29,12 +28,38 @@ function loopEntries(loopId, context) {
29
28
  .filter((entry) => entry.loop_id === loopId || lineage(entry).loop_id === loopId)
30
29
  .sort((a, b) => entryRound(a) - entryRound(b));
31
30
  }
32
- function entryProjection(entry) {
33
- const report = parseRoundReport(entry.round_report);
34
- return report ? deriveCompilerProjection(report, entryRound(entry)) : null;
31
+ /** v3.3.1: The canonical entry for a round — its compile-time lineage entry
32
+ * (task_type "loop_lineage"; engine hydration merges the committed
33
+ * decision into it, so one round = one merged lineage entry in the
34
+ * compiler's view). Event/journal entries (delegation journals, gate
35
+ * records) share lineage.round with the round but carry none of its
36
+ * compile-time fields; round-level consumers that matched on the round
37
+ * number alone could hit them first (they sort after the lineage entry of
38
+ * the same round), producing an empty goal_id / task / constraints_active
39
+ * and forcing spurious L2 recovery. A direct :feedback entry is accepted
40
+ * when the caller supplies a raw committed view instead of hydration. */
41
+ function roundCanonicalEntry(entries, round) {
42
+ const candidates = entries.filter((entry) => entryRound(entry) === round);
43
+ return (candidates.find((entry) => entry.task_type === "loop_lineage") ??
44
+ candidates.find((entry) => String(entry.task_id ?? "").endsWith(":feedback")) ??
45
+ // Untyped compiler inputs are treated as round views, not events. Known event
46
+ // types (delegation journals, gate records) are excluded explicitly so
47
+ // they can never shadow a round again.
48
+ candidates.find((entry) => {
49
+ const type = String(entry.task_type ?? "");
50
+ return type === "" || (!type.startsWith("delegation") && !type.endsWith("journal"));
51
+ }) ??
52
+ null);
53
+ }
54
+ /** Return the canonical read model when an entry is a committed round.
55
+ * Uncommitted compiler lineage has no durable decision yet and deliberately
56
+ * remains on the local compile path; it cannot be treated as history. */
57
+ function committedView(entry) {
58
+ return decodeMergedRound(entry) ?? decodeCommittedRound(entry);
35
59
  }
36
60
  export function computeGoalTextHash(text) {
37
- return deriveTextHash(text, 12);
61
+ const normalized = text.trim().replace(/\s+/g, " ").toLowerCase();
62
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
38
63
  }
39
64
  export function deriveGoalId(loopId, task, explicit = "") {
40
65
  return explicit.trim() || `${loopId}:${task.trim().toLowerCase()
@@ -42,9 +67,34 @@ export function deriveGoalId(loopId, task, explicit = "") {
42
67
  .replace(/^-|-$/g, "")
43
68
  .slice(0, 48) || computeGoalTextHash(task)}`;
44
69
  }
70
+ /** v3.2: Read the previous round's persisted L1 presentation snapshot — the
71
+ * diff baseline for L1 collapse. Returns null when the previous round's
72
+ * lineage entry lacks the three presented_* fields (as on L0/L2 compiles),
73
+ * so callers render the full L1 state. */
74
+ export function readPresentedBaseline(loopId, round, context) {
75
+ const entry = roundCanonicalEntry(loopEntries(loopId, context), round - 1);
76
+ if (!entry)
77
+ return null;
78
+ const data = lineage(entry);
79
+ const constraintIds = data.presented_constraint_ids;
80
+ const subGoals = data.presented_subgoals;
81
+ const milestoneRanges = data.presented_milestone_ranges;
82
+ if (!Array.isArray(constraintIds) || !constraintIds.every((v) => typeof v === "string") ||
83
+ !Array.isArray(subGoals) || !subGoals.every((v) => Array.isArray(v) && v.length === 2 &&
84
+ typeof v[0] === "string" && typeof v[1] === "string") ||
85
+ !Array.isArray(milestoneRanges) || !milestoneRanges.every((v) => Array.isArray(v) && v.length === 2 &&
86
+ typeof v[0] === "number" && typeof v[1] === "number")) {
87
+ return null;
88
+ }
89
+ return {
90
+ round: round - 1,
91
+ constraintIds: constraintIds,
92
+ subGoals: subGoals,
93
+ milestoneRanges: milestoneRanges,
94
+ };
95
+ }
45
96
  export function getPreviousRound(loopId, round, context) {
46
- const entry = [...loopEntries(loopId, context)].reverse()
47
- .find((candidate) => entryRound(candidate) === round);
97
+ const entry = roundCanonicalEntry(loopEntries(loopId, context), round);
48
98
  if (!entry)
49
99
  return null;
50
100
  const data = lineage(entry);
@@ -61,7 +111,9 @@ export function getPreviousRound(loopId, round, context) {
61
111
  constraints_active: Array.isArray(data.constraints_active)
62
112
  ? data.constraints_active.filter((value) => typeof value === "string")
63
113
  : [],
64
- output_summary: entryProjection(entry)?.summary ?? "",
114
+ output_summary: typeof entry.output_summary === "string"
115
+ ? entry.output_summary
116
+ : typeof data.output_summary === "string" ? data.output_summary : "",
65
117
  };
66
118
  }
67
119
  function latestObjective(loopId, context) {
@@ -98,17 +150,17 @@ function evolveObjective(request, context) {
98
150
  created_at_round: 1,
99
151
  loop_id: request.loop_id,
100
152
  });
101
- const last = request.last_round_view;
153
+ const last = request.last_round_result;
102
154
  const history = [...(current.refinement_history ?? [])];
103
155
  let objective = current.objective || request.task;
104
156
  let version = current.version ?? 1;
105
- if (last?.objectiveRefinement?.trim()) {
106
- const refinement = last.objectiveRefinement.trim();
157
+ if (last?.objective_refinement?.trim()) {
158
+ const refinement = last.objective_refinement.trim();
107
159
  history.push(refinement);
108
160
  objective = `${objective}\nRefinement: ${refinement}`;
109
161
  version++;
110
162
  }
111
- const revisions = new Map((last?.criterionRevisions ?? []).map((item) => [item.old, item.new]));
163
+ const revisions = new Map((last?.revised_success_criteria ?? []).map((item) => [item.old, item.new]));
112
164
  return makeLoopObjective({
113
165
  ...current,
114
166
  objective,
@@ -119,71 +171,257 @@ function evolveObjective(request, context) {
119
171
  loop_id: request.loop_id,
120
172
  });
121
173
  }
122
- function evolveConstraints(request, objective, previous) {
123
- const retired = unique(request.last_round_view?.constraintEvents?.retired ?? []);
174
+ function evolveConstraints(request, objective, previous, context) {
175
+ const retired = unique(request.last_round_result?.retracted_constraints ?? []);
124
176
  const retiredSet = new Set(retired);
125
- const discovered = unique(request.last_round_view?.discoveries?.newConstraints ?? [])
177
+ const discovered = unique(request.last_round_result?.discovered_constraints ?? [])
126
178
  .slice(0, getPolicy().evolution.max_discovered_constraints_per_round);
179
+ // v2.14: agent-declared retractions of plan/hard/criteria constraints
180
+ // persist for constraints.retire_window rounds (they are re-merged from
181
+ // the request/objective every round, so without this the documented
182
+ // "Removed from the active constraint set" never held for them). The
183
+ // retraction rounds are derived from vault entries — no new persistence
184
+ // format. After the window the constraint returns (hard/plan/criteria
185
+ // constraints never auto-decay — only agent action removes them, and the
186
+ // action's memory expires).
187
+ const window = getPolicy().constraints.retire_window;
188
+ const lastRetractedRound = new Map();
189
+ if (window > 0) {
190
+ for (const entry of loopEntries(request.loop_id, context)) {
191
+ const retractedList = lineage(entry).retracted_constraints;
192
+ if (!Array.isArray(retractedList))
193
+ continue;
194
+ const rnd = entryRound(entry);
195
+ for (const text of retractedList) {
196
+ if (typeof text !== "string" || !text)
197
+ continue;
198
+ const prev = lastRetractedRound.get(text);
199
+ if (prev === undefined || rnd > prev)
200
+ lastRetractedRound.set(text, rnd);
201
+ }
202
+ }
203
+ }
127
204
  const active = unique([
128
205
  ...(previous?.constraints_active ?? []),
129
206
  ...request.constraints_from_plan,
130
207
  ...objective.hard_constraints,
131
208
  ...objective.success_criteria,
132
209
  ...discovered,
133
- ]).filter((item) => !retiredSet.has(item))
134
- .slice(0, getPolicy().evolution.max_active_constraints);
210
+ ]).filter((item) => {
211
+ if (retiredSet.has(item))
212
+ return false;
213
+ const retractedRound = lastRetractedRound.get(item);
214
+ if (retractedRound !== undefined && request.round - retractedRound < window) {
215
+ return false;
216
+ }
217
+ return true;
218
+ }).slice(0, getPolicy().evolution.max_active_constraints);
135
219
  return { active, retired };
136
220
  }
137
221
  // ═══════════════════════════════════════════════════════════════════════════
138
222
  // Hierarchical Summary (v2.1)
139
223
  // ═══════════════════════════════════════════════════════════════════════════
224
+ /** Extract execution_evidence from a vault entry, handling both direct-field
225
+ * and lineage-nested shapes. Returns null when absent. */
226
+ function entryExecutionEvidence(entry) {
227
+ const view = committedView(entry);
228
+ if (view)
229
+ return view.executionEvidence;
230
+ const direct = entry.execution_evidence;
231
+ if (direct && typeof direct === "object" && !Array.isArray(direct)) {
232
+ return direct;
233
+ }
234
+ const lin = lineage(entry);
235
+ const nested = lin.execution_evidence;
236
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
237
+ return nested;
238
+ }
239
+ return null;
240
+ }
241
+ /** Read success_criteria_met from an entry's execution_evidence. */
140
242
  function entryCriteriaMet(entry) {
141
- return entryProjection(entry)?.evidence?.verifiedClaims ?? [];
142
- }
143
- function verifiedClaims(loopId, context) {
144
- const facts = new Set();
145
- const refs = new Set();
146
- for (const entry of loopEntries(loopId, context)) {
147
- const report = entry.round_report;
148
- if (!report || typeof report !== "object" || Array.isArray(report))
149
- continue;
150
- const evidence = report.evidence;
151
- if (!evidence || typeof evidence !== "object" || Array.isArray(evidence))
152
- continue;
153
- const envelope = evidence;
154
- const provenance = envelope.provenance;
155
- if (!provenance || typeof provenance !== "object" || Array.isArray(provenance))
156
- continue;
157
- const provenanceMap = provenance;
158
- if (!Array.isArray(envelope.claims))
159
- continue;
160
- for (const raw of envelope.claims) {
161
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
162
- continue;
163
- const claim = raw;
164
- if (typeof claim.targetId !== "string" || !Array.isArray(claim.evidenceRefs))
165
- continue;
166
- const verifiedRefs = claim.evidenceRefs.filter((ref) => {
167
- if (typeof ref !== "string")
168
- return false;
169
- const value = provenanceMap[ref];
170
- return Boolean(value && typeof value === "object" && !Array.isArray(value) &&
171
- value.status === "verified" &&
172
- value.source !== "agent");
173
- });
174
- if (verifiedRefs.length === 0)
243
+ const view = committedView(entry);
244
+ if (view)
245
+ return view.executionEvidence?.success_criteria_met
246
+ ?.filter((value) => typeof value === "string") ?? [];
247
+ const ev = entryExecutionEvidence(entry);
248
+ if (!ev)
249
+ return [];
250
+ const arr = ev.success_criteria_met;
251
+ return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
252
+ }
253
+ /** Read success_criteria_remaining from an entry's execution_evidence. */
254
+ function entryCriteriaRemaining(entry) {
255
+ const view = committedView(entry);
256
+ if (view)
257
+ return view.executionEvidence?.success_criteria_remaining
258
+ ?.filter((value) => typeof value === "string") ?? [];
259
+ const ev = entryExecutionEvidence(entry);
260
+ if (!ev)
261
+ return [];
262
+ const arr = ev.success_criteria_remaining;
263
+ return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
264
+ }
265
+ /** v3.2: Deterministic lessons learned — constraints violated repeatedly or
266
+ * verification checks failing repeatedly across rounds (full history, unlike
267
+ * the enforcement gate's R2 3-round window). Presentation only: the output
268
+ * never feeds enforcement decisions. */
269
+ export function deriveLessons(loopId, context, currentRound) {
270
+ const entries = loopEntries(loopId, context).filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
271
+ const violations = new Map();
272
+ const errors = new Map();
273
+ const warns = new Map();
274
+ for (const entry of entries) {
275
+ const rnd = entryRound(entry);
276
+ const view = committedView(entry);
277
+ const viols = view
278
+ ? view.constraintViolations ?? []
279
+ : Array.isArray(entry.constraint_violations)
280
+ ? entry.constraint_violations.filter((v) => typeof v === "string")
281
+ : [];
282
+ for (const text of viols) {
283
+ const list = violations.get(text) ?? [];
284
+ list.push(rnd);
285
+ violations.set(text, list);
286
+ }
287
+ const flags = view
288
+ ? view.verificationFlags
289
+ : Array.isArray(entry.verification_flags) ? entry.verification_flags : [];
290
+ for (const flag of flags) {
291
+ const target = flag.severity === "error"
292
+ ? errors
293
+ : flag.severity === "warn" ? warns : null;
294
+ if (!target)
175
295
  continue;
176
- facts.add(claim.targetId);
177
- for (const ref of verifiedRefs)
178
- refs.add(ref);
296
+ const list = target.get(flag.check) ?? [];
297
+ list.push(rnd);
298
+ target.set(flag.check, list);
299
+ }
300
+ }
301
+ const lessons = [];
302
+ for (const [text, rounds] of violations) {
303
+ if (rounds.length >= 2) {
304
+ lessons.push({ text, kind: "constraint_violation", count: rounds.length, rounds });
179
305
  }
180
306
  }
181
- return { facts: [...facts].sort(), refs: [...refs].sort() };
307
+ for (const [text, rounds] of errors) {
308
+ if (rounds.length >= 2) {
309
+ lessons.push({ text, kind: "verification_error", count: rounds.length, rounds });
310
+ }
311
+ }
312
+ for (const [text, rounds] of warns) {
313
+ if (rounds.length >= 2) {
314
+ lessons.push({ text, kind: "verification_warning", count: rounds.length, rounds });
315
+ }
316
+ }
317
+ return lessons.sort((a, b) => b.count - a.count);
318
+ }
319
+ /** v3.3: Derive display-only round statistics over the last `window`
320
+ * committed rounds — files changed, rejected attempts (snapshot.attempt
321
+ * minus one: the successful commit's attempt counts the redo), and the
322
+ * self-reported progress delta vs the previous round. One row per round,
323
+ * preferring the :feedback entry when both lineage and feedback entries
324
+ * exist. Zero persistence — re-derived from committed entries. */
325
+ function deriveRoundStats(rounds, window = 5) {
326
+ const stats = [];
327
+ let prevProgress = null;
328
+ for (const round of rounds.slice(-window)) {
329
+ const ev = round.executionEvidence;
330
+ const files = ev?.files_changed;
331
+ const rawProgress = ev?.progress_estimate;
332
+ const progress = typeof rawProgress === "number" ? rawProgress : null;
333
+ stats.push({
334
+ round: round.round,
335
+ filesChangedCount: Array.isArray(files) ? files.length : null,
336
+ rejectedAttempts: round.attempt >= 1 ? round.attempt - 1 : null,
337
+ progressDelta: progress !== null && prevProgress !== null
338
+ ? Number((progress - prevProgress).toFixed(3))
339
+ : null,
340
+ });
341
+ if (progress !== null)
342
+ prevProgress = progress;
343
+ }
344
+ return stats;
345
+ }
346
+ /** v3.3: Machine git-motion cross-check over the last 3 committed rounds
347
+ * (the R4 window). Undefined when no committed rounds carry git snapshots
348
+ * — the dashboard then shows no machine row. Display-only companion to the
349
+ * R4/R5 exculpatory signal. */
350
+ function deriveMachineStatus(rounds, currentRound) {
351
+ const series = machineGitMotionSeries(rounds, currentRound, 3);
352
+ if (series === null)
353
+ return undefined;
354
+ const motionRounds = series.filter(Boolean).length;
355
+ return { windowRounds: 3, gitMotion: motionRounds > 0, motionRounds };
356
+ }
357
+ /** v3.4: ACTIVE Round Contract for the compile round — derived from the
358
+ * committed evals of earlier rounds only, never from request data. Reads
359
+ * the hydrated merged lineage entries (one per round; a committed decision
360
+ * is merged onto the round's lineage entry with `committed_action` set),
361
+ * skipping rounds whose committed action was "backtrack" (a roll-back
362
+ * directive, not an executed round). Because it runs identically on every
363
+ * compile path — including rejection retries and resume / unpause /
364
+ * backtrack compiles that carry no last_round_result — a contract round's
365
+ * re-compiles keep showing its contract as the Current Task. Null when no
366
+ * active contract: the Current Task falls back to the original task text. */
367
+ function deriveActiveContract(loopId, context, currentRound) {
368
+ const entries = loopEntries(loopId, context);
369
+ const canonical = Array.from({ length: Math.max(0, currentRound - 1) }, (_, index) => roundCanonicalEntry(entries, index + 1)).filter((entry) => entry !== null);
370
+ return deriveActiveRoundContract(contractRoundEvaluations(mergedRoundsFromEntries(canonical, currentRound)));
371
+ }
372
+ /** v3.2: Derive per-criterion status — the "goal → criteria → evidence"
373
+ * vertical view. Each objective criterion gets: met/remaining/unknown
374
+ * (from per-round success_criteria_met/remaining reports, ID-first
375
+ * matching), the round it was first reported met, and any sub-goals whose
376
+ * description matches it (Jaccard). Zero persistence — re-derived from the
377
+ * vault every compile. */
378
+ export function deriveCriterionStatuses(loopId, context, objective, currentRound, subGoals, lastRoundResult) {
379
+ if (!objective || objective.success_criteria.length === 0)
380
+ return [];
381
+ const entries = loopEntries(loopId, context).filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
382
+ // The current round's report lives in last_round_result (the vault only
383
+ // gains it on commit); treat it as the report for round currentRound - 1.
384
+ const lastEv = lastRoundResult?.execution_evidence;
385
+ const lastMet = lastEv?.success_criteria_met ?? [];
386
+ const lastRemaining = lastEv?.success_criteria_remaining ?? [];
387
+ const lastEntry = entries[entries.length - 1] ?? null;
388
+ const lastEntryRemaining = lastEntry ? entryCriteriaRemaining(lastEntry) : [];
389
+ const matchThreshold = getPolicy().evolution.subgoal_match_threshold;
390
+ return objective.success_criteria.map((text) => {
391
+ let metAtRound = null;
392
+ for (const entry of entries) {
393
+ const rnd = entryRound(entry);
394
+ if (entryCriteriaMet(entry).some((met) => criteriaMatch(met, text))) {
395
+ metAtRound = rnd;
396
+ break; // first report wins — chronological scan
397
+ }
398
+ }
399
+ if (metAtRound === null && lastMet.some((met) => criteriaMatch(met, text))) {
400
+ metAtRound = currentRound - 1;
401
+ }
402
+ const status = metAtRound !== null
403
+ ? "met"
404
+ : lastRemaining.some((remaining) => criteriaMatch(remaining, text)) ||
405
+ lastEntryRemaining.some((remaining) => criteriaMatch(remaining, text))
406
+ ? "remaining"
407
+ : "unknown";
408
+ return {
409
+ id: deriveCriterionId(text),
410
+ text,
411
+ status,
412
+ ...(metAtRound !== null ? { met_at_round: metAtRound } : {}),
413
+ related_subgoal_ids: subGoals
414
+ .filter((sg) => jaccardSimilarity(text, sg.description) >= matchThreshold)
415
+ .map((sg) => sg.id),
416
+ };
417
+ });
182
418
  }
183
419
  /** v2.11: Match two criterion references for deduplication.
184
420
  * If either is a criterion ID (cr-XXXXXXXX), uses exact ID comparison.
185
- * Otherwise falls back to Jaccard similarity. */
186
- function criteriaMatch(a, b) {
421
+ * Otherwise falls back to Jaccard similarity.
422
+ * v3.3: exported for the verification gate's windowed criteria-completion
423
+ * scan (R4/R5 exculpatory cross-check). */
424
+ export function criteriaMatch(a, b) {
187
425
  const aIsId = isCriterionId(a);
188
426
  const bIsId = isCriterionId(b);
189
427
  // Both are IDs — exact string comparison
@@ -197,13 +435,10 @@ function criteriaMatch(a, b) {
197
435
  // Neither is an ID — Jaccard fallback
198
436
  return jaccardSimilarity(a, b) >= getPolicy().evolution.criteria_dedup_threshold;
199
437
  }
200
- function isCriterionId(ref) {
201
- return /^cr-[a-f0-9]{8}$/.test(ref);
202
- }
203
438
  /** Detect newly met criteria by comparing the current entry's
204
439
  * success_criteria_met against the previous entry's.
205
440
  * v2.11: ID-first matching (cr-XXXXXXXX) with Jaccard similarity
206
- * fallback for backward compatibility.
441
+ * fallback for natural-language references.
207
442
  * Returns empty array when there is no previous entry — the first
208
443
  * entry's criteria are the baseline, not a "new" event. */
209
444
  function detectNewCriteria(current, previous) {
@@ -221,21 +456,42 @@ function detectNewCriteria(current, previous) {
221
456
  }
222
457
  /** Read constraints_active from an entry's lineage. */
223
458
  function entryActiveConstraints(entry) {
459
+ const view = committedView(entry);
460
+ if (view)
461
+ return view.activeConstraints ?? [];
224
462
  const lin = lineage(entry);
225
463
  const arr = lin.constraints_active;
226
464
  return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
227
465
  }
228
466
  /** Read retracted_constraints from an entry. */
229
467
  function entryRetractedConstraints(entry) {
230
- return entryProjection(entry)?.constraintEvents?.retired ?? [];
468
+ const view = committedView(entry);
469
+ if (view)
470
+ return view.retractedConstraints ?? [];
471
+ const direct = entry.retracted_constraints;
472
+ if (Array.isArray(direct))
473
+ return direct.filter((v) => typeof v === "string");
474
+ const lin = lineage(entry);
475
+ const nested = lin.retracted_constraints;
476
+ if (Array.isArray(nested))
477
+ return nested.filter((v) => typeof v === "string");
478
+ return [];
231
479
  }
480
+ /** Read progress_estimate from an entry's execution_evidence. */
232
481
  function entryProgressEstimate(entry) {
233
- return entryProjection(entry)?.evidence?.progressRatio ?? 0;
482
+ const ev = entryExecutionEvidence(entry);
483
+ if (!ev)
484
+ return 0;
485
+ const pe = ev.progress_estimate;
486
+ return typeof pe === "number" ? pe : 0;
234
487
  }
235
488
  /** Build a single MilestoneSummary from a range of completed round entries. */
236
489
  function buildMilestoneFromEntries(phaseEntries, startRound, endRound, label, kind) {
237
490
  const last = phaseEntries.at(-1);
238
- const outcomeRaw = last ? entryProjection(last)?.summary ?? "" : "";
491
+ const lastView = last ? committedView(last) : null;
492
+ const outcomeRaw = lastView
493
+ ? (lastView.outputSummary ?? "")
494
+ : last && typeof last.output_summary === "string" ? last.output_summary : "";
239
495
  const outcomeMax = getPolicy().checkpoint.outcome_max_chars;
240
496
  const outcome = outcomeRaw.length > outcomeMax
241
497
  ? outcomeRaw.slice(0, outcomeMax) + "…"
@@ -254,7 +510,56 @@ function buildMilestoneFromEntries(phaseEntries, startRound, endRound, label, ki
254
510
  generated_at_round: endRound,
255
511
  });
256
512
  }
257
- export function buildRollingSummary(loopId, currentRound, context, sinceRound = 0) {
513
+ /** v3.0.1: Evenly sample `budget` entries from `mid`, preferring
514
+ * agent_declared milestones (human/agent-labeled anchors) first and filling
515
+ * the remainder at even time positions. The caller guarantees
516
+ * `mid.length > budget`. */
517
+ function sampleMiddleMilestones(mid, budget) {
518
+ if (budget <= 0)
519
+ return [];
520
+ if (mid.length <= budget)
521
+ return mid;
522
+ const chosen = [];
523
+ const used = new Set();
524
+ for (const m of mid) {
525
+ if (m.kind === "agent_declared") {
526
+ chosen.push(m);
527
+ used.add(m);
528
+ if (chosen.length >= budget)
529
+ return chosen;
530
+ }
531
+ }
532
+ const rest = mid.filter((m) => !used.has(m));
533
+ const need = budget - chosen.length;
534
+ for (let i = 0; i < need; i++) {
535
+ const idx = Math.floor(((i + 0.5) * rest.length) / need);
536
+ chosen.push(rest[idx]);
537
+ }
538
+ return chosen;
539
+ }
540
+ /** v3.0.1: L1 milestone sampling — when milestones exceed max_milestones,
541
+ * keep the oldest `milestone_head_count` (history anchors), the newest
542
+ * `milestone_tail_count` (current progress), and an even sample of the
543
+ * middle. L2 (full rehydration) is never sampled: the recovery view keeps
544
+ * every milestone as the loop's memory skeleton. */
545
+ function sampleMilestones(milestones, policy, level) {
546
+ if (level === "l2")
547
+ return milestones;
548
+ if (milestones.length <= policy.max_milestones)
549
+ return milestones;
550
+ const head = Math.max(0, policy.milestone_head_count);
551
+ const tail = Math.max(0, policy.milestone_tail_count);
552
+ const midBudget = Math.max(0, policy.max_milestones - head - tail);
553
+ if (midBudget === 0)
554
+ return milestones.slice(0, head).concat(milestones.slice(milestones.length - tail));
555
+ const mid = milestones.slice(head, milestones.length - tail);
556
+ return [
557
+ ...milestones.slice(0, head),
558
+ ...sampleMiddleMilestones(mid, midBudget),
559
+ ...milestones.slice(milestones.length - tail),
560
+ ];
561
+ }
562
+ export function buildRollingSummary(loopId, currentRound, context, sinceRound = 0, level) {
258
563
  const policy = getPolicy().summary;
259
564
  const allEntries = loopEntries(loopId, context);
260
565
  // ── Phase 1: Recent Window (preserves existing behavior) ──
@@ -267,51 +572,43 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
267
572
  const outcomes = [];
268
573
  const issues = [];
269
574
  for (const entry of windowEntries) {
575
+ const view = committedView(entry);
270
576
  const data = lineage(entry);
271
577
  const round = entryRound(entry);
272
- const success = entry.success ?? data.success;
273
- const summary = entryProjection(entry)?.summary ?? "";
578
+ const success = view ? view.success : entry.success ?? data.success;
579
+ const summary = view
580
+ ? view.outputSummary ?? ""
581
+ : typeof entry.output_summary === "string"
582
+ ? entry.output_summary
583
+ : typeof data.output_summary === "string" ? data.output_summary : "";
584
+ // v2.12: Declared outcome wins the label; the default wording stays
585
+ // "accepted" for zero regression on existing loop output.
586
+ const declared = view ? view.outcome : entry.outcome;
587
+ const outcomeLabel = declared === "success" || declared === "partial" ||
588
+ declared === "failed" || declared === "blocked"
589
+ ? declared
590
+ : success === false ? "failed" : "accepted";
274
591
  if (summary)
275
- outcomes.push(`[R${round}] ${success === false ? "failed" : "accepted"}: ${summary}`);
276
- const violations = entryProjection(entry)?.violations ?? [];
592
+ outcomes.push(`[R${round}] ${outcomeLabel}: ${summary}`);
593
+ const violations = view
594
+ ? view.constraintViolations ?? []
595
+ : Array.isArray(entry.constraint_violations)
596
+ ? entry.constraint_violations
597
+ : Array.isArray(data.constraint_violations) ? data.constraint_violations : [];
277
598
  issues.push(...violations.filter((item) => typeof item === "string"));
278
599
  }
279
- const issueCounts = new Map();
280
- for (const issue of issues) {
281
- const key = issue.trim().replace(/\s+/g, " ").toLowerCase();
282
- if (!key)
283
- continue;
284
- const previous = issueCounts.get(key);
285
- issueCounts.set(key, { text: previous?.text ?? issue.trim(), count: (previous?.count ?? 0) + 1 });
286
- }
287
600
  // ── Phase 2: Milestone Accumulation ──
288
601
  // Scan all completed entries in round order. For each entry, check three
289
602
  // trigger signals in priority order: agent_declared > criteria_milestone > auto.
290
603
  const completedEntries = allEntries.filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
291
- const failedGroups = [];
292
- for (const entry of completedEntries) {
293
- const data = lineage(entry);
294
- if ((entry.success ?? data.success) !== false)
295
- continue;
296
- const text = (entryProjection(entry)?.summary ?? "").trim();
297
- if (!text)
298
- continue;
299
- const group = failedGroups.find((candidate) => jaccardSimilarity(candidate.text, text) >= getPolicy().evolution.failed_pattern_match_threshold);
300
- if (group)
301
- group.count++;
302
- else
303
- failedGroups.push({ text, count: 1 });
304
- }
305
604
  const milestones = [];
306
605
  let lastMilestoneRound = 0;
307
606
  for (let i = 0; i < completedEntries.length; i++) {
308
607
  const entry = completedEntries[i];
309
608
  const rnd = entryRound(entry);
310
609
  const lin = lineage(entry);
311
- const report = parseRoundReport(entry.round_report);
312
610
  // Signal 1: Agent-declared checkpoint
313
- const isAgentCheckpoint = Boolean(report?.phaseBoundaryProposal) ||
314
- lin.compression_checkpoint === true;
611
+ const isAgentCheckpoint = lin.compression_checkpoint === true;
315
612
  // Signal 2: New criteria met (compare with previous vault entry)
316
613
  const prev = i > 0 ? completedEntries[i - 1] : null;
317
614
  const newCriteria = detectNewCriteria(entry, prev);
@@ -325,19 +622,25 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
325
622
  ? "agent_declared"
326
623
  : "criteria_milestone";
327
624
  const label = isAgentCheckpoint
328
- ? (report?.phaseBoundaryProposal?.label?.trim() ||
329
- (typeof lin.checkpoint_label === "string" && lin.checkpoint_label.trim()
330
- ? lin.checkpoint_label.trim()
331
- : `Round ${rnd}`))
625
+ ? (typeof lin.checkpoint_label === "string" && lin.checkpoint_label.trim()
626
+ ? lin.checkpoint_label.trim()
627
+ : `Round ${rnd}`)
332
628
  : `Completed: ${newCriteria.join(", ")}`;
333
629
  milestones.push(buildMilestoneFromEntries(phaseEntries, startRound, rnd, label, kind));
334
630
  lastMilestoneRound = rnd;
335
631
  }
336
632
  }
337
- // Signal 3: Safety net — auto milestone when gap exceeds milestone_interval
633
+ // Signal 3: Safety net — auto milestone when gap exceeds milestone_interval.
634
+ // v3.3.1: the policy contract (milestone_interval: "only fires when no
635
+ // agent_declared or criteria_milestone has been created for this many
636
+ // rounds") includes loops that NEVER created a milestone — but the guard
637
+ // `lastMilestoneRound > 0` made the gap start from the (nonexistent) first
638
+ // milestone, so a checkpoint-less loop could run forever with an empty
639
+ // Phase History and no memory skeleton. With lastMilestoneRound == 0 the
640
+ // gap is measured from round 1 and the safety net fires from the very
641
+ // first interval.
338
642
  const gapSinceLast = (currentRound - 1) - lastMilestoneRound;
339
- if (lastMilestoneRound > 0 &&
340
- gapSinceLast >= policy.milestone_interval &&
643
+ if (gapSinceLast >= policy.milestone_interval &&
341
644
  completedEntries.length > 0) {
342
645
  const startRound = lastMilestoneRound + 1;
343
646
  const endRound = currentRound - 1;
@@ -349,42 +652,256 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
349
652
  milestones.push(buildMilestoneFromEntries(phaseEntries, startRound, endRound, `Rounds ${startRound}–${endRound}`, "auto"));
350
653
  }
351
654
  }
352
- const cappedMilestones = milestones.slice(-policy.max_milestones);
353
- // ── Phase 3: Loop Synthesis (formulaic) ──
354
- let loopSynthesis = "";
355
- if (policy.enable_loop_synthesis && currentRound > 1) {
356
- const totalRounds = currentRound - 1;
357
- const phaseCount = cappedMilestones.length;
358
- const lastProgress = completedEntries.length > 0
359
- ? entryProgressEstimate(completedEntries[completedEntries.length - 1])
360
- : 0;
361
- const activeCount = completedEntries.length > 0
362
- ? entryActiveConstraints(completedEntries[completedEntries.length - 1]).length
363
- : 0;
364
- const totalResolved = cappedMilestones.reduce((sum, m) => sum + m.resolved_constraints.length, 0);
365
- loopSynthesis =
366
- `Loop spans ${totalRounds} rounds across ${phaseCount} phases. ` +
367
- `Overall progress: ${(lastProgress * 100).toFixed(0)}%. ` +
368
- `${activeCount} active constraints, ${totalResolved} resolved across all phases.`;
369
- }
655
+ // v3.0.1: L1 keeps head anchors + newest tail + an even middle sample;
656
+ // L2 (full rehydration) keeps every milestone — the recovery view is the
657
+ // loop's memory skeleton and must not be truncated.
658
+ const cappedMilestones = sampleMilestones(milestones, policy, level);
370
659
  if (windowEntries.length === 0 && cappedMilestones.length === 0)
371
660
  return null;
372
661
  return makeRollingSummary({
373
662
  key_outcomes: unique(outcomes),
374
- recurring_issues: [...issueCounts.values()]
375
- .filter((issue) => issue.count >= 2)
376
- .map((issue) => `${issue.text} (${issue.count} rounds)`),
663
+ recurring_issues: unique(issues),
377
664
  rounds_sampled: windowEntries.length,
378
665
  generated_at_round: currentRound,
379
- failed_patterns: failedGroups
380
- .filter((failure) => failure.count >= 2)
381
- .map((failure) => `${failure.text} (${failure.count} attempts)`),
666
+ failed_patterns: [],
382
667
  milestones: cappedMilestones,
383
- loop_synthesis: loopSynthesis || undefined,
384
668
  });
385
669
  }
386
- // Sub-goal lifecycle is implemented in compiler-subgoals.ts; this facade keeps
387
- // the compiler's public entry point and projection flow unchanged.
670
+ // ═══════════════════════════════════════════════════════════════════════════
671
+ // Sub-Goal Structured Tracking (v2.2)
672
+ // ═══════════════════════════════════════════════════════════════════════════
673
+ /** Derive a stable sub-goal ID from its description hash.
674
+ * Exported as the single source of truth (verification-gate imports it). */
675
+ export function deriveSubGoalId(description) {
676
+ return "sg-" + deriveItemId(description);
677
+ }
678
+ /** v2.11: Derive a stable constraint ID from its text hash (c-XXXXXXXX).
679
+ * Same hash strategy as SubGoal — deterministic across rounds. */
680
+ export function deriveConstraintId(text) {
681
+ return "c-" + deriveItemId(text);
682
+ }
683
+ /** v2.11: Derive a stable criterion ID from its text hash (cr-XXXXXXXX).
684
+ * Same hash strategy as SubGoal — deterministic across rounds. */
685
+ export function deriveCriterionId(text) {
686
+ return "cr-" + deriveItemId(text);
687
+ }
688
+ /** v2.11: Check whether a user-provided reference looks like a constraint ID.
689
+ * Matches the pattern c-XXXXXXXX where X is a hex digit. */
690
+ export function isConstraintId(ref) {
691
+ return /^c-[a-f0-9]{8}$/.test(ref);
692
+ }
693
+ /** v2.11: Check whether a user-provided reference looks like a criterion ID.
694
+ * Matches the pattern cr-XXXXXXXX where X is a hex digit. */
695
+ export function isCriterionId(ref) {
696
+ return /^cr-[a-f0-9]{8}$/.test(ref);
697
+ }
698
+ /** Read emerged_subtasks from a vault entry (handles direct + lineage nesting). */
699
+ function entryEmergedSubtasks(entry) {
700
+ const view = committedView(entry);
701
+ if (view)
702
+ return view.emergedSubtasks ?? [];
703
+ const direct = entry.emerged_subtasks;
704
+ if (Array.isArray(direct))
705
+ return direct.filter((v) => typeof v === "string");
706
+ const lin = lineage(entry);
707
+ const nested = lin.emerged_subtasks;
708
+ if (Array.isArray(nested))
709
+ return nested.filter((v) => typeof v === "string");
710
+ return [];
711
+ }
712
+ /** Read completed_subtasks from an entry. */
713
+ function entryCompletedSubtasks(entry) {
714
+ const view = committedView(entry);
715
+ if (view)
716
+ return view.completedSubtasks ?? [];
717
+ const direct = entry.completed_subtasks;
718
+ if (Array.isArray(direct))
719
+ return direct.filter((v) => typeof v === "string");
720
+ return [];
721
+ }
722
+ /** Read blocked_subtasks from an entry. */
723
+ function entryBlockedSubtasks(entry) {
724
+ const view = committedView(entry);
725
+ if (view)
726
+ return view.blockedSubtasks ?? [];
727
+ const direct = entry.blocked_subtasks;
728
+ if (Array.isArray(direct))
729
+ return direct.filter((v) => typeof v === "string");
730
+ return [];
731
+ }
732
+ /** Read canceled_subtasks from an entry. */
733
+ function entryCanceledSubtasks(entry) {
734
+ const view = committedView(entry);
735
+ if (view)
736
+ return view.canceledSubtasks ?? [];
737
+ const direct = entry.canceled_subtasks;
738
+ if (Array.isArray(direct))
739
+ return direct.filter((v) => typeof v === "string");
740
+ return [];
741
+ }
742
+ /**
743
+ * Match a user-provided sub-task reference against existing SubGoals.
744
+ *
745
+ * Tries exact SubGoal ID match first (e.g. "sg-a3f2b1c0"), then falls
746
+ * back to Jaccard token similarity on descriptions. Returns the index
747
+ * of the best-matching SubGoal, or -1 when no match reaches threshold.
748
+ */
749
+ function matchSubGoal(target, subGoals, threshold) {
750
+ const trimmed = target.trim();
751
+ // Phase 1: Exact ID match
752
+ for (let i = 0; i < subGoals.length; i++) {
753
+ if (subGoals[i].id === trimmed)
754
+ return i;
755
+ }
756
+ // Phase 2: Jaccard fallback on description text
757
+ let bestIdx = -1;
758
+ let bestScore = 0;
759
+ for (let i = 0; i < subGoals.length; i++) {
760
+ const score = jaccardSimilarity(trimmed, subGoals[i].description);
761
+ if (score > bestScore) {
762
+ bestScore = score;
763
+ bestIdx = i;
764
+ }
765
+ }
766
+ return bestScore >= threshold ? bestIdx : -1;
767
+ }
768
+ /**
769
+ * Manage the full sub-goal lifecycle across rounds.
770
+ *
771
+ * Accumulates sub-goals from all prior vault entries, processes the current
772
+ * round's declarations, derives status changes, and returns the complete
773
+ * structured SubGoal list.
774
+ *
775
+ * Status derivation priority (compiler-driven):
776
+ * 1. Agent declared done/canceled/blocked → explicit status change
777
+ * 2. next_action match → auto in_progress
778
+ * 3. success_criteria_met match → auto done
779
+ */
780
+ function manageSubGoals(loopId, currentRound, lastRoundResult, vaultContext) {
781
+ const policy = getPolicy().evolution;
782
+ const allEntries = loopEntries(loopId, vaultContext);
783
+ const completedEntries = allEntries.filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound);
784
+ const subGoals = [];
785
+ // Phase 1 — Accumulate historical sub-goals from vault entries
786
+ for (const entry of completedEntries) {
787
+ const rnd = entryRound(entry);
788
+ const emerged = entryEmergedSubtasks(entry);
789
+ for (const desc of emerged) {
790
+ const id = deriveSubGoalId(desc);
791
+ // Dedup: skip if an existing sub-goal is too similar
792
+ if (matchSubGoal(desc, subGoals, policy.subgoal_dedup_threshold) >= 0)
793
+ continue;
794
+ subGoals.push(makeSubGoal({
795
+ id,
796
+ description: desc.trim(),
797
+ status: "pending",
798
+ declared_at_round: rnd,
799
+ status_changed_at_round: rnd,
800
+ priority: subGoals.length,
801
+ }));
802
+ }
803
+ }
804
+ // Phase 2 — Process current round's sub-task declarations
805
+ if (lastRoundResult) {
806
+ const rnd = lastRoundResult.round || (currentRound - 1);
807
+ // New sub-goals from this round
808
+ const emerged = lastRoundResult.emerged_subtasks ?? [];
809
+ for (const desc of emerged) {
810
+ if (matchSubGoal(desc, subGoals, policy.subgoal_dedup_threshold) >= 0)
811
+ continue;
812
+ subGoals.push(makeSubGoal({
813
+ id: deriveSubGoalId(desc),
814
+ description: desc.trim(),
815
+ status: "pending",
816
+ declared_at_round: rnd,
817
+ status_changed_at_round: rnd,
818
+ priority: subGoals.length,
819
+ }));
820
+ }
821
+ // Completed sub-goals
822
+ const completed = lastRoundResult.completed_subtasks ?? [];
823
+ for (const desc of completed) {
824
+ const idx = matchSubGoal(desc, subGoals, policy.subgoal_match_threshold);
825
+ if (idx >= 0 && subGoals[idx].status !== "done" && subGoals[idx].status !== "canceled") {
826
+ subGoals[idx].status = "done";
827
+ subGoals[idx].status_changed_at_round = rnd;
828
+ subGoals[idx].completed_at_round = rnd;
829
+ }
830
+ }
831
+ // Blocked sub-goals
832
+ const blocked = lastRoundResult.blocked_subtasks ?? [];
833
+ for (const desc of blocked) {
834
+ const idx = matchSubGoal(desc, subGoals, policy.subgoal_match_threshold);
835
+ if (idx >= 0 && subGoals[idx].status !== "done" && subGoals[idx].status !== "canceled") {
836
+ subGoals[idx].status = "blocked";
837
+ subGoals[idx].status_changed_at_round = rnd;
838
+ }
839
+ }
840
+ // Canceled sub-goals
841
+ const canceled = lastRoundResult.canceled_subtasks ?? [];
842
+ for (const desc of canceled) {
843
+ const idx = matchSubGoal(desc, subGoals, policy.subgoal_match_threshold);
844
+ if (idx >= 0 && subGoals[idx].status !== "done" && subGoals[idx].status !== "canceled") {
845
+ subGoals[idx].status = "canceled";
846
+ subGoals[idx].status_changed_at_round = rnd;
847
+ }
848
+ }
849
+ // Phase 3 — Auto-derive: next_action match → in_progress
850
+ const nextAction = lastRoundResult.next_action?.trim();
851
+ if (nextAction) {
852
+ for (const sg of subGoals) {
853
+ if (sg.status === "pending") {
854
+ if (jaccardSimilarity(nextAction, sg.description) >= policy.subgoal_auto_in_progress_threshold) {
855
+ sg.status = "in_progress";
856
+ sg.status_changed_at_round = rnd;
857
+ break; // Only one sub-goal can be in_progress at a time
858
+ }
859
+ }
860
+ }
861
+ }
862
+ // Phase 4 — Auto-complete: new criteria match → done
863
+ const newCriteria = lastRoundResult.execution_evidence?.success_criteria_met ?? [];
864
+ if (newCriteria.length > 0) {
865
+ for (const sg of subGoals) {
866
+ if (sg.status === "pending" || sg.status === "in_progress") {
867
+ for (const crit of newCriteria) {
868
+ if (jaccardSimilarity(crit, sg.description) >= policy.subgoal_auto_complete_threshold) {
869
+ sg.status = "done";
870
+ sg.status_changed_at_round = rnd;
871
+ sg.completed_at_round = rnd;
872
+ break;
873
+ }
874
+ }
875
+ }
876
+ }
877
+ }
878
+ }
879
+ // Sort for display: in_progress first, then pending by age desc, then blocked, then done, then canceled
880
+ subGoals.sort((a, b) => {
881
+ const rank = (s) => {
882
+ switch (s.status) {
883
+ case "in_progress": return 0;
884
+ case "pending": return 1;
885
+ case "blocked": return 2;
886
+ case "done": return 3;
887
+ case "canceled": return 4;
888
+ }
889
+ };
890
+ const ra = rank(a), rb = rank(b);
891
+ if (ra !== rb)
892
+ return ra - rb;
893
+ // Within same status: pending by oldest first, done by most recent first
894
+ // (v2.14: the previous expression simplified to b.declared - a.declared,
895
+ // which sorted NEWEST first despite the comment)
896
+ if (a.status === "pending")
897
+ return a.declared_at_round - b.declared_at_round;
898
+ if (a.status === "done")
899
+ return (b.completed_at_round ?? 0) - (a.completed_at_round ?? 0);
900
+ return a.declared_at_round - b.declared_at_round;
901
+ });
902
+ return subGoals;
903
+ }
904
+ // ═══════════════════════════════════════════════════════════════════════════
388
905
  // Time-Aware Constraint Management (v2.3)
389
906
  // ═══════════════════════════════════════════════════════════════════════════
390
907
  /** Determine the source of a constraint by matching against known sources. */
@@ -409,7 +926,12 @@ function findDiscoveredRound(text, vaultEntries) {
409
926
  const rnd = entryRound(entry);
410
927
  if (rnd < 1)
411
928
  continue;
412
- const discovered = entryProjection(entry)?.discoveries?.newConstraints ?? [];
929
+ const view = committedView(entry);
930
+ const discovered = view
931
+ ? view.discoveredConstraints ?? []
932
+ : Array.isArray(entry.discovered_constraints)
933
+ ? entry.discovered_constraints.filter((v) => typeof v === "string")
934
+ : [];
413
935
  for (const d of discovered) {
414
936
  // Phase 1: exact ID match (v2.11)
415
937
  if (d === targetId || deriveConstraintId(d) === targetId) {
@@ -417,7 +939,7 @@ function findDiscoveredRound(text, vaultEntries) {
417
939
  earliest = rnd;
418
940
  break;
419
941
  }
420
- // Phase 2: Jaccard fallback (backward compatibility)
942
+ // Phase 2: Jaccard fallback for natural-language references.
421
943
  if (jaccardSimilarity(text, d) >= getPolicy().evolution.constraint_match_threshold) {
422
944
  if (earliest === 0 || rnd < earliest)
423
945
  earliest = rnd;
@@ -431,23 +953,30 @@ function findDiscoveredRound(text, vaultEntries) {
431
953
  * v2.11: ID-first matching. Checks exact constraint ID match (c-XXXXXXXX)
432
954
  * against the derived ID of each vault entry's constraint_violations, then
433
955
  * falls back to Jaccard similarity on text. Returns 0 if never violated. */
434
- function findLastViolatedRound(text, vaultEntries) {
956
+ /** True when a reported violation text matches a constraint text — same
957
+ * ID-first + Jaccard fallback matching as findLastViolatedRound. */
958
+ function matchesConstraintText(text, violation) {
435
959
  const targetId = deriveConstraintId(text);
960
+ if (violation === targetId || deriveConstraintId(violation) === targetId) {
961
+ return true;
962
+ }
963
+ return jaccardSimilarity(text, violation) >=
964
+ getPolicy().evolution.constraint_match_threshold;
965
+ }
966
+ function findLastViolatedRound(text, vaultEntries) {
436
967
  let latest = 0;
437
968
  for (const entry of vaultEntries) {
438
969
  const rnd = entryRound(entry);
439
970
  if (rnd < 1)
440
971
  continue;
441
- const violations = entryProjection(entry)?.violations ?? [];
972
+ const view = committedView(entry);
973
+ const violations = view
974
+ ? view.constraintViolations ?? []
975
+ : Array.isArray(entry.constraint_violations)
976
+ ? entry.constraint_violations.filter((v) => typeof v === "string")
977
+ : [];
442
978
  for (const v of violations) {
443
- // Phase 1: exact ID match (v2.11)
444
- if (v === targetId || deriveConstraintId(v) === targetId) {
445
- if (rnd > latest)
446
- latest = rnd;
447
- break;
448
- }
449
- // Phase 2: Jaccard fallback (backward compatibility)
450
- if (jaccardSimilarity(text, v) >= getPolicy().evolution.constraint_match_threshold) {
979
+ if (matchesConstraintText(text, v)) {
451
980
  if (rnd > latest)
452
981
  latest = rnd;
453
982
  break;
@@ -464,20 +993,36 @@ function findLastViolatedRound(text, vaultEntries) {
464
993
  * constraints never auto-decay. Inactive constraints are auto-reactivated
465
994
  * if violated again in the current round.
466
995
  */
467
- function manageConstraintLifecycle(active, objective, constraintsFromPlan, vaultContext, currentRound) {
996
+ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vaultContext, currentRound, lastRoundViolations = []) {
468
997
  const policy = getPolicy().evolution;
469
998
  const allEntries = loopEntries(objective.loop_id || "", vaultContext);
470
999
  const completedEntries = allEntries.filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound);
471
1000
  const metadata = [];
472
1001
  for (const text of active) {
473
1002
  const source = constraintSource(text, objective, constraintsFromPlan);
1003
+ // v3.2.1: a violation reported in THIS round (last_round_result) counts
1004
+ // as a violation at currentRound — the previous rounds' scan cannot see
1005
+ // it (this round is not committed yet). This makes the L1 collapse
1006
+ // render "(violated this round)" (diffConstraints compares
1007
+ // last_violated_at_round === round) and re-activates decayed
1008
+ // discovered constraints.
1009
+ const violatedThisRound = lastRoundViolations.some((v) => matchesConstraintText(text, v));
474
1010
  // Structural constraints never decay
475
1011
  if (source === "hard" || source === "plan" || source === "criteria") {
1012
+ // v3.3.1: one text, one identity — a success-criterion text that also
1013
+ // lives in the merged active set belongs to the cr-XXXXXXXX namespace
1014
+ // (its own Success Criteria section and the verification gate's
1015
+ // criteriaMatch are ID-first on cr-). Rendering it under c-XXXXXXXX in
1016
+ // the active list meant the same text carried two IDs in one prompt,
1017
+ // and an agent echoing the active-list ID into success_criteria_met
1018
+ // could never match.
476
1019
  metadata.push(makeConstraintMeta({
477
- id: deriveConstraintId(text),
1020
+ id: source === "criteria" ? deriveCriterionId(text) : deriveConstraintId(text),
478
1021
  text,
479
1022
  discovered_at_round: objective.created_at_round || 1,
480
- last_violated_at_round: findLastViolatedRound(text, completedEntries),
1023
+ last_violated_at_round: violatedThisRound
1024
+ ? currentRound
1025
+ : findLastViolatedRound(text, completedEntries),
481
1026
  source,
482
1027
  status: "active",
483
1028
  }));
@@ -485,7 +1030,9 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
485
1030
  }
486
1031
  // Discovered constraints: check for inactivity
487
1032
  const discoveredAt = findDiscoveredRound(text, completedEntries);
488
- const lastViolated = findLastViolatedRound(text, completedEntries);
1033
+ const lastViolated = violatedThisRound
1034
+ ? currentRound
1035
+ : findLastViolatedRound(text, completedEntries);
489
1036
  const roundsSinceRelevant = lastViolated > 0
490
1037
  ? currentRound - lastViolated
491
1038
  : currentRound - (discoveredAt || currentRound);
@@ -513,21 +1060,25 @@ export function alignTask(proposedTask, request, context) {
513
1060
  const objective = request.loop_objective ?? latestObjective(request.loop_id, context);
514
1061
  if (!objective)
515
1062
  return makeTaskAlignment();
1063
+ // v2.14: threshold is policy-driven (evolution.progress_mismatch_threshold)
1064
+ const threshold = getPolicy().evolution.progress_mismatch_threshold;
516
1065
  const score = jaccardSimilarity(proposedTask, [objective.objective, ...objective.success_criteria, ...objective.hard_constraints].join(" "));
517
1066
  return makeTaskAlignment({
518
- is_aligned: score >= 0.3,
1067
+ is_aligned: score >= threshold,
519
1068
  alignment_score: Number(score.toFixed(2)),
520
- warning: score < 0.3 ? "Current task may be drifting from the loop objective." : "",
521
- escalation: score < 0.3 ? "warn" : "none",
1069
+ warning: score < threshold ? "Current task may be drifting from the loop objective." : "",
1070
+ escalation: score < threshold ? "warn" : "none",
522
1071
  });
523
1072
  }
524
1073
  export function checkLoopHealth(loopId, request, context) {
525
1074
  const previous = getPreviousRound(loopId, request.round - 1, context);
526
1075
  const alignment = alignTask(request.task, request, context);
527
- const violations = request.last_round_view?.violations?.length ?? 0;
1076
+ const violations = request.last_round_result?.constraint_violations.length ?? 0;
528
1077
  const integrity = Math.max(0, 1 - violations * 0.2);
529
1078
  const continuity = previous ? jaccardSimilarity(previous.task, request.task) : 1;
530
- const drift = alignment.alignment_score < 0.3 || continuity < 0.2;
1079
+ // v2.14: thresholds are policy-driven
1080
+ const drift = alignment.alignment_score < getPolicy().evolution.progress_mismatch_threshold ||
1081
+ continuity < getPolicy().evolution.task_continuity_threshold;
531
1082
  return makeLoopHealth({
532
1083
  goal_alignment: alignment.alignment_score,
533
1084
  constraint_integrity: integrity,
@@ -539,132 +1090,144 @@ export function checkLoopHealth(loopId, request, context) {
539
1090
  }
540
1091
  function levelDecision(request, context) {
541
1092
  const previous = getPreviousRound(request.loop_id, request.round - 1, context);
542
- const last = request.last_round_view;
1093
+ const last = request.last_round_result;
543
1094
  const hasNewInformation = Boolean(request.new_since_last_round.trim() ||
544
- last?.discoveries?.newConstraints?.length ||
545
- last?.objectiveRefinement?.trim() ||
546
- last?.discoveries?.emergedWork?.length ||
547
- last?.constraintEvents?.retired?.length ||
548
- last?.criterionRevisions?.length ||
549
- last?.discoveries?.wrongAssumptions?.length ||
550
- last?.evidence?.verifiedClaims.length);
1095
+ last?.discovered_constraints?.length ||
1096
+ last?.objective_refinement?.trim() ||
1097
+ last?.emerged_subtasks?.length ||
1098
+ last?.retracted_constraints?.length ||
1099
+ last?.revised_success_criteria?.length ||
1100
+ last?.wrong_assumptions?.length);
551
1101
  const lastFullRound = loopEntries(request.loop_id, context)
552
1102
  .filter((entry) => lineage(entry).recompile_level === "l2")
553
1103
  .map(entryRound)
554
1104
  .filter((round) => round < request.round)
555
1105
  .at(-1) ?? 1;
556
- const entries = loopEntries(request.loop_id, context)
557
- .filter((entry) => entryRound(entry) < request.round);
558
- const lastMeaningfulRound = [...entries].reverse().find((entry) => {
559
- const data = lineage(entry);
560
- const evidence = entryProjection(entry)?.evidence;
561
- return Boolean(entryCriteriaMet(entry).length ||
562
- evidence?.observedFiles.length ||
563
- entryProjection(entry)?.discoveries?.newConstraints.length ||
564
- entryProjection(entry)?.discoveries?.emergedWork.length);
565
- });
566
- const meaningfulSections = 2 + [
567
- request.task.trim(),
568
- ...(last?.violations ?? []),
569
- ...(last?.evidence?.criteriaRemaining ?? []),
570
- ...(last?.evidence?.criteriaMet ?? []),
571
- ...(last?.discoveries?.newConstraints ?? []),
572
- ...(last?.discoveries?.emergedWork ?? []),
573
- ...(last?.evidence?.observedFiles ?? []),
574
- ].filter(Boolean).length;
575
- const promptPolicy = getPolicy().prompt;
1106
+ // v2.14: a backtrack committed for this round means the loop is
1107
+ // re-walking restored state — the recovery boundary forces L2
1108
+ // rehydration (the decidePromptLevel recovery_boundary branch existed
1109
+ // but no caller ever set it).
1110
+ const recoveryBoundary = loopEntries(request.loop_id, context)
1111
+ .some((entry) => lineage(entry).committed_action === "backtrack" &&
1112
+ entryRound(entry) === request.round);
576
1113
  return decidePromptLevel({
577
1114
  round: request.round,
578
1115
  attempt: request.attempt,
579
1116
  forceLevel: request.force_level,
580
1117
  hasPlanSource: Boolean(request.plan_source),
581
- checkpointBoundary: Boolean(last?.phaseBoundaryProposal),
582
- goalChanged: Boolean(last?.objectiveRefinement?.trim()) ||
583
- (previous !== null && previous.goal_id !== deriveGoalId(request.loop_id, request.task, request.goal_id)),
584
- constraintChanged: Boolean(last?.discoveries?.newConstraints?.length || last?.constraintEvents?.retired?.length),
1118
+ recoveryBoundary,
1119
+ checkpointBoundary: last?.compression_checkpoint === true,
1120
+ goalChanged: previous !== null && previous.goal_id !== deriveGoalId(request.loop_id, request.task, request.goal_id),
585
1121
  previousStateMissing: request.round > 1 && previous === null,
586
- previousFailedWithoutNewInformation: last?.outcome === "failed" && !hasNewInformation,
1122
+ previousFailedWithoutNewInformation: last?.success === false && !hasNewInformation,
587
1123
  verificationContradicted: (request.verification_flags ?? [])
588
1124
  .some((flag) => flag.severity === "error"),
589
1125
  consecutiveRejections: request.consecutive_rejections,
590
1126
  fullRefreshInterval: getPolicy().prompt.full_refresh_interval,
591
1127
  lastFullRound,
592
- l1Streak: Math.max(0, request.round - lastFullRound - 1),
593
- maxL1Streak: promptPolicy.adaptive_refresh ? promptPolicy.max_l1_streak : undefined,
594
- meaningfulSections: request.round <= 2
595
- ? Math.max(meaningfulSections, promptPolicy.min_meaningful_sections)
596
- : meaningfulSections,
597
- minMeaningfulSections: promptPolicy.adaptive_refresh
598
- ? promptPolicy.min_meaningful_sections
599
- : undefined,
600
- staleProgressRounds: request.round - (lastMeaningfulRound ? entryRound(lastMeaningfulRound) : 1),
601
- staleRoundThreshold: promptPolicy.adaptive_refresh
602
- ? promptPolicy.stale_round_threshold
603
- : undefined,
604
1128
  });
605
1129
  }
606
1130
  export function decideLevel(request, context) {
607
1131
  return levelDecision(request, context).level;
608
1132
  }
609
- export function buildRoundReportBlock(round, prevDriftFlags) {
1133
+ export function buildSelfEvalBlock(round, prevDriftFlags,
1134
+ /** v2.12: L0 is the minimal retry template — the v2.12 declarative fields
1135
+ * (outcome/blocker/retroactiveClaims) are L1/L2 additions so the retry
1136
+ * prompt stays within its tight budget. */
1137
+ level,
1138
+ /** v3.3/v3.4: Whether this round's Current Task IS the ACTIVE Round
1139
+ * Contract (derived from committed rounds — compileLoop passes
1140
+ * `activeContract != null`). Only then does the template ask the agent
1141
+ * to restate/propose it — a generic empty contract template would invite
1142
+ * placeholder submissions that trigger round_underspecified noise. */
1143
+ hasContract = false,
1144
+ /** v3.5: L2-only prose suggesting a Round Contract declaration when the
1145
+ * Current Task is NOT one (contract_nudge_on_l2 policy, computed at the
1146
+ * compileLoop call site). Mutually exclusive with hasContract. The prose
1147
+ * must never contain the JSON key name `round_contract` — contract-less
1148
+ * L2 tests assert its lowercase absence. */
1149
+ proposalNudge = false) {
610
1150
  const hasIntentDrift = prevDriftFlags?.some(f => f.check === "intent_drift") ?? false;
611
1151
  const hasSubGoalDrift = prevDriftFlags?.some(f => f.check === "subgoal_drift") ?? false;
612
1152
  const needsClarification = hasIntentDrift || hasSubGoalDrift;
613
- const report = {
614
- format: "loopforge.round-report",
615
- outcome: "partial",
616
- continuation: "continue",
617
- summary: `<verified result of round ${round}>`,
618
- violations: [],
619
- evidence: {
620
- files: [],
621
- checks: [{ name: "<configured provider or command name>", status: "not_run" }],
622
- claims: [{
623
- id: "claim-<12 hex chars>",
624
- targetId: "<cr-XXXXXXXX or criterion text>",
625
- evidenceRefs: ["provider:<name>"],
626
- }],
627
- noChangeReason: "",
1153
+ const declareOutcome = level !== "l0";
1154
+ const restateContract = hasContract && declareOutcome;
1155
+ const evalObj = {
1156
+ success: false,
1157
+ output_summary: `<verified result of round ${round}>`,
1158
+ constraint_violations: ["<c-XXXXXXXX or text>"],
1159
+ should_continue: true,
1160
+ discovered_constraints: ["<new constraint>"],
1161
+ emerged_subtasks: [],
1162
+ execution_evidence: {
1163
+ files_changed: [],
1164
+ test_results: { passed: 0, failed: 0, skipped: 0 },
1165
+ success_criteria_met: ["<cr-XXXXXXXX or text>"],
1166
+ success_criteria_remaining: ["<cr-XXXXXXXX or text>"],
1167
+ progress_estimate: 0,
628
1168
  },
629
- discoveries: {
630
- wrongAssumptions: [],
631
- emergedWork: [],
632
- facts: [],
633
- newConstraints: [],
634
- },
635
- subGoalEvents: { completed: [], blocked: [], canceled: [] },
1169
+ wrong_assumptions: [],
1170
+ next_action: "<next concrete action, or empty when complete>",
1171
+ completed_subtasks: ["<sg-XXXXXXXX or text>"],
1172
+ blocked_subtasks: ["<sg-XXXXXXXX or text>"],
1173
+ canceled_subtasks: ["<sg-XXXXXXXX or text>"],
636
1174
  };
1175
+ // v2.12: Declarative tri-state outcome (L1/L2 only — keeps L0 retry lean)
1176
+ if (declareOutcome) {
1177
+ evalObj.outcome = "<success|partial|failed|blocked>";
1178
+ evalObj.blocker = "<required only when outcome=blocked>";
1179
+ evalObj.retroactiveClaims = [];
1180
+ }
1181
+ // v3.3/v3.4: Round Contract proposal template (L1/L2, active-contract
1182
+ // rounds only — L0 retry stays lean, and rounds without an active
1183
+ // contract never see the template). When the prompt's Current Task IS the
1184
+ // ACTIVE contract, the agent restates it unchanged as its proposal; when
1185
+ // the active contract's done_when are all satisfied (or the round is
1186
+ // blocked), a NEW contract is declared for the next round instead.
1187
+ if (restateContract) {
1188
+ evalObj.round_contract = {
1189
+ work_item: "<this round's focus, or empty>",
1190
+ done_when: [],
1191
+ verification_plan: [],
1192
+ scope: [],
1193
+ };
1194
+ }
637
1195
  // v2.8: Inject drift clarification field when previous round had drift flags
638
1196
  if (needsClarification) {
639
1197
  const flagTypes = [
640
1198
  hasIntentDrift ? "intent_drift" : "",
641
1199
  hasSubGoalDrift ? "subgoal_drift" : "",
642
1200
  ].filter(Boolean).join(" / ");
643
- report.discoveries = {
644
- ...report.discoveries,
645
- facts: [`Explain the evidence-backed pivot from ${flagTypes}`],
646
- };
647
- report.driftClarification = {
648
- explanation: "<explain why the current evidence requires this pivot>",
649
- anchors: ["<criterion ID, constraint ID, or observed file path>"],
650
- };
1201
+ evalObj.drift_clarification =
1202
+ `<explain why your Round ${round} actions diverged from your stated plan ` +
1203
+ `(${flagTypes} detected in previous round). ` +
1204
+ `Leave empty if no divergence occurred>`;
651
1205
  }
652
1206
  const lines = [
653
1207
  "### LoopForge Evaluation (Required)",
654
1208
  "",
655
1209
  `After completing Round ${round}, replace the placeholder values below`,
656
- "with your actual results and submit via `loopforge_next`.",
1210
+ "with your actual results and pass this object as `loopforge_next.evaluation`.",
657
1211
  ];
658
1212
  if (needsClarification) {
659
- lines.push("", "> ⚠️ **Drift Detected:** The verification gate detected that your previous", "> round's previously suggested action did not match your actual output.", "> Explain the evidence-backed pivot in `driftClarification` below and include a runtime-verifiable anchor.", "> An honest explanation helps the enforcement gate distinguish intentional", "> course corrections from unacknowledged drift.");
1213
+ lines.push("", "> ⚠️ **Drift Detected:** The verification gate detected that your previous", "> round's declared intent (next_action) did not match your actual output.", "> Please explain the pivot in the `drift_clarification` field below.", "> An honest explanation helps the enforcement gate distinguish intentional", "> course corrections from unacknowledged drift.");
660
1214
  }
661
- lines.push("Submit this object in the `report` parameter:");
662
- lines.push(JSON.stringify(report, null, 2));
1215
+ lines.push("```json");
1216
+ lines.push(JSON.stringify(evalObj, null, 2));
1217
+ lines.push("```");
663
1218
  lines.push("");
664
1219
  lines.push("IMPORTANT: Replace every <placeholder> with your actual data.");
665
- lines.push("Set outcome=success only when every criterion has runtime-verifiable evidence; set continuation=stop only when no further round is requested.");
1220
+ lines.push("Set success=true only when the full goal and ALL hard constraints are verified.");
666
1221
  lines.push("Use IDs for exact matching: c-XXXXXXXX, cr-XXXXXXXX, sg-XXXXXXXX.");
667
1222
  lines.push("For subtasks, use sub-goal IDs from the Sub-Goal Dashboard above.");
1223
+ // v3.5: L2 contract-less nudge (prose only — never the JSON key name, so
1224
+ // contract-less rounds keep their template-lean assertions).
1225
+ if (proposalNudge) {
1226
+ lines.push("", "If the remaining work will span several rounds, consider declaring a", "Round Contract for the next round — list its done_when items, the", "verification_plan commands that will back them, and the scope. This", "keeps each round's boundary machine-checkable.");
1227
+ }
1228
+ if (restateContract) {
1229
+ lines.push("Round Contract: restate the Current Task's contract UNCHANGED in", "`round_contract` (work_item/done_when/verification_plan/scope) and run", "its Verify commands; list satisfied done_when items in", "`success_criteria_met`. When the current contract's done_when items are", "all satisfied, or the round is blocked (outcome=blocked + blocker),", "declare the NEXT round's contract instead (or omit `round_contract`", "when the whole task is done — the Current Task reverts to the", "original task).");
1230
+ }
668
1231
  return lines.join("\n");
669
1232
  }
670
1233
  // v2.5: checkpoint() removed — agent_declared milestones from
@@ -674,10 +1237,14 @@ export function compileLoop(request, context) {
674
1237
  const decision = levelDecision(request, context);
675
1238
  const previous = getPreviousRound(request.loop_id, request.round - 1, context);
676
1239
  const objective = evolveObjective(request, context);
677
- const constraints = evolveConstraints(request, objective, previous);
678
- const constraintLifecycle = manageConstraintLifecycle(constraints.active, objective, request.constraints_from_plan, context, request.round);
679
- const rolling = buildRollingSummary(request.loop_id, request.round, context);
680
- const subGoals = deriveSubGoals(request.loop_id, request.round, request.last_round_view, context, entryProjection);
1240
+ const constraints = evolveConstraints(request, objective, previous, context);
1241
+ const constraintLifecycle = manageConstraintLifecycle(constraints.active, objective, request.constraints_from_plan, context, request.round, request.last_round_result?.constraint_violations ?? []);
1242
+ const rolling = buildRollingSummary(request.loop_id, request.round, context, 0, decision.level);
1243
+ const subGoals = manageSubGoals(request.loop_id, request.round, request.last_round_result, context);
1244
+ // v3.2: Goal → criteria → evidence vertical view (derived, zero persistence).
1245
+ const criterionStatuses = deriveCriterionStatuses(request.loop_id, context, objective, request.round, subGoals, request.last_round_result);
1246
+ // v3.2: Lessons learned — repeated violations / verification failures.
1247
+ const lessons = deriveLessons(request.loop_id, context, request.round);
681
1248
  const alignment = alignTask(request.task, request, context);
682
1249
  const health = checkLoopHealth(request.loop_id, { ...request, loop_objective: objective }, context);
683
1250
  const warnings = unique([
@@ -695,37 +1262,22 @@ export function compileLoop(request, context) {
695
1262
  // No new persistence; reconstructed same as milestones/sub-goals.
696
1263
  const completedEntries = loopEntries(request.loop_id, context)
697
1264
  .filter((e) => entryRound(e) >= 1 && entryRound(e) < request.round);
1265
+ const committedRounds = mergedRoundsFromEntries(completedEntries, request.round);
698
1266
  const trend = [];
699
- for (const entry of completedEntries.slice(-10)) {
700
- const viols = entryProjection(entry)?.violations ?? [];
1267
+ for (const round of committedRounds.slice(-10)) {
1268
+ const viols = round.evaluation?.constraint_violations ?? [];
701
1269
  // Simple proxy: no violations = perfect trust, each violation = -0.1
702
1270
  const score = Math.max(0, 1.0 - viols.length * 0.1);
703
1271
  trend.push(Number(score.toFixed(2)));
704
1272
  }
705
- const verified = verifiedClaims(request.loop_id, context);
706
- const latestReport = [...loopEntries(request.loop_id, context)].reverse()
707
- .map((entry) => parseRoundReport(entry.round_report))
708
- .find((report) => report !== null);
709
- const audit = auditEntries(request.loop_id, loopEntries(request.loop_id, context));
710
- const regressionObligations = audit.regressionObligations.map((obligation) => ({
711
- id: obligation.id,
712
- description: `Re-run ${obligation.check}`,
713
- sourceRound: obligation.sourceRound,
714
- evidenceRefs: [`check:${obligation.check}`],
715
- status: obligation.status,
716
- }));
717
- const latestBlocker = latestReport?.outcome === "blocked" && latestReport.blocker;
718
- const blockerGate = latestBlocker
719
- ? latestBlocker.action
720
- ? deriveStructuredGate(latestBlocker.action)
721
- : deriveGate(latestBlocker.reason, latestBlocker.references)
722
- : null;
723
- const activeBlockers = latestBlocker && blockerGate?.gate.kind === "user" &&
724
- !audit.unresolvedUserGates.includes(blockerGate.id)
725
- ? []
726
- : latestBlocker
727
- ? [latestBlocker.reason]
728
- : [];
1273
+ // v3.3: Round stats + machine git-motion status (display-only, derived).
1274
+ const roundStats = deriveRoundStats(committedRounds);
1275
+ const machineStatus = deriveMachineStatus(committedRounds, request.round);
1276
+ // v3.4: ACTIVE Round Contract for this compile — derived from committed
1277
+ // rounds, so rejection retries / resume / unpause / backtrack compiles
1278
+ // (no last_round_result) keep showing the contract as Current Task, and a
1279
+ // satisfied or blocked contract stops being shown.
1280
+ const activeContract = deriveActiveContract(request.loop_id, context, request.round);
729
1281
  const response = makeLoopCompileResponse({
730
1282
  status: AgentStatus.OK,
731
1283
  recompile_level: decision.level,
@@ -744,26 +1296,23 @@ export function compileLoop(request, context) {
744
1296
  task_alignment: alignment,
745
1297
  rolling_summary: rolling,
746
1298
  sub_goals: subGoals,
1299
+ criterion_statuses: criterionStatuses,
1300
+ lessons,
747
1301
  agent_trust_score: request.round > 1 ? Number(trustScore.toFixed(2)) : undefined,
748
1302
  agent_trust_trend: request.round > 1 ? trend : [],
749
- // Agent intent is advisory only. Runtime suggestions are derived from
750
- // remaining criteria, active sub-goals and blockers; never copy intent
751
- // into the canonical next-action projection.
752
- projection: makeLoopCompileProjection({
753
- suggestedNextAction: request.last_round_view?.evidence?.criteriaRemaining?.[0] ||
754
- request.last_round_view?.discoveries?.emergedWork?.[0] || "",
755
- verifiedFacts: verified.facts,
756
- verifiedEvidenceRefs: verified.refs,
757
- regressionObligations,
758
- discoveredFacts: latestReport?.discoveries?.facts ?? [],
759
- activeBlockers,
760
- }),
1303
+ suggested_next_task: request.last_round_result?.next_action?.trim() ||
1304
+ request.last_round_result?.emerged_subtasks?.[0] || "",
761
1305
  plan_source: request.plan_source,
762
1306
  warnings,
763
1307
  });
764
1308
  const policy = getPolicy();
765
1309
  const statePath = `${policy.state_file.directory}/${request.loop_id}-state.md`;
766
- const state = createCanonicalLoopState(request, response, statePath);
1310
+ const state = createCanonicalLoopState(request, response, statePath, {
1311
+ roundStats,
1312
+ machineStatus,
1313
+ // v3.4: derived active contract — the Current Task's source of truth.
1314
+ roundContract: activeContract,
1315
+ });
767
1316
  const markdown = renderCanonicalStateMarkdown(state);
768
1317
  // v2.4–v2.5: Adaptive L2 budget — scales with loop complexity
769
1318
  const adaptiveL2 = policy.prompt.l2_adaptive_enabled
@@ -787,36 +1336,24 @@ export function compileLoop(request, context) {
787
1336
  l2: adaptiveL2,
788
1337
  },
789
1338
  attempt: request.attempt,
790
- roundReportBlock: buildRoundReportBlock(request.round, request.verification_flags),
1339
+ selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags, decision.level,
1340
+ // v3.4: restate the contract only when this prompt's Current Task IS
1341
+ // the ACTIVE contract (derived — not the previous submission's field,
1342
+ // which is a proposal and may differ from what this round executes).
1343
+ activeContract != null,
1344
+ // v3.5: L2-only declaration nudge when nothing is active (policy-gated;
1345
+ // mutually exclusive with the restate template above).
1346
+ decision.level === "l2" && activeContract === null &&
1347
+ getPolicy().prompt.contract_nudge_on_l2),
791
1348
  fullStateMarkdown: l2Pointer ? undefined : markdown,
792
- agentIntent: request.last_round_view?.agentIntent,
793
- promptRequests: request.last_round_view?.contextRequest ?? undefined,
794
- });
795
- const priorEntries = loopEntries(request.loop_id, context)
796
- .filter((entry) => entryRound(entry) < request.round);
797
- const previousL2 = [...priorEntries].reverse()
798
- .find((entry) => lineage(entry).recompile_level === "l2");
799
- artifact.lastL2Round = artifact.level === "l2"
800
- ? request.round
801
- : previousL2 ? entryRound(previousL2) : 0;
802
- const meaningfulEntry = [...priorEntries].reverse().find((entry) => {
803
- const data = lineage(entry);
804
- const evidence = entryProjection(entry)?.evidence;
805
- return Boolean(entryCriteriaMet(entry).length ||
806
- evidence?.observedFiles.length ||
807
- entryProjection(entry)?.discoveries?.newConstraints.length ||
808
- entryProjection(entry)?.discoveries?.emergedWork.length);
1349
+ // v2.9: Model's information needs from the previous round's SelfEvaluation
1350
+ promptRequests: request.last_round_result?.prompt_requests,
1351
+ // v3.2: Previous L1 presentation as the collapse diff baseline.
1352
+ presentedBaseline: readPresentedBaseline(request.loop_id, request.round, context),
809
1353
  });
810
- artifact.lastMeaningfulProgressRound = meaningfulEntry
811
- ? entryRound(meaningfulEntry)
812
- : request.round <= 1 ? 0 : 1;
813
- artifact.stateAgeRounds = artifact.lastMeaningfulProgressRound > 0
814
- ? Math.max(0, request.round - artifact.lastMeaningfulProgressRound)
815
- : request.round - 1;
816
1354
  response.prompt = artifact.renderedPrompt;
817
1355
  response.prompt_artifact = artifact;
818
1356
  response.state_file_content = policy.state_file.enabled ? markdown : undefined;
819
- response.canonical_state = state;
820
1357
  return response;
821
1358
  }
822
1359
  //# sourceMappingURL=loop-compiler.js.map