loopforge 3.0.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +128 -145
  2. package/dist/audit.d.ts +50 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +158 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/canonical-state.d.ts +28 -20
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +82 -173
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +25 -16
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +25 -33
  14. package/dist/cognitive-governance.d.ts.map +1 -1
  15. package/dist/cognitive-governance.js +38 -138
  16. package/dist/cognitive-governance.js.map +1 -1
  17. package/dist/enforcement-gate.d.ts +76 -0
  18. package/dist/enforcement-gate.d.ts.map +1 -0
  19. package/dist/enforcement-gate.js +1071 -0
  20. package/dist/enforcement-gate.js.map +1 -0
  21. package/dist/engine.d.ts +99 -0
  22. package/dist/engine.d.ts.map +1 -0
  23. package/dist/engine.js +839 -0
  24. package/dist/engine.js.map +1 -0
  25. package/dist/evidence-claims.d.ts +50 -0
  26. package/dist/evidence-claims.d.ts.map +1 -0
  27. package/dist/evidence-claims.js +134 -0
  28. package/dist/evidence-claims.js.map +1 -0
  29. package/dist/evidence-provider.d.ts +21 -23
  30. package/dist/evidence-provider.d.ts.map +1 -1
  31. package/dist/evidence-provider.js +74 -34
  32. package/dist/evidence-provider.js.map +1 -1
  33. package/dist/generate-schema.js +1 -84
  34. package/dist/generate-schema.js.map +1 -1
  35. package/dist/index.d.ts +19 -22
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +16 -15
  38. package/dist/index.js.map +1 -1
  39. package/dist/loop-compiler.d.ts +41 -3
  40. package/dist/loop-compiler.d.ts.map +1 -1
  41. package/dist/loop-compiler.js +632 -273
  42. package/dist/loop-compiler.js.map +1 -1
  43. package/dist/loop-extras-parser.d.ts +2 -2
  44. package/dist/loop-extras-parser.d.ts.map +1 -1
  45. package/dist/loop-extras-parser.js +2 -2
  46. package/dist/loop-extras-parser.js.map +1 -1
  47. package/dist/loop-projection.d.ts +26 -0
  48. package/dist/loop-projection.d.ts.map +1 -0
  49. package/dist/loop-projection.js +142 -0
  50. package/dist/loop-projection.js.map +1 -0
  51. package/dist/loop-store.d.ts +112 -24
  52. package/dist/loop-store.d.ts.map +1 -1
  53. package/dist/loop-store.js +365 -218
  54. package/dist/loop-store.js.map +1 -1
  55. package/dist/mcp/round-lifecycle.d.ts +216 -0
  56. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  57. package/dist/mcp/round-lifecycle.js +926 -0
  58. package/dist/mcp/round-lifecycle.js.map +1 -0
  59. package/dist/mcp/server.d.ts +2 -1
  60. package/dist/mcp/server.d.ts.map +1 -1
  61. package/dist/mcp/server.js +28 -42
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/mcp/session.d.ts +53 -101
  64. package/dist/mcp/session.d.ts.map +1 -1
  65. package/dist/mcp/session.js +306 -1105
  66. package/dist/mcp/session.js.map +1 -1
  67. package/dist/mcp/tools.d.ts +13 -16
  68. package/dist/mcp/tools.d.ts.map +1 -1
  69. package/dist/mcp/tools.js +479 -413
  70. package/dist/mcp/tools.js.map +1 -1
  71. package/dist/policy-metrics.d.ts +12 -17
  72. package/dist/policy-metrics.d.ts.map +1 -1
  73. package/dist/policy-metrics.js +95 -161
  74. package/dist/policy-metrics.js.map +1 -1
  75. package/dist/policy.d.ts +44 -35
  76. package/dist/policy.d.ts.map +1 -1
  77. package/dist/policy.js +31 -115
  78. package/dist/policy.js.map +1 -1
  79. package/dist/prompt-assembler.d.ts +37 -6
  80. package/dist/prompt-assembler.d.ts.map +1 -1
  81. package/dist/prompt-assembler.js +389 -160
  82. package/dist/prompt-assembler.js.map +1 -1
  83. package/dist/prompt-policy.d.ts +1 -8
  84. package/dist/prompt-policy.d.ts.map +1 -1
  85. package/dist/prompt-policy.js +8 -26
  86. package/dist/prompt-policy.js.map +1 -1
  87. package/dist/protocol.d.ts +357 -229
  88. package/dist/protocol.d.ts.map +1 -1
  89. package/dist/protocol.js +139 -15
  90. package/dist/protocol.js.map +1 -1
  91. package/dist/replay.d.ts +5 -13
  92. package/dist/replay.d.ts.map +1 -1
  93. package/dist/replay.js +19 -55
  94. package/dist/replay.js.map +1 -1
  95. package/dist/round-coordinator.d.ts +20 -24
  96. package/dist/round-coordinator.d.ts.map +1 -1
  97. package/dist/round-coordinator.js +99 -92
  98. package/dist/round-coordinator.js.map +1 -1
  99. package/dist/round-driver.d.ts +14 -13
  100. package/dist/round-driver.d.ts.map +1 -1
  101. package/dist/round-driver.js +26 -14
  102. package/dist/round-driver.js.map +1 -1
  103. package/dist/round-transaction.d.ts +15 -18
  104. package/dist/round-transaction.d.ts.map +1 -1
  105. package/dist/round-transaction.js +38 -47
  106. package/dist/round-transaction.js.map +1 -1
  107. package/dist/self-eval.d.ts +53 -0
  108. package/dist/self-eval.d.ts.map +1 -0
  109. package/dist/self-eval.js +284 -0
  110. package/dist/self-eval.js.map +1 -0
  111. package/dist/storage.d.ts +9 -4
  112. package/dist/storage.d.ts.map +1 -1
  113. package/dist/storage.js +18 -6
  114. package/dist/storage.js.map +1 -1
  115. package/dist/token-utils.d.ts +15 -3
  116. package/dist/token-utils.d.ts.map +1 -1
  117. package/dist/token-utils.js +24 -5
  118. package/dist/token-utils.js.map +1 -1
  119. package/dist/verification-gate.d.ts +108 -0
  120. package/dist/verification-gate.d.ts.map +1 -0
  121. package/dist/verification-gate.js +971 -0
  122. package/dist/verification-gate.js.map +1 -0
  123. package/loop_policy.json +44 -15
  124. package/package.json +5 -10
  125. package/skills/perception/SKILL.md +182 -199
  126. package/dist/canonical-event-writer.d.ts +0 -20
  127. package/dist/canonical-event-writer.d.ts.map +0 -1
  128. package/dist/canonical-event-writer.js +0 -131
  129. package/dist/canonical-event-writer.js.map +0 -1
  130. package/dist/compiler-subgoals.d.ts +0 -7
  131. package/dist/compiler-subgoals.d.ts.map +0 -1
  132. package/dist/compiler-subgoals.js +0 -93
  133. package/dist/compiler-subgoals.js.map +0 -1
  134. package/dist/derive-id.d.ts +0 -12
  135. package/dist/derive-id.d.ts.map +0 -1
  136. package/dist/derive-id.js +0 -31
  137. package/dist/derive-id.js.map +0 -1
  138. package/dist/mcp/session-projection.d.ts +0 -5
  139. package/dist/mcp/session-projection.d.ts.map +0 -1
  140. package/dist/mcp/session-projection.js +0 -18
  141. package/dist/mcp/session-projection.js.map +0 -1
  142. package/dist/mcp/session-queue.d.ts +0 -3
  143. package/dist/mcp/session-queue.d.ts.map +0 -1
  144. package/dist/mcp/session-queue.js +0 -18
  145. package/dist/mcp/session-queue.js.map +0 -1
  146. package/dist/mcp/session-types.d.ts +0 -72
  147. package/dist/mcp/session-types.d.ts.map +0 -1
  148. package/dist/mcp/session-types.js +0 -2
  149. package/dist/mcp/session-types.js.map +0 -1
  150. package/dist/round-commit-store.d.ts +0 -12
  151. package/dist/round-commit-store.d.ts.map +0 -1
  152. package/dist/round-commit-store.js +0 -13
  153. package/dist/round-commit-store.js.map +0 -1
  154. package/dist/round-gate.d.ts +0 -31
  155. package/dist/round-gate.d.ts.map +0 -1
  156. package/dist/round-gate.js +0 -259
  157. package/dist/round-gate.js.map +0 -1
  158. package/dist/round-recovery.d.ts +0 -17
  159. package/dist/round-recovery.d.ts.map +0 -1
  160. package/dist/round-recovery.js +0 -117
  161. package/dist/round-recovery.js.map +0 -1
  162. package/dist/round-report.d.ts +0 -16
  163. package/dist/round-report.d.ts.map +0 -1
  164. package/dist/round-report.js +0 -401
  165. package/dist/round-report.js.map +0 -1
  166. package/dist/runtime-services.d.ts +0 -59
  167. package/dist/runtime-services.d.ts.map +0 -1
  168. package/dist/runtime-services.js +0 -460
  169. package/dist/runtime-services.js.map +0 -1
  170. package/dist/vault-format.d.ts +0 -4
  171. package/dist/vault-format.d.ts.map +0 -1
  172. package/dist/vault-format.js +0 -4
  173. package/dist/vault-format.js.map +0 -1
  174. package/dist/vault-types.d.ts +0 -24
  175. package/dist/vault-types.d.ts.map +0 -1
  176. package/dist/vault-types.js +0 -2
  177. package/dist/vault-types.js.map +0 -1
  178. package/loopforge-policy.json +0 -340
@@ -5,12 +5,11 @@
5
5
  * are never truncated; budgets are soft and overflow is recorded.
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
- import { deriveItemId } from "./derive-id.js";
9
8
  import { hashCanonicalState } from "./canonical-state.js";
10
- import { jaccardSimilarity } from "./token-utils.js";
9
+ import { deriveItemId, STABLE_ID_RE, jaccardSimilarity } from "./token-utils.js";
11
10
  import { getPolicy } from "./policy.js";
12
- export const PROMPT_ARTIFACT_FORMAT = "loopforge.prompt";
13
- export const BASE_PROMPT_VERSION = "stable";
11
+ export const PROMPT_ARTIFACT_SCHEMA_VERSION = 1;
12
+ export const BASE_PROMPT_VERSION = "2.0.0";
14
13
  export const DEFAULT_PROMPT_BUDGETS = {
15
14
  l0: 3000,
16
15
  l1: 7000,
@@ -31,22 +30,62 @@ function bulletsWithIds(values, prefix) {
31
30
  const idEnabled = getPolicy().evolution.constraint_id_enabled;
32
31
  if (!idEnabled)
33
32
  return bullets(values);
34
- const idRegex = /^(c|cr|sg)-[a-f0-9]{8}$/;
35
33
  return values.map((v) => {
36
34
  const trimmed = v.trim();
37
- if (idRegex.test(trimmed)) {
35
+ if (STABLE_ID_RE.test(trimmed)) {
38
36
  return `- [\`${trimmed}\`] ${trimmed}`;
39
37
  }
40
38
  const id = prefix + "-" + deriveItemId(trimmed);
41
39
  return `- [\`${id}\`] ${trimmed}`;
42
40
  }).join("\n");
43
41
  }
44
- function verificationText(state) {
42
+ /** v3.2: Short imperative fix/action per verification check, appended to the
43
+ * L1/L2 Verification Gate lines. Deliberately shorter than the rejection-
44
+ * prompt wording in enforcement-gate.ts; keys are the plain check-name
45
+ * strings (NOT imported from verification-gate.ts — it already imports
46
+ * loop-compiler, which imports this module, so importing back would cycle).
47
+ * Full coverage of the 19 CHECK_* constants is enforced by a test. */
48
+ const VERIFICATION_ACTIONS = {
49
+ progress_regression: "Correct progress_estimate or explain the change in output_summary.",
50
+ empty_change_with_passing: "Report the actual files_changed, or set success=false with the real state.",
51
+ success_with_remaining_criteria: "Complete the remaining criteria before claiming success, or set success=false and report what remains.",
52
+ success_without_verified_evidence: "Back the success claim with machine-verifiable evidence: run tests or a required verification command and report the actual output.",
53
+ outcome_success_contradiction: "Reconcile the declared outcome with the success flag and resubmit an honest evaluation.",
54
+ success_claim_conflict: "Align the success claim with your own evaluation data before resubmitting.",
55
+ blocked_without_blocker: "State the concrete blocker, or drop the blocked outcome.",
56
+ retroactive_claim_bad_round: "Correct the referenced round number, or withdraw the retroactive claim.",
57
+ retroactive_claim_unverified: "Provide evidence for the retroactive claim, or withdraw it.",
58
+ duplicate_constraint_discovery: "Update the existing constraint entry instead of duplicating it.",
59
+ recurring_violation: "Explain why this violation keeps recurring and try a different approach — do not repeat the failed strategy.",
60
+ retract_fresh_constraint: "Justify the retraction explicitly, or restore the constraint.",
61
+ evidence_integrity: "Correct the mismatch between your claims and the recorded evidence.",
62
+ required_command_failed: "Re-run the required verification command and report its actual output before claiming success.",
63
+ command_evidence_mismatch: "Report the true command output and reconcile the mismatch.",
64
+ intent_drift: "Explain the pivot in drift_clarification with concrete references, or redo the round as promised.",
65
+ subgoal_drift: "Reconcile the sub-goal list with your reported work before resubmitting.",
66
+ backtrack_workspace_not_restored: "Restore the skipped-round files and the expected git HEAD, then resubmit.",
67
+ criteria_claims_unverified: "Provide evidence for each claimed success criterion.",
68
+ };
69
+ /** Fallback for unmapped check names (forward-compat with future checks). */
70
+ const VERIFICATION_ACTION_FALLBACK = "Re-examine the flagged claim and correct your evaluation before resubmitting.";
71
+ /** v3.2: The actionable instruction for a verification check. Exported for
72
+ * the coverage test (which imports the CHECK_* constants). */
73
+ export function verificationActionFor(check) {
74
+ return VERIFICATION_ACTIONS[check] ?? VERIFICATION_ACTION_FALLBACK;
75
+ }
76
+ function verificationText(state, level) {
45
77
  const lines = state.verificationFlags.map((flag) => {
46
78
  const icon = flag.severity === "error"
47
79
  ? "🚫"
48
80
  : flag.severity === "warn" ? "⚠️" : "ℹ️";
49
- return `- ${icon} [${flag.check}] ${flag.detail}`;
81
+ const base = `- ${icon} [${flag.check}] ${flag.detail}`;
82
+ // v3.2: error/warn flags get an actionable continuation line in L1/L2 —
83
+ // the verdict is translated into what to DO. L0 keeps the compact retry
84
+ // prompt byte-identical; info flags stay informational.
85
+ if (level === "l0" || flag.severity === "info")
86
+ return base;
87
+ const label = flag.severity === "error" ? "Fix" : "Action";
88
+ return `${base}\n → ${label}: ${verificationActionFor(flag.check)}`;
50
89
  });
51
90
  if (state.verificationFlags.some((flag) => flag.severity === "error")) {
52
91
  lines.push("- Gate Verdict: CONTRADICTED — resolve every error before claiming success.");
@@ -57,6 +96,81 @@ function activeNonHardConstraints(state) {
57
96
  const hard = new Set(state.hardConstraints);
58
97
  return state.activeConstraints.filter((value) => !hard.has(value));
59
98
  }
99
+ // ═══════════════════════════════════════════════════════════════════════════
100
+ // v3.2: L1 diff-collapse helpers
101
+ // ═══════════════════════════════════════════════════════════════════════════
102
+ /** v3.2: Collapse only when at least this many items are unchanged — a tiny
103
+ * unchanged set renders in full (nothing worth saving). */
104
+ export const COLLAPSE_MIN_UNCHANGED = 3;
105
+ /** v3.2: Recent-rounds items kept in full when older ones collapse. */
106
+ export const KEEP_RECENT_ROUNDS = 3;
107
+ /** v3.2: Stable ID of a constraint text as rendered by bulletsWithIds —
108
+ * metadata's own id when present, else the derived hash. */
109
+ function constraintIdOf(text, metadata) {
110
+ const meta = metadata.find((m) => m.text === text);
111
+ return meta?.id ?? `c-${deriveItemId(text)}`;
112
+ }
113
+ export function diffConstraints(baseline, activeTexts, metadata, round, emphasized) {
114
+ if (!baseline)
115
+ return { changed: activeTexts, unchangedCount: 0, removedCount: 0 };
116
+ const baselineIds = new Set(baseline.constraintIds);
117
+ const currentIds = new Set(activeTexts.map((text) => constraintIdOf(text, metadata)));
118
+ const changed = [];
119
+ let unchangedCount = 0;
120
+ for (const text of activeTexts) {
121
+ const id = constraintIdOf(text, metadata);
122
+ const violatedThisRound = metadata.find((m) => m.text === text)
123
+ ?.last_violated_at_round === round;
124
+ if (!baselineIds.has(id) || violatedThisRound)
125
+ changed.push(text);
126
+ else
127
+ unchangedCount++;
128
+ }
129
+ let removedCount = 0;
130
+ if (emphasized.size > 0) {
131
+ const emphasizedIds = new Set([...emphasized].map((text) => constraintIdOf(text, metadata)));
132
+ for (const id of baselineIds) {
133
+ if (!currentIds.has(id) && !emphasizedIds.has(id))
134
+ removedCount++;
135
+ }
136
+ }
137
+ else {
138
+ for (const id of baselineIds) {
139
+ if (!currentIds.has(id))
140
+ removedCount++;
141
+ }
142
+ }
143
+ return { changed, unchangedCount, removedCount };
144
+ }
145
+ export function diffSubGoals(baseline, activeSubs) {
146
+ if (!baseline)
147
+ return { changed: activeSubs, unchangedCount: 0, removedCount: 0 };
148
+ const baselineById = new Map(baseline.subGoals);
149
+ const currentIds = new Set(activeSubs.map((sg) => sg.id));
150
+ const changed = [];
151
+ let unchangedCount = 0;
152
+ for (const sg of activeSubs) {
153
+ const prevStatus = baselineById.get(sg.id);
154
+ if (prevStatus === undefined || prevStatus !== sg.status)
155
+ changed.push(sg);
156
+ else
157
+ unchangedCount++;
158
+ }
159
+ let removedCount = 0;
160
+ for (const [id] of baseline.subGoals) {
161
+ if (!currentIds.has(id))
162
+ removedCount++;
163
+ }
164
+ return { changed, unchangedCount, removedCount };
165
+ }
166
+ /** v3.2: True when a milestone boundary was crossed since the baseline —
167
+ * the Recent Rounds section then renders in full (a phase ended). */
168
+ export function milestoneBoundaryChanged(baseline, milestones) {
169
+ if (!baseline)
170
+ return false;
171
+ const baselineRanges = new Set(baseline.milestoneRanges.map(([s, e]) => `${s}:${e}`));
172
+ return milestones.some((m) => !baselineRanges.has(`${m.round_range.start}:${m.round_range.end}`));
173
+ }
60
174
  function commonMandatorySections(state,
61
175
  /** v2.11: L0 skips ID rendering to stay within tight budget. */
62
176
  level) {
@@ -66,41 +180,12 @@ level) {
66
180
  text: section("Objective", state.objective),
67
181
  mandatory: true,
68
182
  },
69
- ];
70
- const phase = state.phaseHistory.at(-1);
71
- if (phase) {
72
- sections.push({
73
- id: "current_phase",
74
- text: section("Current Phase", `${phase.label} (${phase.status})\n${phase.outcome}`),
75
- mandatory: false,
76
- });
77
- }
78
- const focus = state.currentFocus || state.objective;
79
- if (focus.trim()) {
80
- sections.push({
81
- id: "current_focus",
82
- text: section("Current Focus", focus),
183
+ {
184
+ id: "current_task",
185
+ text: section("Current Task", state.currentTask),
83
186
  mandatory: true,
84
- });
85
- }
86
- if (state.todoProjection) {
87
- sections.push({
88
- id: "todo_projection",
89
- text: section("Active Todo Projection", `${state.todoProjection.action}\nCompletion: ${state.todoProjection.completionSignal}`),
90
- mandatory: false,
91
- });
92
- }
93
- sections.push({
94
- id: "verification",
95
- text: section("Verification Gate", verificationText(state) || "No verification contradiction is currently open."),
96
- mandatory: true,
97
- });
98
- if (state.activeGate) {
99
- const body = state.activeGate.kind === "user"
100
- ? [`Owner: user`, `Question: ${state.activeGate.question}`, `Blocked scope: ${state.activeGate.blockedScope.join("; ")}`, `Safe work: ${state.activeGate.allowedWork.join("; ")}`].join("\n")
101
- : [`Owner: agent`, `Problem: ${state.activeGate.problem}`, `Required evidence: ${state.activeGate.requiredEvidence.join("; ")}`, `Resolution: ${state.activeGate.suggestedResolution}`].join("\n");
102
- sections.push({ id: "active_gate", text: section("Active Gate", body), mandatory: true });
103
- }
187
+ },
188
+ ];
104
189
  if (state.hardConstraints.length > 0) {
105
190
  // v2.11: L0 uses plain bullets (tight budget); L1/L2 show constraint IDs
106
191
  const body = level === "l0"
@@ -112,6 +197,13 @@ level) {
112
197
  mandatory: true,
113
198
  });
114
199
  }
200
+ if (state.verificationFlags.length > 0) {
201
+ sections.push({
202
+ id: "verification",
203
+ text: section("Verification Gate", verificationText(state, level)),
204
+ mandatory: true,
205
+ });
206
+ }
115
207
  return sections;
116
208
  }
117
209
  function l0Sections(state) {
@@ -136,17 +228,10 @@ function l0Sections(state) {
136
228
  }
137
229
  return sections;
138
230
  }
139
- function l1Sections(state) {
231
+ function l1Sections(state, baseline, emphasized) {
140
232
  const sections = commonMandatorySections(state, "l1");
141
- const openRegression = state.regressionObligations.filter((obligation) => obligation.status !== "satisfied");
142
- if (openRegression.length > 0) {
143
- sections.push({
144
- id: "regression_obligations",
145
- text: section("Regression Obligations", bullets(openRegression.map((item) => `[${item.status}] ${item.description}`))),
146
- mandatory: true,
147
- });
148
- }
149
233
  const active = activeNonHardConstraints(state);
234
+ const collapseEnabled = getPolicy().prompt.l1_collapse_enabled;
150
235
  if (state.changesSinceLastRound.length > 0) {
151
236
  sections.push({
152
237
  id: "changes",
@@ -154,12 +239,36 @@ function l1Sections(state) {
154
239
  mandatory: false,
155
240
  });
156
241
  }
242
+ // v3.2: Active Constraints — collapse unchanged items against the previous
243
+ // round's presentation; new and violated-this-round constraints render in
244
+ // full. The collapse line points at the state file (regenerated every
245
+ // round), so no information is lost — only presentation is compacted.
157
246
  if (active.length > 0) {
158
- sections.push({
159
- id: "active_constraints",
160
- text: section("Active Constraints / Success Criteria", bulletsWithIds(active, "c")),
161
- mandatory: false,
162
- });
247
+ const diff = diffConstraints(baseline, active, state.constraintMetadata, state.round, emphasized);
248
+ const renderFull = !collapseEnabled || !baseline
249
+ || diff.unchangedCount < COLLAPSE_MIN_UNCHANGED;
250
+ if (renderFull) {
251
+ sections.push({
252
+ id: "active_constraints",
253
+ text: section("Active Constraints / Success Criteria", bulletsWithIds(active, "c")),
254
+ mandatory: false,
255
+ });
256
+ }
257
+ else {
258
+ const lines = diff.changed.map((text) => {
259
+ const id = constraintIdOf(text, state.constraintMetadata);
260
+ const violated = state.constraintMetadata.find((m) => m.text === text)
261
+ ?.last_violated_at_round === state.round;
262
+ const suffix = violated ? " (violated this round)" : " (new this round)";
263
+ return `- [\`${id}\`] ${text}${suffix}`;
264
+ });
265
+ lines.push(`- … ${diff.unchangedCount} unchanged constraints, ${diff.removedCount} demoted since R${baseline.round} (see state file)`);
266
+ sections.push({
267
+ id: "active_constraints",
268
+ text: section("Active Constraints / Success Criteria", lines.join("\n")),
269
+ mandatory: false,
270
+ });
271
+ }
163
272
  }
164
273
  if (state.remainingCriteria.length > 0) {
165
274
  sections.push({
@@ -172,32 +281,47 @@ function l1Sections(state) {
172
281
  sections.push({
173
282
  id: "blockers",
174
283
  text: section("Blockers", bullets(state.blockers)),
175
- mandatory: true,
176
- });
177
- }
178
- if (state.handoff.verifiedFacts.length > 0) {
179
- sections.push({
180
- id: "verified_facts",
181
- text: section("Latest Verified Facts", bulletsWithIds(state.handoff.verifiedFacts, "cr")),
182
- mandatory: true,
284
+ mandatory: false,
183
285
  });
184
286
  }
185
- // v2.2: Sub-Goal Dashboard (compact — L1)
287
+ // v2.2: Sub-Goal Dashboard (compact — L1). v3.2: unchanged sub-goals
288
+ // collapse to a count line; new/transitioned ones render in full.
186
289
  const activeSubs = state.subGoals.filter((sg) => sg.status === "in_progress" || sg.status === "pending");
187
290
  if (activeSubs.length > 0) {
188
- const maxShow = 5;
189
- const items = activeSubs.slice(0, maxShow).map((sg) => {
190
- const icon = sg.status === "in_progress" ? "🔄" : "⏳";
191
- return `${icon} [\`${sg.id}\`] ${sg.description}`;
192
- });
193
- if (activeSubs.length > maxShow) {
194
- items.push(`... and ${activeSubs.length - maxShow} more`);
291
+ const diff = diffSubGoals(baseline, activeSubs);
292
+ const renderFull = !collapseEnabled || !baseline
293
+ || diff.unchangedCount < COLLAPSE_MIN_UNCHANGED;
294
+ if (renderFull) {
295
+ const maxShow = 5;
296
+ const items = activeSubs.slice(0, maxShow).map((sg) => {
297
+ const icon = sg.status === "in_progress" ? "🔄" : "⏳";
298
+ return `${icon} [\`${sg.id}\`] ${sg.description}`;
299
+ });
300
+ if (activeSubs.length > maxShow) {
301
+ items.push(`... and ${activeSubs.length - maxShow} more`);
302
+ }
303
+ sections.push({
304
+ id: "sub_goals",
305
+ text: section("Active Sub-Goals", items.join("\n")),
306
+ mandatory: false,
307
+ });
308
+ }
309
+ else {
310
+ const transitionCount = diff.changed.filter((sg) => {
311
+ const prev = baseline.subGoals.find(([id]) => id === sg.id);
312
+ return prev !== undefined && prev[1] !== sg.status;
313
+ }).length;
314
+ const items = diff.changed.slice(0, 5).map((sg) => {
315
+ const icon = sg.status === "in_progress" ? "🔄" : "⏳";
316
+ return `${icon} [\`${sg.id}\`] ${sg.description}`;
317
+ });
318
+ items.push(`- … ${diff.unchangedCount} unchanged sub-goals, ${transitionCount + diff.removedCount} changed since R${baseline.round} (see state file)`);
319
+ sections.push({
320
+ id: "sub_goals",
321
+ text: section("Active Sub-Goals", items.join("\n")),
322
+ mandatory: false,
323
+ });
195
324
  }
196
- sections.push({
197
- id: "sub_goals",
198
- text: section("Active Sub-Goals", items.join("\n")),
199
- mandatory: false,
200
- });
201
325
  }
202
326
  if (state.discoveries.length > 0) {
203
327
  sections.push({
@@ -206,24 +330,63 @@ function l1Sections(state) {
206
330
  mandatory: false,
207
331
  });
208
332
  }
333
+ // v3.2: Recent Rounds — keep the newest KEEP_RECENT_ROUNDS in full, fold
334
+ // older ones into one pointer line (unless a milestone boundary crossed).
209
335
  if (state.rollingOutcomes.length > 0) {
336
+ const boundaryChanged = milestoneBoundaryChanged(baseline, state.milestones);
337
+ const collapseable = collapseEnabled && baseline !== null
338
+ && state.rollingOutcomes.length > KEEP_RECENT_ROUNDS && !boundaryChanged;
339
+ if (collapseable) {
340
+ const kept = state.rollingOutcomes.slice(-KEEP_RECENT_ROUNDS);
341
+ const earlier = state.rollingOutcomes.length - kept.length;
342
+ const lines = [
343
+ ...kept,
344
+ `- … ${earlier} earlier round${earlier === 1 ? "" : "s"} (see state file)`,
345
+ ];
346
+ sections.push({
347
+ id: "rolling_outcomes",
348
+ text: section("Recent Rounds", lines.join("\n")),
349
+ mandatory: false,
350
+ });
351
+ }
352
+ else {
353
+ sections.push({
354
+ id: "rolling_outcomes",
355
+ text: section("Recent Rounds", bullets(state.rollingOutcomes)),
356
+ mandatory: false,
357
+ });
358
+ }
359
+ }
360
+ // v3.2: L1 also renders the explicit external context (previously L2-only).
361
+ // Placed after the changes delta, within the soft budget.
362
+ if (state.externalContext) {
210
363
  sections.push({
211
- id: "rolling_outcomes",
212
- text: section("Recent Rounds", bullets(state.rollingOutcomes)),
364
+ id: "external_context",
365
+ text: section("External Context", state.externalContext),
213
366
  mandatory: false,
214
367
  });
215
368
  }
216
- if (state.recurringIssues.length > 0) {
369
+ // v3.2: Recurring Issues carries the deterministic violation lessons (with
370
+ // counts and rounds) when available; falls back to the rolling window text.
371
+ if (state.recurringIssues.length > 0 || state.lessons.length > 0) {
372
+ const violationLessons = state.lessons
373
+ .filter((lesson) => lesson.kind === "constraint_violation")
374
+ .slice(0, 3);
375
+ const body = violationLessons.length > 0
376
+ ? violationLessons
377
+ .map((lesson) => `- ${lesson.text} (violated ${lesson.count}×: R${lesson.rounds.join(", R")})`)
378
+ .join("\n")
379
+ : bullets(state.recurringIssues);
217
380
  sections.push({
218
381
  id: "recurring_issues",
219
- text: section("Recurring Issues", bullets(state.recurringIssues)),
382
+ text: section("Recurring Issues", body),
220
383
  mandatory: false,
221
384
  });
222
385
  }
223
- if (state.suggestedNextAction) {
386
+ if (state.nextAction) {
224
387
  sections.push({
225
- id: "agent_intent",
226
- text: section("Suggested Next Action", state.suggestedNextAction),
388
+ id: "next_action",
389
+ text: section("Next Action", state.nextAction),
227
390
  mandatory: false,
228
391
  });
229
392
  }
@@ -231,14 +394,6 @@ function l1Sections(state) {
231
394
  }
232
395
  function l2Sections(state, fullStateMarkdown) {
233
396
  const sections = commonMandatorySections(state, "l2");
234
- const openRegression = state.regressionObligations.filter((obligation) => obligation.status !== "satisfied");
235
- if (openRegression.length > 0) {
236
- sections.push({
237
- id: "regression_obligations",
238
- text: section("Regression Obligations", bullets(openRegression.map((item) => `[${item.status}] ${item.description}`))),
239
- mandatory: true,
240
- });
241
- }
242
397
  const fullState = fullStateMarkdown?.trim();
243
398
  if (fullState) {
244
399
  sections.push({
@@ -255,7 +410,6 @@ function l2Sections(state, fullStateMarkdown) {
255
410
  ["changes", "Changes Since Last Round", state.changesSinceLastRound, null],
256
411
  ["remaining", "Remaining", state.remainingCriteria, "cr"],
257
412
  ["blockers", "Blockers", state.blockers, null],
258
- ["verified_facts", "Latest Verified Facts", state.handoff.verifiedFacts, "cr"],
259
413
  ["discoveries", "Discoveries", state.discoveries, null],
260
414
  ["rolling_outcomes", "Cross-Round Outcomes", state.rollingOutcomes, null],
261
415
  ["recurring_issues", "Recurring Issues", state.recurringIssues, null],
@@ -266,25 +420,46 @@ function l2Sections(state, fullStateMarkdown) {
266
420
  const body = idPrefix
267
421
  ? bulletsWithIds(values, idPrefix)
268
422
  : bullets(values);
269
- sections.push({
270
- id,
271
- text: section(title, body),
272
- mandatory: id === "blockers" || id === "verified_facts",
273
- });
423
+ sections.push({ id, text: section(title, body), mandatory: false });
274
424
  }
275
425
  }
276
- if (state.phaseHistory.length > 0) {
277
- const phaseText = state.phaseHistory
278
- .map((phase) => {
279
- const range = phase.closedAtRound
280
- ? `R${phase.enteredAtRound}-R${phase.closedAtRound}`
281
- : `R${phase.enteredAtRound}-current`;
282
- return `**${phase.label}** (${range}, ${phase.status})\n Outcome: ${phase.outcome}`;
426
+ // v3.2: Lessons learned — repeated violations / verification failures
427
+ // across the whole loop (not just the rolling window).
428
+ if (state.lessons.length > 0) {
429
+ const lessonLines = state.lessons.slice(0, 8).map((lesson) => {
430
+ const icon = lesson.kind === "constraint_violation"
431
+ ? "🚫"
432
+ : lesson.kind === "verification_error" ? "⚠️" : "ℹ️";
433
+ return `- ${icon} ${lesson.text} — ${lesson.count}× (R${lesson.rounds.join(", R")})`;
434
+ });
435
+ if (state.lessons.length > 8) {
436
+ lessonLines.push(`- ... and ${state.lessons.length - 8} more`);
437
+ }
438
+ sections.push({
439
+ id: "lessons",
440
+ text: section("Lessons Learned", lessonLines.join("\n")),
441
+ mandatory: false,
442
+ });
443
+ }
444
+ if (state.milestones.length > 0) {
445
+ const milestoneText = state.milestones
446
+ .map((m) => {
447
+ const kindIcon = m.kind === "agent_declared"
448
+ ? "🏁"
449
+ : m.kind === "criteria_milestone" ? "✅" : "📍";
450
+ const lines = [
451
+ `**${kindIcon} ${m.label}** (Rounds ${m.round_range.start}–${m.round_range.end}, ${(m.progress_at_boundary * 100).toFixed(0)}%)`,
452
+ ` Outcome: ${m.outcome}`,
453
+ ];
454
+ if (m.resolved_constraints.length > 0) {
455
+ lines.push(` Resolved: ${m.resolved_constraints.slice(0, 3).join("; ")}`);
456
+ }
457
+ return lines.join("\n");
283
458
  })
284
459
  .join("\n\n");
285
460
  sections.push({
286
- id: "phase_history",
287
- text: section("Phase History", phaseText),
461
+ id: "milestones",
462
+ text: section("Phase History", milestoneText),
288
463
  mandatory: false,
289
464
  });
290
465
  }
@@ -339,10 +514,10 @@ function l2Sections(state, fullStateMarkdown) {
339
514
  mandatory: false,
340
515
  });
341
516
  }
342
- if (state.suggestedNextAction) {
517
+ if (state.nextAction) {
343
518
  sections.push({
344
- id: "agent_intent",
345
- text: section("Suggested Next Action", state.suggestedNextAction),
519
+ id: "next_action",
520
+ text: section("Next Action", state.nextAction),
346
521
  mandatory: false,
347
522
  });
348
523
  }
@@ -380,6 +555,22 @@ function l2Sections(state, fullStateMarkdown) {
380
555
  lines.push(`- ... and ${state.progress.filesChanged.length - 10} more`);
381
556
  }
382
557
  }
558
+ // v3.2: Goal → criteria → evidence vertical view — per-criterion status
559
+ // with the round it was met and linked sub-goals. IDs follow
560
+ // constraint_id_enabled like every other ID-rendering path.
561
+ if (state.criterionStatuses.length > 0) {
562
+ const idEnabled = getPolicy().evolution.constraint_id_enabled;
563
+ lines.push("", "**Goal → Criteria**:");
564
+ for (const cs of state.criterionStatuses) {
565
+ const icon = cs.status === "met" ? "✅" : cs.status === "remaining" ? "⬜" : "❔";
566
+ const idTag = idEnabled ? ` [\`${cs.id}\`]` : "";
567
+ const met = cs.met_at_round !== undefined ? `(met R${cs.met_at_round})` : "";
568
+ const related = cs.related_subgoal_ids.length > 0
569
+ ? ` [↔ ${cs.related_subgoal_ids.join(", ")}]`
570
+ : "";
571
+ lines.push(`- ${icon}${idTag} ${cs.text} ${met}${related}`);
572
+ }
573
+ }
383
574
  sections.push({
384
575
  id: "progress",
385
576
  text: section("Progress Dashboard", lines.join("\n")),
@@ -439,27 +630,22 @@ function l2Sections(state, fullStateMarkdown) {
439
630
  }
440
631
  return sections;
441
632
  }
442
- function selectSections(input) {
443
- const sections = input.mode === "full"
444
- ? l2Sections(input.state, input.fullStateMarkdown)
445
- : input.level === "l0" ? l0Sections(input.state)
446
- : input.level === "l1" ? l1Sections(input.state)
447
- : l2Sections(input.state, input.fullStateMarkdown);
448
- if (input.agentIntent?.trim()) {
449
- sections.push({
450
- id: "agent_intent",
451
- text: section("Agent Intent (non-authoritative)", `${input.agentIntent.trim()}\nRuntime-derived Focus, Todo and Suggested Next Action take precedence.`),
452
- mandatory: false,
453
- });
633
+ function selectSections(input, emphasized) {
634
+ if (input.mode === "full") {
635
+ return l2Sections(input.state, input.fullStateMarkdown);
454
636
  }
455
- return sections;
637
+ if (input.level === "l0")
638
+ return l0Sections(input.state);
639
+ if (input.level === "l1") {
640
+ return l1Sections(input.state, input.presentedBaseline ?? null, emphasized);
641
+ }
642
+ return l2Sections(input.state, input.fullStateMarkdown);
456
643
  }
457
644
  function renderWithinBudget(sections, fixedText, budget) {
458
645
  const mandatory = sections.filter((item) => item.mandatory);
459
646
  const optional = sections.filter((item) => !item.mandatory);
460
647
  let rendered = fixedText + mandatory.map((item) => item.text).join("");
461
648
  const included = mandatory.filter((item) => item.text).map((item) => item.id);
462
- const omitted = [];
463
649
  for (const item of optional) {
464
650
  if (!item.text)
465
651
  continue;
@@ -467,11 +653,8 @@ function renderWithinBudget(sections, fixedText, budget) {
467
653
  rendered += item.text;
468
654
  included.push(item.id);
469
655
  }
470
- else {
471
- omitted.push(item.id);
472
- }
473
656
  }
474
- return { rendered, included, omitted };
657
+ return { rendered, included };
475
658
  }
476
659
  // ═══════════════════════════════════════════════════════════════════════════
477
660
  // v2.9: Prompt Requests — model-expressed information needs
@@ -483,13 +666,12 @@ function renderWithinBudget(sections, fixedText, budget) {
483
666
  function matchEmphasize(targets, candidates, cap) {
484
667
  if (targets.length === 0 || candidates.length === 0)
485
668
  return [];
486
- const idRegex = /^(c|cr|sg)-[a-f0-9]{8}$/;
487
669
  const scored = candidates.map((c) => {
488
670
  let best = 0;
489
671
  for (const t of targets) {
490
672
  let s = 0;
491
673
  // Phase 1: exact ID match (v2.11)
492
- const idMatch = t.match(idRegex);
674
+ const idMatch = t.match(STABLE_ID_RE);
493
675
  if (idMatch) {
494
676
  const candidateId = idMatch[1] + "-" + deriveItemId(c);
495
677
  if (candidateId === t)
@@ -532,7 +714,11 @@ function renderConfusionAlerts(points, state, expandAll) {
532
714
  { keyword: "objective", label: "Loop Objective", ref: "## Loop Objective" },
533
715
  { keyword: "loop", label: "Loop Summary", ref: "## Loop Summary" },
534
716
  ];
535
- const shown = expandAll ? points : points.slice(0, 1);
717
+ // v2.14: L2 renders up to the policy cap (was: every confusion point,
718
+ // unbounded by max_confusion_points); L1 keeps a single alert.
719
+ const shown = expandAll
720
+ ? points.slice(0, getPolicy().prompt.max_confusion_points)
721
+ : points.slice(0, 1);
536
722
  for (const point of shown) {
537
723
  const truncated = point.length > 200 ? point.slice(0, 197) + "…" : point;
538
724
  // Find best-matching state section
@@ -545,7 +731,7 @@ function renderConfusionAlerts(points, state, expandAll) {
545
731
  bestSection = sec;
546
732
  }
547
733
  }
548
- const pointer = bestSection && bestScore >= 0.15
734
+ const pointer = bestSection && bestScore >= getPolicy().prompt.confusion_section_threshold
549
735
  ? `→ Check **${bestSection.label}** (\`${bestSection.ref}\`) in the state file.`
550
736
  : "→ Read the full state file for relevant context.";
551
737
  lines.push(`- **"${truncated}"**`);
@@ -581,14 +767,20 @@ function renderCriticalContext(emphasized, state) {
581
767
  function renderExpandedSection(sectionName, state) {
582
768
  switch (sectionName) {
583
769
  case "milestones": {
584
- if (state.phaseHistory.length === 0)
770
+ if (state.milestones.length === 0)
585
771
  return "";
586
- const text = state.phaseHistory
587
- .map((phase) => {
588
- const range = phase.closedAtRound
589
- ? `R${phase.enteredAtRound}-R${phase.closedAtRound}`
590
- : `R${phase.enteredAtRound}-current`;
591
- return `**${phase.label}** (${range}, ${phase.status})\n Outcome: ${phase.outcome}`;
772
+ const text = state.milestones
773
+ .map((m) => {
774
+ const icon = m.kind === "agent_declared" ? "🏁" : m.kind === "criteria_milestone" ? "✅" : "📍";
775
+ const lines = [
776
+ `**${icon} ${m.label}** (R${m.round_range.start}–R${m.round_range.end}, ${(m.progress_at_boundary * 100).toFixed(0)}%)`,
777
+ ` Outcome: ${m.outcome}`,
778
+ ];
779
+ if (m.carried_constraints.length > 0)
780
+ lines.push(` Carried: ${m.carried_constraints.slice(0, 3).join("; ")}`);
781
+ if (m.resolved_constraints.length > 0)
782
+ lines.push(` Resolved: ${m.resolved_constraints.slice(0, 3).join("; ")}`);
783
+ return lines.join("\n");
592
784
  })
593
785
  .join("\n\n");
594
786
  return section("Expanded Phase History", text);
@@ -686,7 +878,6 @@ export function assemblePromptArtifact(input) {
686
878
  // than relying on potentially-compacted conversation history.
687
879
  const fullContextReasons = new Set([
688
880
  "first_round", "plan_boundary", "checkpoint_boundary", "goal_changed",
689
- "constraint_changed",
690
881
  "missing_previous_state", "verification_contradicted",
691
882
  "rejection_rehydrate", "recovery_boundary", "state_drift",
692
883
  "periodic_refresh",
@@ -695,8 +886,13 @@ export function assemblePromptArtifact(input) {
695
886
  || input.mode === "pointer"
696
887
  || input.reasons.some((r) => fullContextReasons.has(r));
697
888
  const stateFilePath = input.state.stateFilePath;
889
+ // v3.2: L1 annotates the pointer with the round the freshly-regenerated
890
+ // state file describes (it is rewritten every compile); L0/L2 keep the
891
+ // plain pointer byte-identical.
698
892
  const stateFileRef = stateFilePath
699
- ? `📄 Full state: \`${stateFilePath}\`\n`
893
+ ? (input.level === "l1"
894
+ ? `📄 Full state: \`${stateFilePath}\` (updated round ${input.state.round})\n`
895
+ : `📄 Full state: \`${stateFilePath}\`\n`)
700
896
  : "";
701
897
  const readInstruction = needsFullContext && stateFilePath
702
898
  ? [
@@ -716,13 +912,11 @@ export function assemblePromptArtifact(input) {
716
912
  readInstruction,
717
913
  "",
718
914
  ].join("\n");
719
- const sections = selectSections(input);
720
915
  const pointer = stateFilePath
721
916
  ? section("Full State", stateFilePath)
722
917
  : "";
723
- const reportBlock = input.roundReportBlock ?? "";
724
- const evaluation = reportBlock.trim()
725
- ? `${reportBlock.trim()}\n`
918
+ const evaluation = input.selfEvaluationBlock.trim()
919
+ ? `${input.selfEvaluationBlock.trim()}\n`
726
920
  : "";
727
921
  const howToRespond = [
728
922
  "## How to Complete This Round",
@@ -730,20 +924,24 @@ export function assemblePromptArtifact(input) {
730
924
  "1. Execute the task described above. Do NOT generate another prompt or plan — act.",
731
925
  "2. When finished, call the MCP tool **`loopforge_next`** with these parameters:",
732
926
  " - `sessionId`: the session ID from `loopforge_start`.",
733
- " - `report`: a canonical `RoundReport` object (see schema below).",
927
+ " - `evaluation`: a structured self-assessment object (see schema below).",
734
928
  "",
735
929
  "### Evaluation Rules",
736
930
  "",
737
- "- Use `outcome=success` only when all criteria have runtime-verified evidence; use `continuation=stop` only when you are requesting no further round.",
738
- "- `violations`: list every constraint actually violated.",
739
- "- Put claims in `evidence.claims` and reference configured providers or commands through `evidenceRefs`; provenance is runtime-owned.",
740
- "- Do not estimate progress. LoopForge derives it from verified claims and remaining criteria.",
931
+ "- `success`: Set to **`true` ONLY** when ALL hard constraints are met AND",
932
+ " the task goal is fully achieved. If anything remains incomplete, set `false`.",
933
+ "- `should_continue`: Set to **`false` ONLY** when the ENTIRE loop task is done.",
934
+ " Partial progress or completed subtasks → `true`.",
935
+ "- `constraint_violations`: Be honest. List every constraint you actually violated.",
936
+ "- `execution_evidence.files_changed`: List files you modified — used for verification.",
937
+ "- `execution_evidence.test_results`: Report actual test runner output.",
938
+ "- `progress_estimate`: A number 0.0–1.0 reflecting overall task completion.",
741
939
  "",
742
940
  "### If the Prompt Says \"REJECTED\"",
743
941
  "",
744
942
  "- Re-execute the **same round**. Do NOT advance the round counter.",
745
943
  "- Read the Required Fix section, address every issue, then submit again.",
746
- "- Use `loopforge_next` with a corrected report.",
944
+ "- Use `loopforge_next` with a corrected evaluation.",
747
945
  "",
748
946
  evaluation,
749
947
  ].join("\n");
@@ -768,7 +966,45 @@ export function assemblePromptArtifact(input) {
768
966
  ...input.state.changesSinceLastRound,
769
967
  ], emphasizeCap)
770
968
  : [];
969
+ // v2.14: emphasize is a pure reorder (zero token overhead) — the matched
970
+ // items are MOVED to the Critical Context section, not duplicated. Filter
971
+ // them out of the source arrays before section rendering; unmatched
972
+ // emphasizes are dropped (nothing to reorder → nothing rendered).
973
+ // v3.2.1: hard constraints and success criteria live in BOTH their own
974
+ // sections (Active Hard Constraints / Success Criteria) and the active
975
+ // constraint list (loop-compiler merges objective.hard_constraints and
976
+ // success_criteria into constraints_active), so they must be filtered
977
+ // from BOTH sources too — otherwise the emphasized item renders twice.
978
+ const emphasizedSet = new Set(emphasized);
979
+ const stateForSections = emphasizedSet.size > 0
980
+ ? {
981
+ ...input.state,
982
+ activeConstraints: input.state.activeConstraints.filter((item) => !emphasizedSet.has(item)),
983
+ hardConstraints: input.state.hardConstraints.filter((item) => !emphasizedSet.has(item)),
984
+ successCriteria: input.state.successCriteria.filter((item) => !emphasizedSet.has(item)),
985
+ discoveries: input.state.discoveries.filter((item) => !emphasizedSet.has(item)),
986
+ blockers: input.state.blockers.filter((item) => !emphasizedSet.has(item)),
987
+ changesSinceLastRound: input.state.changesSinceLastRound.filter((item) => !emphasizedSet.has(item)),
988
+ }
989
+ : input.state;
990
+ const sections = selectSections({ ...input, state: stateForSections }, emphasizedSet);
771
991
  const criticalContextText = renderCriticalContext(emphasized, input.state);
992
+ // v3.2: L1 only — snapshot what this prompt actually presented, so the
993
+ // NEXT L1 compile can diff against it (persisted via PromptArtifact →
994
+ // lineage). L0/L2 leave it undefined: their presentation semantics differ
995
+ // (L2's dashboard includes done/canceled sub-goals, which would
996
+ // contaminate the L1 active-set diff).
997
+ let presentedState;
998
+ if (levelIsL1) {
999
+ const activeSubsForSnapshot = stateForSections.subGoals.filter((sg) => sg.status === "in_progress" || sg.status === "pending");
1000
+ presentedState = {
1001
+ round: input.state.round,
1002
+ constraintIds: activeNonHardConstraints(stateForSections)
1003
+ .map((text) => constraintIdOf(text, stateForSections.constraintMetadata)),
1004
+ subGoals: activeSubsForSnapshot.map((sg) => [sg.id, sg.status]),
1005
+ milestoneRanges: input.state.milestones.map((m) => [m.round_range.start, m.round_range.end]),
1006
+ };
1007
+ }
772
1008
  // Expand: L1 only, max 1 section (L2 already expanded, L0 ignored)
773
1009
  const expandCap = levelIsL1 ? policy.max_expand_l1 : 0;
774
1010
  const expandedSection = (pr?.expand?.length && expandCap > 0)
@@ -802,7 +1038,7 @@ export function assemblePromptArtifact(input) {
802
1038
  ];
803
1039
  const promptHash = createHash("sha256").update(renderedPrompt).digest("hex");
804
1040
  return {
805
- format: PROMPT_ARTIFACT_FORMAT,
1041
+ schemaVersion: PROMPT_ARTIFACT_SCHEMA_VERSION,
806
1042
  roundId: `loop:${input.state.loopId}:round:${input.state.round}`,
807
1043
  attempt,
808
1044
  level: input.level,
@@ -812,18 +1048,11 @@ export function assemblePromptArtifact(input) {
812
1048
  stateHash,
813
1049
  basePromptVersion: BASE_PROMPT_VERSION,
814
1050
  includedSections,
815
- omittedSections: selected.omitted,
816
1051
  budgetChars: budget,
817
1052
  charCount: renderedPrompt.length,
818
1053
  budgetExceeded: renderedPrompt.length > budget,
819
- // lastL2Round / lastMeaningfulProgressRound / stateAgeRounds are NOT
820
- // assembled here: the assembler has no vault history and can only guess,
821
- // so the values were always immediately overwritten by the compiler's
822
- // vault-derived backfill. They stay undefined at this layer.
823
- phaseId: input.state.phaseHistory.at(-1)?.id,
824
- focusId: input.state.todoProjection?.sourceId,
825
- todoId: input.state.todoProjection?.id,
826
1054
  generatedAt: Date.now(),
1055
+ ...(presentedState ? { presentedState } : {}),
827
1056
  };
828
1057
  }
829
1058
  //# sourceMappingURL=prompt-assembler.js.map