loopforge 2.11.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 (117) hide show
  1. package/README.md +4 -4
  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 +21 -9
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +34 -16
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +16 -6
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +40 -0
  14. package/dist/cognitive-governance.d.ts.map +1 -0
  15. package/dist/cognitive-governance.js +126 -0
  16. package/dist/cognitive-governance.js.map +1 -0
  17. package/dist/enforcement-gate.d.ts +8 -3
  18. package/dist/enforcement-gate.d.ts.map +1 -1
  19. package/dist/enforcement-gate.js +419 -92
  20. package/dist/enforcement-gate.js.map +1 -1
  21. package/dist/engine.d.ts +47 -7
  22. package/dist/engine.d.ts.map +1 -1
  23. package/dist/engine.js +303 -60
  24. package/dist/engine.js.map +1 -1
  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/generate-schema.js +6 -1
  30. package/dist/generate-schema.js.map +1 -1
  31. package/dist/index.d.ts +3 -3
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/loop-compiler.d.ts +41 -3
  36. package/dist/loop-compiler.d.ts.map +1 -1
  37. package/dist/loop-compiler.js +310 -38
  38. package/dist/loop-compiler.js.map +1 -1
  39. package/dist/loop-projection.d.ts +26 -0
  40. package/dist/loop-projection.d.ts.map +1 -0
  41. package/dist/loop-projection.js +142 -0
  42. package/dist/loop-projection.js.map +1 -0
  43. package/dist/loop-store.d.ts +91 -41
  44. package/dist/loop-store.d.ts.map +1 -1
  45. package/dist/loop-store.js +243 -173
  46. package/dist/loop-store.js.map +1 -1
  47. package/dist/mcp/round-lifecycle.d.ts +216 -0
  48. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  49. package/dist/mcp/round-lifecycle.js +926 -0
  50. package/dist/mcp/round-lifecycle.js.map +1 -0
  51. package/dist/mcp/server.d.ts +3 -2
  52. package/dist/mcp/server.d.ts.map +1 -1
  53. package/dist/mcp/server.js +65 -9
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/mcp/session.d.ts +47 -139
  56. package/dist/mcp/session.d.ts.map +1 -1
  57. package/dist/mcp/session.js +284 -784
  58. package/dist/mcp/session.js.map +1 -1
  59. package/dist/mcp/tools.d.ts +23 -1
  60. package/dist/mcp/tools.d.ts.map +1 -1
  61. package/dist/mcp/tools.js +270 -79
  62. package/dist/mcp/tools.js.map +1 -1
  63. package/dist/policy-metrics.d.ts +11 -0
  64. package/dist/policy-metrics.d.ts.map +1 -1
  65. package/dist/policy-metrics.js +118 -21
  66. package/dist/policy-metrics.js.map +1 -1
  67. package/dist/policy.d.ts +26 -1
  68. package/dist/policy.d.ts.map +1 -1
  69. package/dist/policy.js +13 -2
  70. package/dist/policy.js.map +1 -1
  71. package/dist/prompt-assembler.d.ts +34 -1
  72. package/dist/prompt-assembler.d.ts.map +1 -1
  73. package/dist/prompt-assembler.js +320 -42
  74. package/dist/prompt-assembler.js.map +1 -1
  75. package/dist/protocol.d.ts +169 -1
  76. package/dist/protocol.d.ts.map +1 -1
  77. package/dist/protocol.js +39 -0
  78. package/dist/protocol.js.map +1 -1
  79. package/dist/replay.d.ts +5 -5
  80. package/dist/replay.d.ts.map +1 -1
  81. package/dist/replay.js +9 -8
  82. package/dist/replay.js.map +1 -1
  83. package/dist/round-coordinator.d.ts +6 -3
  84. package/dist/round-coordinator.d.ts.map +1 -1
  85. package/dist/round-coordinator.js +38 -19
  86. package/dist/round-coordinator.js.map +1 -1
  87. package/dist/round-driver.d.ts +9 -4
  88. package/dist/round-driver.d.ts.map +1 -1
  89. package/dist/round-driver.js +15 -7
  90. package/dist/round-driver.js.map +1 -1
  91. package/dist/round-transaction.d.ts +7 -3
  92. package/dist/round-transaction.d.ts.map +1 -1
  93. package/dist/round-transaction.js +93 -88
  94. package/dist/round-transaction.js.map +1 -1
  95. package/dist/self-eval.d.ts +31 -1
  96. package/dist/self-eval.d.ts.map +1 -1
  97. package/dist/self-eval.js +115 -12
  98. package/dist/self-eval.js.map +1 -1
  99. package/dist/storage.d.ts +3 -4
  100. package/dist/storage.d.ts.map +1 -1
  101. package/dist/storage.js +5 -4
  102. package/dist/storage.js.map +1 -1
  103. package/dist/token-utils.d.ts +20 -7
  104. package/dist/token-utils.d.ts.map +1 -1
  105. package/dist/token-utils.js +68 -9
  106. package/dist/token-utils.js.map +1 -1
  107. package/dist/verification-gate.d.ts +54 -2
  108. package/dist/verification-gate.d.ts.map +1 -1
  109. package/dist/verification-gate.js +433 -25
  110. package/dist/verification-gate.js.map +1 -1
  111. package/loop_policy.json +35 -8
  112. package/package.json +3 -2
  113. package/skills/perception/SKILL.md +24 -6
  114. package/dist/backends/interface.d.ts +0 -39
  115. package/dist/backends/interface.d.ts.map +0 -1
  116. package/dist/backends/interface.js +0 -6
  117. package/dist/backends/interface.js.map +0 -1
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
8
  import { hashCanonicalState } from "./canonical-state.js";
9
- import { jaccardSimilarity } from "./token-utils.js";
9
+ import { deriveItemId, STABLE_ID_RE, jaccardSimilarity } from "./token-utils.js";
10
10
  import { getPolicy } from "./policy.js";
11
11
  export const PROMPT_ARTIFACT_SCHEMA_VERSION = 1;
12
12
  export const BASE_PROMPT_VERSION = "2.0.0";
@@ -23,10 +23,6 @@ function bullets(values, prefix = "- ") {
23
23
  }
24
24
  /** v2.11: Derive a stable item ID from text content.
25
25
  * Same hash strategy as computeGoalTextHash in loop-compiler.ts. */
26
- function deriveItemId(text) {
27
- const normalized = text.trim().replace(/\s+/g, " ").toLowerCase();
28
- return createHash("sha256").update(normalized).digest("hex").slice(0, 8);
29
- }
30
26
  /** v2.11: Render a bullet list with ID prefixes for constraints/criteria.
31
27
  * Each item gets a [prefix-XXXXXXXX] tag. Items that already look like IDs
32
28
  * are rendered as-is. When constraint_id_enabled is false, renders plain bullets. */
@@ -34,22 +30,62 @@ function bulletsWithIds(values, prefix) {
34
30
  const idEnabled = getPolicy().evolution.constraint_id_enabled;
35
31
  if (!idEnabled)
36
32
  return bullets(values);
37
- const idRegex = /^(c|cr|sg)-[a-f0-9]{8}$/;
38
33
  return values.map((v) => {
39
34
  const trimmed = v.trim();
40
- if (idRegex.test(trimmed)) {
35
+ if (STABLE_ID_RE.test(trimmed)) {
41
36
  return `- [\`${trimmed}\`] ${trimmed}`;
42
37
  }
43
38
  const id = prefix + "-" + deriveItemId(trimmed);
44
39
  return `- [\`${id}\`] ${trimmed}`;
45
40
  }).join("\n");
46
41
  }
47
- 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) {
48
77
  const lines = state.verificationFlags.map((flag) => {
49
78
  const icon = flag.severity === "error"
50
79
  ? "🚫"
51
80
  : flag.severity === "warn" ? "⚠️" : "ℹ️";
52
- 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)}`;
53
89
  });
54
90
  if (state.verificationFlags.some((flag) => flag.severity === "error")) {
55
91
  lines.push("- Gate Verdict: CONTRADICTED — resolve every error before claiming success.");
@@ -60,6 +96,81 @@ function activeNonHardConstraints(state) {
60
96
  const hard = new Set(state.hardConstraints);
61
97
  return state.activeConstraints.filter((value) => !hard.has(value));
62
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
+ }
63
174
  function commonMandatorySections(state,
64
175
  /** v2.11: L0 skips ID rendering to stay within tight budget. */
65
176
  level) {
@@ -89,7 +200,7 @@ level) {
89
200
  if (state.verificationFlags.length > 0) {
90
201
  sections.push({
91
202
  id: "verification",
92
- text: section("Verification Gate", verificationText(state)),
203
+ text: section("Verification Gate", verificationText(state, level)),
93
204
  mandatory: true,
94
205
  });
95
206
  }
@@ -117,9 +228,10 @@ function l0Sections(state) {
117
228
  }
118
229
  return sections;
119
230
  }
120
- function l1Sections(state) {
231
+ function l1Sections(state, baseline, emphasized) {
121
232
  const sections = commonMandatorySections(state, "l1");
122
233
  const active = activeNonHardConstraints(state);
234
+ const collapseEnabled = getPolicy().prompt.l1_collapse_enabled;
123
235
  if (state.changesSinceLastRound.length > 0) {
124
236
  sections.push({
125
237
  id: "changes",
@@ -127,12 +239,36 @@ function l1Sections(state) {
127
239
  mandatory: false,
128
240
  });
129
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.
130
246
  if (active.length > 0) {
131
- sections.push({
132
- id: "active_constraints",
133
- text: section("Active Constraints / Success Criteria", bulletsWithIds(active, "c")),
134
- mandatory: false,
135
- });
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
+ }
136
272
  }
137
273
  if (state.remainingCriteria.length > 0) {
138
274
  sections.push({
@@ -148,22 +284,44 @@ function l1Sections(state) {
148
284
  mandatory: false,
149
285
  });
150
286
  }
151
- // 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.
152
289
  const activeSubs = state.subGoals.filter((sg) => sg.status === "in_progress" || sg.status === "pending");
153
290
  if (activeSubs.length > 0) {
154
- const maxShow = 5;
155
- const items = activeSubs.slice(0, maxShow).map((sg) => {
156
- const icon = sg.status === "in_progress" ? "🔄" : "⏳";
157
- return `${icon} [\`${sg.id}\`] ${sg.description}`;
158
- });
159
- if (activeSubs.length > maxShow) {
160
- 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
+ });
161
324
  }
162
- sections.push({
163
- id: "sub_goals",
164
- text: section("Active Sub-Goals", items.join("\n")),
165
- mandatory: false,
166
- });
167
325
  }
168
326
  if (state.discoveries.length > 0) {
169
327
  sections.push({
@@ -172,17 +330,56 @@ function l1Sections(state) {
172
330
  mandatory: false,
173
331
  });
174
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).
175
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) {
176
363
  sections.push({
177
- id: "rolling_outcomes",
178
- text: section("Recent Rounds", bullets(state.rollingOutcomes)),
364
+ id: "external_context",
365
+ text: section("External Context", state.externalContext),
179
366
  mandatory: false,
180
367
  });
181
368
  }
182
- 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);
183
380
  sections.push({
184
381
  id: "recurring_issues",
185
- text: section("Recurring Issues", bullets(state.recurringIssues)),
382
+ text: section("Recurring Issues", body),
186
383
  mandatory: false,
187
384
  });
188
385
  }
@@ -226,6 +423,24 @@ function l2Sections(state, fullStateMarkdown) {
226
423
  sections.push({ id, text: section(title, body), mandatory: false });
227
424
  }
228
425
  }
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
+ }
229
444
  if (state.milestones.length > 0) {
230
445
  const milestoneText = state.milestones
231
446
  .map((m) => {
@@ -340,6 +555,22 @@ function l2Sections(state, fullStateMarkdown) {
340
555
  lines.push(`- ... and ${state.progress.filesChanged.length - 10} more`);
341
556
  }
342
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
+ }
343
574
  sections.push({
344
575
  id: "progress",
345
576
  text: section("Progress Dashboard", lines.join("\n")),
@@ -399,14 +630,15 @@ function l2Sections(state, fullStateMarkdown) {
399
630
  }
400
631
  return sections;
401
632
  }
402
- function selectSections(input) {
633
+ function selectSections(input, emphasized) {
403
634
  if (input.mode === "full") {
404
635
  return l2Sections(input.state, input.fullStateMarkdown);
405
636
  }
406
637
  if (input.level === "l0")
407
638
  return l0Sections(input.state);
408
- if (input.level === "l1")
409
- return l1Sections(input.state);
639
+ if (input.level === "l1") {
640
+ return l1Sections(input.state, input.presentedBaseline ?? null, emphasized);
641
+ }
410
642
  return l2Sections(input.state, input.fullStateMarkdown);
411
643
  }
412
644
  function renderWithinBudget(sections, fixedText, budget) {
@@ -434,13 +666,12 @@ function renderWithinBudget(sections, fixedText, budget) {
434
666
  function matchEmphasize(targets, candidates, cap) {
435
667
  if (targets.length === 0 || candidates.length === 0)
436
668
  return [];
437
- const idRegex = /^(c|cr|sg)-[a-f0-9]{8}$/;
438
669
  const scored = candidates.map((c) => {
439
670
  let best = 0;
440
671
  for (const t of targets) {
441
672
  let s = 0;
442
673
  // Phase 1: exact ID match (v2.11)
443
- const idMatch = t.match(idRegex);
674
+ const idMatch = t.match(STABLE_ID_RE);
444
675
  if (idMatch) {
445
676
  const candidateId = idMatch[1] + "-" + deriveItemId(c);
446
677
  if (candidateId === t)
@@ -483,7 +714,11 @@ function renderConfusionAlerts(points, state, expandAll) {
483
714
  { keyword: "objective", label: "Loop Objective", ref: "## Loop Objective" },
484
715
  { keyword: "loop", label: "Loop Summary", ref: "## Loop Summary" },
485
716
  ];
486
- 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);
487
722
  for (const point of shown) {
488
723
  const truncated = point.length > 200 ? point.slice(0, 197) + "…" : point;
489
724
  // Find best-matching state section
@@ -496,7 +731,7 @@ function renderConfusionAlerts(points, state, expandAll) {
496
731
  bestSection = sec;
497
732
  }
498
733
  }
499
- const pointer = bestSection && bestScore >= 0.15
734
+ const pointer = bestSection && bestScore >= getPolicy().prompt.confusion_section_threshold
500
735
  ? `→ Check **${bestSection.label}** (\`${bestSection.ref}\`) in the state file.`
501
736
  : "→ Read the full state file for relevant context.";
502
737
  lines.push(`- **"${truncated}"**`);
@@ -651,8 +886,13 @@ export function assemblePromptArtifact(input) {
651
886
  || input.mode === "pointer"
652
887
  || input.reasons.some((r) => fullContextReasons.has(r));
653
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.
654
892
  const stateFileRef = stateFilePath
655
- ? `📄 Full state: \`${stateFilePath}\`\n`
893
+ ? (input.level === "l1"
894
+ ? `📄 Full state: \`${stateFilePath}\` (updated round ${input.state.round})\n`
895
+ : `📄 Full state: \`${stateFilePath}\`\n`)
656
896
  : "";
657
897
  const readInstruction = needsFullContext && stateFilePath
658
898
  ? [
@@ -672,7 +912,6 @@ export function assemblePromptArtifact(input) {
672
912
  readInstruction,
673
913
  "",
674
914
  ].join("\n");
675
- const sections = selectSections(input);
676
915
  const pointer = stateFilePath
677
916
  ? section("Full State", stateFilePath)
678
917
  : "";
@@ -727,7 +966,45 @@ export function assemblePromptArtifact(input) {
727
966
  ...input.state.changesSinceLastRound,
728
967
  ], emphasizeCap)
729
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);
730
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
+ }
731
1008
  // Expand: L1 only, max 1 section (L2 already expanded, L0 ignored)
732
1009
  const expandCap = levelIsL1 ? policy.max_expand_l1 : 0;
733
1010
  const expandedSection = (pr?.expand?.length && expandCap > 0)
@@ -775,6 +1052,7 @@ export function assemblePromptArtifact(input) {
775
1052
  charCount: renderedPrompt.length,
776
1053
  budgetExceeded: renderedPrompt.length > budget,
777
1054
  generatedAt: Date.now(),
1055
+ ...(presentedState ? { presentedState } : {}),
778
1056
  };
779
1057
  }
780
1058
  //# sourceMappingURL=prompt-assembler.js.map