loopforge 3.7.1 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +171 -169
  2. package/dist/audit.d.ts +53 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +106 -22
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +52 -67
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +134 -147
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +103 -4
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts +13 -0
  14. package/dist/cognitive-facts.d.ts.map +1 -1
  15. package/dist/cognitive-facts.js +28 -13
  16. package/dist/cognitive-facts.js.map +1 -1
  17. package/dist/committed-round.d.ts +103 -21
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +188 -45
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/contract-items.d.ts +55 -0
  22. package/dist/contract-items.d.ts.map +1 -0
  23. package/dist/contract-items.js +180 -0
  24. package/dist/contract-items.js.map +1 -0
  25. package/dist/enforcement-gate.d.ts +20 -2
  26. package/dist/enforcement-gate.d.ts.map +1 -1
  27. package/dist/enforcement-gate.js +197 -386
  28. package/dist/enforcement-gate.js.map +1 -1
  29. package/dist/engine.d.ts +1 -1
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +53 -38
  32. package/dist/engine.js.map +1 -1
  33. package/dist/evidence-claims.d.ts +4 -4
  34. package/dist/evidence-claims.d.ts.map +1 -1
  35. package/dist/evidence-claims.js +16 -14
  36. package/dist/evidence-claims.js.map +1 -1
  37. package/dist/evidence-provider.d.ts +85 -89
  38. package/dist/evidence-provider.d.ts.map +1 -1
  39. package/dist/evidence-provider.js +328 -184
  40. package/dist/evidence-provider.js.map +1 -1
  41. package/dist/explain.d.ts +76 -0
  42. package/dist/explain.d.ts.map +1 -0
  43. package/dist/explain.js +127 -0
  44. package/dist/explain.js.map +1 -0
  45. package/dist/generate-schema.js +152 -16
  46. package/dist/generate-schema.js.map +1 -1
  47. package/dist/index.d.ts +6 -6
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +5 -5
  50. package/dist/index.js.map +1 -1
  51. package/dist/loop-compiler.d.ts +40 -53
  52. package/dist/loop-compiler.d.ts.map +1 -1
  53. package/dist/loop-compiler.js +229 -553
  54. package/dist/loop-compiler.js.map +1 -1
  55. package/dist/loop-extras-parser.d.ts +0 -1
  56. package/dist/loop-extras-parser.d.ts.map +1 -1
  57. package/dist/loop-extras-parser.js +0 -4
  58. package/dist/loop-extras-parser.js.map +1 -1
  59. package/dist/loop-projection.d.ts.map +1 -1
  60. package/dist/loop-projection.js +3 -1
  61. package/dist/loop-projection.js.map +1 -1
  62. package/dist/mcp/round-lifecycle.d.ts +30 -21
  63. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  64. package/dist/mcp/round-lifecycle.js +47 -103
  65. package/dist/mcp/round-lifecycle.js.map +1 -1
  66. package/dist/mcp/server.d.ts.map +1 -1
  67. package/dist/mcp/server.js +51 -7
  68. package/dist/mcp/server.js.map +1 -1
  69. package/dist/mcp/session.d.ts +39 -23
  70. package/dist/mcp/session.d.ts.map +1 -1
  71. package/dist/mcp/session.js +148 -163
  72. package/dist/mcp/session.js.map +1 -1
  73. package/dist/mcp/submission-boundary.d.ts +89 -0
  74. package/dist/mcp/submission-boundary.d.ts.map +1 -0
  75. package/dist/mcp/submission-boundary.js +203 -0
  76. package/dist/mcp/submission-boundary.js.map +1 -0
  77. package/dist/mcp/tools.d.ts.map +1 -1
  78. package/dist/mcp/tools.js +249 -128
  79. package/dist/mcp/tools.js.map +1 -1
  80. package/dist/policy-metrics.js +2 -2
  81. package/dist/policy-metrics.js.map +1 -1
  82. package/dist/policy.d.ts +55 -93
  83. package/dist/policy.d.ts.map +1 -1
  84. package/dist/policy.js +149 -38
  85. package/dist/policy.js.map +1 -1
  86. package/dist/prompt-assembler.d.ts +1 -32
  87. package/dist/prompt-assembler.d.ts.map +1 -1
  88. package/dist/prompt-assembler.js +282 -435
  89. package/dist/prompt-assembler.js.map +1 -1
  90. package/dist/prompt-policy.d.ts +1 -4
  91. package/dist/prompt-policy.d.ts.map +1 -1
  92. package/dist/prompt-policy.js +6 -7
  93. package/dist/prompt-policy.js.map +1 -1
  94. package/dist/protocol.d.ts +365 -142
  95. package/dist/protocol.d.ts.map +1 -1
  96. package/dist/protocol.js +21 -66
  97. package/dist/protocol.js.map +1 -1
  98. package/dist/replay.js +2 -2
  99. package/dist/replay.js.map +1 -1
  100. package/dist/round-contract.d.ts +69 -57
  101. package/dist/round-contract.d.ts.map +1 -1
  102. package/dist/round-contract.js +104 -64
  103. package/dist/round-contract.js.map +1 -1
  104. package/dist/round-coordinator.d.ts +36 -16
  105. package/dist/round-coordinator.d.ts.map +1 -1
  106. package/dist/round-coordinator.js +166 -102
  107. package/dist/round-coordinator.js.map +1 -1
  108. package/dist/round-driver.d.ts +3 -5
  109. package/dist/round-driver.d.ts.map +1 -1
  110. package/dist/round-driver.js +0 -1
  111. package/dist/round-driver.js.map +1 -1
  112. package/dist/round-facts.d.ts +96 -0
  113. package/dist/round-facts.d.ts.map +1 -0
  114. package/dist/round-facts.js +216 -0
  115. package/dist/round-facts.js.map +1 -0
  116. package/dist/round-transaction.d.ts +19 -9
  117. package/dist/round-transaction.d.ts.map +1 -1
  118. package/dist/round-transaction.js +90 -22
  119. package/dist/round-transaction.js.map +1 -1
  120. package/dist/self-eval.d.ts +87 -9
  121. package/dist/self-eval.d.ts.map +1 -1
  122. package/dist/self-eval.js +396 -27
  123. package/dist/self-eval.js.map +1 -1
  124. package/dist/subgoal-state.d.ts +77 -0
  125. package/dist/subgoal-state.d.ts.map +1 -0
  126. package/dist/subgoal-state.js +198 -0
  127. package/dist/subgoal-state.js.map +1 -0
  128. package/dist/token-utils.d.ts +52 -19
  129. package/dist/token-utils.d.ts.map +1 -1
  130. package/dist/token-utils.js +83 -72
  131. package/dist/token-utils.js.map +1 -1
  132. package/dist/verification-gate.d.ts +14 -31
  133. package/dist/verification-gate.d.ts.map +1 -1
  134. package/dist/verification-gate.js +178 -496
  135. package/dist/verification-gate.js.map +1 -1
  136. package/dist/version.d.ts +1 -1
  137. package/dist/version.js +1 -1
  138. package/dist/workspace.d.ts +9 -0
  139. package/dist/workspace.d.ts.map +1 -1
  140. package/dist/workspace.js +17 -0
  141. package/dist/workspace.js.map +1 -1
  142. package/loop_policy.json +6 -30
  143. package/package.json +1 -1
  144. package/skills/perception/SKILL.md +243 -79
@@ -10,10 +10,10 @@
10
10
  * alone are over it — truncation of mandatory content never happens.
11
11
  */
12
12
  import { createHash } from "node:crypto";
13
- import { activeSubGoalView, buildRoadmap, hashCanonicalState, milestoneHeading, trustBarLine, } from "./canonical-state.js";
14
- import { deriveItemId, STABLE_ID_RE, jaccardSimilarity } from "./token-utils.js";
13
+ import { activeSubGoalView, buildPhaseLine, hashCanonicalState, milestoneHeading, } from "./canonical-state.js";
14
+ import { PROMPT_ARTIFACT_SCHEMA_VERSION } from "./protocol.js";
15
+ import { deriveItemId, STABLE_ID_RE, normalizeText } from "./token-utils.js";
15
16
  import { getPolicy } from "./policy.js";
16
- export const PROMPT_ARTIFACT_SCHEMA_VERSION = 1;
17
17
  export const DEFAULT_PROMPT_BUDGETS = {
18
18
  l0: 3000,
19
19
  l1: 7000,
@@ -29,11 +29,9 @@ function bullets(values, prefix = "- ") {
29
29
  * Same hash strategy as computeGoalTextHash in loop-compiler.ts. */
30
30
  /** v2.11: Render a bullet list with ID prefixes for constraints/criteria.
31
31
  * Each item gets a [prefix-XXXXXXXX] tag. Items that already look like IDs
32
- * are rendered as-is. When constraint_id_enabled is false, renders plain bullets. */
32
+ * are rendered as-is. v3.8.1: IDs are always rendered — the
33
+ * `constraint_id_enabled` escape hatch is gone. */
33
34
  function bulletsWithIds(values, prefix) {
34
- const idEnabled = getPolicy().evolution.constraint_id_enabled;
35
- if (!idEnabled)
36
- return bullets(values);
37
35
  return values.map((v) => {
38
36
  const trimmed = v.trim();
39
37
  if (STABLE_ID_RE.test(trimmed)) {
@@ -52,9 +50,6 @@ function bulletsWithIds(values, prefix) {
52
50
  * one prompt. Falls back to a c- derivation only when metadata is missing
53
51
  * (defensive: metadata covers every active text by construction). */
54
52
  function bulletsWithIdentities(values, metadata) {
55
- const idEnabled = getPolicy().evolution.constraint_id_enabled;
56
- if (!idEnabled)
57
- return bullets(values);
58
53
  const idByText = new Map(metadata.map((meta) => [meta.text, meta.id]));
59
54
  return values.map((v) => {
60
55
  const trimmed = v.trim();
@@ -75,7 +70,7 @@ function bulletsWithIdentities(values, metadata) {
75
70
  * list — a new constant can never silently lack an action entry). */
76
71
  const VERIFICATION_ACTIONS = {
77
72
  success_with_remaining_criteria: "Complete the remaining criteria before claiming success, or set success=false and report what remains.",
78
- success_without_verified_evidence: "Back the success claim with machine-verifiable evidence: run tests or a required verification command and report the actual output. If you genuinely performed no work, provide execution_evidence and declare no_change_reason.",
73
+ success_without_verified_evidence: "Back the success claim with machine-verifiable evidence: run tests or a required verification command and report the actual output. If you genuinely performed no work, provide execution_report and declare no_change_reason.",
79
74
  outcome_success_contradiction: "Reconcile the declared outcome with the success flag and resubmit an honest evaluation.",
80
75
  blocked_without_blocker: "State the concrete blocker, or drop the blocked outcome.",
81
76
  retroactive_claim_bad_round: "Correct the referenced round number, or withdraw the retroactive claim.",
@@ -86,21 +81,16 @@ const VERIFICATION_ACTIONS = {
86
81
  evidence_integrity: "Correct the mismatch between your claims and the recorded evidence.",
87
82
  required_command_failed: "Re-run the required verification command and report its actual output before claiming success.",
88
83
  command_evidence_mismatch: "Report the true command output and reconcile the mismatch.",
89
- intent_drift: "Explain the pivot in drift_clarification with concrete references, or redo the round as promised.",
90
- subgoal_drift: "Reconcile the sub-goal list with your reported work before resubmitting.",
91
84
  backtrack_workspace_not_restored: "Restore the skipped-round files and the expected git HEAD, then resubmit.",
92
85
  criteria_claims_unverified: "Provide evidence for each claimed success criterion.",
93
86
  // v3.3 — Round Contract checks (and the three v3.3 checks that predated
94
87
  // this table entry and fell back to the generic action).
95
88
  verification_entrypoint_modified: "Re-run the verification command on the changed entrypoint, or revert the entrypoint change and re-run.",
96
89
  test_files_modified: "Re-run the verification command after the test-file changes and report the fresh output.",
97
- round_underspecified: "Declare this round's contract with a non-empty done_when list — what must be true when the round is done?",
98
- round_unverifiable: "Name only configured, enabled evidence.commands in verification_plan, or drop done_when items that cannot be machine-verified.",
99
- round_scope_drift: "Revert the out-of-scope file changes, or extend the contract scope to cover them and explain why in drift_clarification.",
100
- premature_boundary: "Run the contract's verification_plan commands and report real output before claiming done_when met; or set success=false and list the items in success_criteria_remaining (no_change_reason does not apply to contract claims).",
101
- // v3.5 — contract completion machine-backing + premature-replacement warn
102
- contract_completion_unverified: "Completion under the ACTIVE Round Contract requires its verification_plan commands to pass this round — fix the underlying failure so they pass and resubmit. Completion claims are not accepted without machine verification (no_change_reason does not apply).",
103
- contract_premature: "The ACTIVE contract is still open — restate it unchanged to continue it; a different contract is ignored until the active one is completed or blocked.",
90
+ round_scope_drift: "Revert the out-of-scope file changes, or close the active contract and declare the extended scope in a new proposal. Scope drift is a machine fact and is not waivable by explanation.",
91
+ // v3.8 — item model: claimed-but-unverified debt
92
+ contract_items_unverified: "Run the bound command(s) for each claimed item so they are observed passing, or report the item as remaining (or declare outcome=\"blocked\").",
93
+ contract_premature: "The ACTIVE contract is still open — restate it unchanged to continue it; a different contract is ignored until every item is verified or the round reports blocked.",
104
94
  // v3.7.1 — opt-in gate layer: cited gates need an approved human decision
105
95
  user_gate_unresolved: "The round cites a high-risk action without an approved human decision. Run loopforge_gate_check, present the approval question, call loopforge_gate_resolve after the human decides, and resubmit with the approved gate id in evaluation.gate_ids.",
106
96
  };
@@ -137,78 +127,12 @@ function activeNonHardConstraints(state) {
137
127
  // ═══════════════════════════════════════════════════════════════════════════
138
128
  // v3.2: L1 diff-collapse helpers
139
129
  // ═══════════════════════════════════════════════════════════════════════════
140
- /** v3.2: Collapse only when at least this many items are unchanged — a tiny
141
- * unchanged set renders in full (nothing worth saving). */
142
- export const COLLAPSE_MIN_UNCHANGED = 3;
143
- /** v3.2: Recent-rounds items kept in full when older ones collapse. */
144
- export const KEEP_RECENT_ROUNDS = 3;
145
130
  /** v3.2: Stable ID of a constraint text as rendered by bulletsWithIds —
146
131
  * metadata's own id when present, else the derived hash. */
147
132
  function constraintIdOf(text, metadata) {
148
133
  const meta = metadata.find((m) => m.text === text);
149
134
  return meta?.id ?? `c-${deriveItemId(text)}`;
150
135
  }
151
- export function diffConstraints(baseline, activeTexts, metadata, round, emphasized) {
152
- if (!baseline)
153
- return { changed: activeTexts, unchangedCount: 0, removedCount: 0 };
154
- const baselineIds = new Set(baseline.constraintIds);
155
- const currentIds = new Set(activeTexts.map((text) => constraintIdOf(text, metadata)));
156
- const changed = [];
157
- let unchangedCount = 0;
158
- for (const text of activeTexts) {
159
- const id = constraintIdOf(text, metadata);
160
- const violatedThisRound = metadata.find((m) => m.text === text)
161
- ?.last_violated_at_round === round;
162
- if (!baselineIds.has(id) || violatedThisRound)
163
- changed.push(text);
164
- else
165
- unchangedCount++;
166
- }
167
- let removedCount = 0;
168
- if (emphasized.size > 0) {
169
- const emphasizedIds = new Set([...emphasized].map((text) => constraintIdOf(text, metadata)));
170
- for (const id of baselineIds) {
171
- if (!currentIds.has(id) && !emphasizedIds.has(id))
172
- removedCount++;
173
- }
174
- }
175
- else {
176
- for (const id of baselineIds) {
177
- if (!currentIds.has(id))
178
- removedCount++;
179
- }
180
- }
181
- return { changed, unchangedCount, removedCount };
182
- }
183
- export function diffSubGoals(baseline, activeSubs) {
184
- if (!baseline)
185
- return { changed: activeSubs, unchangedCount: 0, removedCount: 0 };
186
- const baselineById = new Map(baseline.subGoals);
187
- const currentIds = new Set(activeSubs.map((sg) => sg.id));
188
- const changed = [];
189
- let unchangedCount = 0;
190
- for (const sg of activeSubs) {
191
- const prevStatus = baselineById.get(sg.id);
192
- if (prevStatus === undefined || prevStatus !== sg.status)
193
- changed.push(sg);
194
- else
195
- unchangedCount++;
196
- }
197
- let removedCount = 0;
198
- for (const [id] of baseline.subGoals) {
199
- if (!currentIds.has(id))
200
- removedCount++;
201
- }
202
- return { changed, unchangedCount, removedCount };
203
- }
204
- /** v3.2: True when a milestone boundary was crossed since the baseline —
205
- * the Recent Rounds section then renders in full (a phase ended). */
206
- export function milestoneBoundaryChanged(baseline, milestones) {
207
- if (!baseline)
208
- return false;
209
- const baselineRanges = new Set(baseline.milestoneRanges.map(([s, e]) => `${s}:${e}`));
210
- return milestones.some((m) => !baselineRanges.has(`${m.round_range.start}:${m.round_range.end}`));
211
- }
212
136
  function commonMandatorySections(state,
213
137
  /** v2.11: L0 skips ID rendering to stay within tight budget. */
214
138
  level) {
@@ -266,14 +190,38 @@ function l0Sections(state) {
266
190
  }
267
191
  return sections;
268
192
  }
269
- function l1Sections(state, baseline, emphasized) {
193
+ /** v3.8.1: the committed-round window Active Warnings reports on. */
194
+ const ACTIVE_WARNINGS_WINDOW = 3;
195
+ /** v3.8.1: hard cap on Active Warnings lines. */
196
+ const ACTIVE_WARNINGS_CAP = 5;
197
+ /** v3.8.1: the prompt's slice of the recurring-fact list — the facts still
198
+ * firing inside the last ACTIVE_WARNINGS_WINDOW committed rounds.
199
+ *
200
+ * One list, two windows: this shows what is going wrong NOW (any count), the
201
+ * state file shows what keeps recurring (>= 2 rounds). Before this release
202
+ * the prompt carried a raw, unthresholded violation list AND a whole-history
203
+ * count >= 2 list, under two different headings.
204
+ *
205
+ * Returns "" when nothing is firing, so the caller renders no section. */
206
+ function activeWarnings(flags, currentRound) {
207
+ const cutoff = currentRound - ACTIVE_WARNINGS_WINDOW;
208
+ const shown = flags
209
+ .filter((flag) => flag.rounds.some((round) => round >= cutoff))
210
+ .sort((a, b) => Math.max(...b.rounds) - Math.max(...a.rounds) || b.count - a.count)
211
+ .slice(0, ACTIVE_WARNINGS_CAP);
212
+ if (shown.length === 0)
213
+ return "";
214
+ return shown.map((flag) => {
215
+ const icon = flag.kind === "constraint_violation"
216
+ ? "🚫"
217
+ : flag.kind === "verification_error" ? "⚠️" : "ℹ️";
218
+ const subject = flag.ref ? `${flag.subject} [${flag.ref}]` : flag.subject;
219
+ return `- ${icon} ${subject} — ${flag.count}×, latest R${Math.max(...flag.rounds)}`;
220
+ }).join("\n");
221
+ }
222
+ function l1Sections(state) {
270
223
  const sections = commonMandatorySections(state, "l1");
271
224
  const active = activeNonHardConstraints(state);
272
- // L7: collapse needs the state file — its collapse line points at the file
273
- // for the full content. With the file disabled (stateFilePath empty),
274
- // collapsing would silently truncate the prompt's constraints view.
275
- const collapseEnabled = getPolicy().prompt.l1_collapse_enabled &&
276
- state.stateFilePath.length > 0;
277
225
  if (state.changesSinceLastRound.length > 0) {
278
226
  sections.push({
279
227
  id: "changes",
@@ -281,36 +229,27 @@ function l1Sections(state, baseline, emphasized) {
281
229
  mandatory: false,
282
230
  });
283
231
  }
284
- // v3.2: Active Constraints — collapse unchanged items against the previous
285
- // round's presentation; new and violated-this-round constraints render in
286
- // full. The collapse line points at the state file (regenerated every
287
- // round), so no information is lost — only presentation is compacted.
232
+ // v3.8.1: Active Constraints render IN FULL. v3.2 folded unchanged items
233
+ // into "… N unchanged constraints (see state file)" pointer lines, which
234
+ // made the prompt's content depend on what the PREVIOUS prompt happened to
235
+ // show rather than on committed facts — and those pointer lines fed the
236
+ // prompt hash. The fold needed the previous round's persisted presentation
237
+ // snapshot, which is deleted.
288
238
  if (active.length > 0) {
289
- const diff = diffConstraints(baseline, active, state.constraintMetadata, state.round, emphasized);
290
- const renderFull = !collapseEnabled || !baseline
291
- || diff.unchangedCount < COLLAPSE_MIN_UNCHANGED;
292
- if (renderFull) {
293
- sections.push({
294
- id: "active_constraints",
295
- text: section("Active Constraints / Success Criteria", bulletsWithIdentities(active, state.constraintMetadata)),
296
- mandatory: false,
297
- });
298
- }
299
- else {
300
- const lines = diff.changed.map((text) => {
301
- const id = constraintIdOf(text, state.constraintMetadata);
302
- const violated = state.constraintMetadata.find((m) => m.text === text)
303
- ?.last_violated_at_round === state.round;
304
- const suffix = violated ? " (violated this round)" : " (new this round)";
305
- return `- [\`${id}\`] ${text}${suffix}`;
306
- });
307
- lines.push(`- … ${diff.unchangedCount} unchanged constraints, ${diff.removedCount} demoted since R${baseline.round} (see state file)`);
308
- sections.push({
309
- id: "active_constraints",
310
- text: section("Active Constraints / Success Criteria", lines.join("\n")),
311
- mandatory: false,
312
- });
313
- }
239
+ // The one thing the folded branch carried that a plain bullet list does
240
+ // not: the machine fact that this round broke the constraint. Kept so the
241
+ // annotation is not lost along with the fold.
242
+ const lines = active.map((text) => {
243
+ const violated = state.constraintMetadata.find((m) => m.text === text)
244
+ ?.last_violated_at_round === state.round;
245
+ return `- [\`${constraintIdOf(text, state.constraintMetadata)}\`] ${text}` +
246
+ (violated ? " (violated this round)" : "");
247
+ });
248
+ sections.push({
249
+ id: "active_constraints",
250
+ text: section("Active Constraints / Success Criteria", lines.join("\n")),
251
+ mandatory: false,
252
+ });
314
253
  }
315
254
  if (state.remainingCriteria.length > 0) {
316
255
  sections.push({
@@ -326,42 +265,23 @@ function l1Sections(state, baseline, emphasized) {
326
265
  mandatory: false,
327
266
  });
328
267
  }
329
- // v2.2: Sub-Goal Dashboard (compact — L1). v3.2: unchanged sub-goals
330
- // collapse to a count line; new/transitioned ones render in full.
331
- // v3.7.1: only ACTIVE items (pending/in_progress/blocked) enter the view;
332
- // done/canceled never render as rows.
268
+ // v2.2: Sub-Goal Dashboard (compact — L1). v3.7.1: only ACTIVE items
269
+ // (pending/in_progress/blocked) enter the view; done/canceled never render
270
+ // as rows. v3.8.1: rendered in full — the unchanged-count fold is gone.
333
271
  const view = activeSubGoalView(state.subGoals, Number.POSITIVE_INFINITY);
334
272
  const activeSubs = view.active;
335
273
  if (activeSubs.length > 0) {
336
274
  const iconOf = (sg) => sg.status === "in_progress" ? "🔄" : sg.status === "blocked" ? "🚫" : "⏳";
337
- const diff = diffSubGoals(baseline, activeSubs);
338
- const renderFull = !collapseEnabled || !baseline
339
- || diff.unchangedCount < COLLAPSE_MIN_UNCHANGED;
340
- if (renderFull) {
341
- const maxShow = 5;
342
- const items = activeSubs.slice(0, maxShow).map((sg) => `${iconOf(sg)} [\`${sg.id}\`] ${sg.description}`);
343
- if (activeSubs.length > maxShow) {
344
- items.push(`... and ${activeSubs.length - maxShow} more`);
345
- }
346
- sections.push({
347
- id: "sub_goals",
348
- text: section("Active Sub-Goals", items.join("\n")),
349
- mandatory: false,
350
- });
351
- }
352
- else {
353
- const transitionCount = diff.changed.filter((sg) => {
354
- const prev = baseline.subGoals.find(([id]) => id === sg.id);
355
- return prev !== undefined && prev[1] !== sg.status;
356
- }).length;
357
- const items = diff.changed.slice(0, 5).map((sg) => `${iconOf(sg)} [\`${sg.id}\`] ${sg.description}`);
358
- items.push(`- … ${diff.unchangedCount} unchanged sub-goals, ${transitionCount + diff.removedCount} changed since R${baseline.round} (see state file)`);
359
- sections.push({
360
- id: "sub_goals",
361
- text: section("Active Sub-Goals", items.join("\n")),
362
- mandatory: false,
363
- });
275
+ const maxShow = 5;
276
+ const items = activeSubs.slice(0, maxShow).map((sg) => `${iconOf(sg)} [\`${sg.id}\`] ${sg.description}`);
277
+ if (activeSubs.length > maxShow) {
278
+ items.push(`... and ${activeSubs.length - maxShow} more`);
364
279
  }
280
+ sections.push({
281
+ id: "sub_goals",
282
+ text: section("Active Sub-Goals", items.join("\n")),
283
+ mandatory: false,
284
+ });
365
285
  }
366
286
  if (state.discoveries.length > 0) {
367
287
  sections.push({
@@ -370,32 +290,14 @@ function l1Sections(state, baseline, emphasized) {
370
290
  mandatory: false,
371
291
  });
372
292
  }
373
- // v3.2: Recent Rounds — keep the newest KEEP_RECENT_ROUNDS in full, fold
374
- // older ones into one pointer line (unless a milestone boundary crossed).
293
+ // v3.2: Recent Rounds. v3.8.1: rendered in full — the keep-newest-N fold and
294
+ // its milestone-boundary exception are gone.
375
295
  if (state.rollingOutcomes.length > 0) {
376
- const boundaryChanged = milestoneBoundaryChanged(baseline, state.milestones);
377
- const collapseable = collapseEnabled && baseline !== null
378
- && state.rollingOutcomes.length > KEEP_RECENT_ROUNDS && !boundaryChanged;
379
- if (collapseable) {
380
- const kept = state.rollingOutcomes.slice(-KEEP_RECENT_ROUNDS);
381
- const earlier = state.rollingOutcomes.length - kept.length;
382
- const lines = [
383
- ...kept,
384
- `- … ${earlier} earlier round${earlier === 1 ? "" : "s"} (see state file)`,
385
- ];
386
- sections.push({
387
- id: "rolling_outcomes",
388
- text: section("Recent Rounds", lines.join("\n")),
389
- mandatory: false,
390
- });
391
- }
392
- else {
393
- sections.push({
394
- id: "rolling_outcomes",
395
- text: section("Recent Rounds", bullets(state.rollingOutcomes)),
396
- mandatory: false,
397
- });
398
- }
296
+ sections.push({
297
+ id: "rolling_outcomes",
298
+ text: section("Recent Rounds", bullets(state.rollingOutcomes)),
299
+ mandatory: false,
300
+ });
399
301
  }
400
302
  // v3.2: L1 also renders the explicit external context (previously L2-only).
401
303
  // Placed after the changes delta, within the soft budget.
@@ -406,36 +308,26 @@ function l1Sections(state, baseline, emphasized) {
406
308
  mandatory: false,
407
309
  });
408
310
  }
409
- // v3.3: Forward-looking roadmap (same buildRoadmap as state.md and L2).
410
- const roadmapLines = buildRoadmap(state);
411
- if (roadmapLines.length > 0) {
311
+ // v3.8.1: one compact phase line, not the Roadmap prose.
312
+ const l1Phase = buildPhaseLine(state);
313
+ if (l1Phase) {
412
314
  sections.push({
413
- id: "roadmap",
414
- text: section("Roadmap", roadmapLines.join("\n")),
315
+ id: "phase",
316
+ text: section("Phase", l1Phase),
415
317
  mandatory: false,
416
318
  });
417
319
  }
418
- // v3.2: Recurring Issues carries the deterministic violation lessons (with
419
- // counts and rounds) when available; falls back to the rolling window text.
420
- if (state.recurringIssues.length > 0 || state.lessons.length > 0) {
421
- const violationLessons = state.lessons
422
- .filter((lesson) => lesson.kind === "constraint_violation")
423
- .slice(0, 3);
424
- const body = violationLessons.length > 0
425
- ? violationLessons
426
- .map((lesson) => `- ${lesson.text} (violated ${lesson.count}×: R${lesson.rounds.join(", R")})`)
427
- .join("\n")
428
- : bullets(state.recurringIssues);
320
+ // v3.8.1: Active Warnings — the recent tail of the ONE recurring-fact
321
+ // derivation. It replaces two prompt sections ("Recurring Issues", fed by
322
+ // the last 5 rounds' raw violations with no threshold, and "Lessons
323
+ // Learned", fed by the whole-history count >= 2 view) that were two windows
324
+ // over one fact set. What the agent needs in the prompt is the recent tail;
325
+ // the full recurring set lives in the state file.
326
+ const warnings = activeWarnings(state.recurringFlags, state.round);
327
+ if (warnings) {
429
328
  sections.push({
430
- id: "recurring_issues",
431
- text: section("Recurring Issues", body),
432
- mandatory: false,
433
- });
434
- }
435
- if (state.nextAction) {
436
- sections.push({
437
- id: "next_action",
438
- text: section("Next Action", state.nextAction),
329
+ id: "active_warnings",
330
+ text: section("Active Warnings", warnings),
439
331
  mandatory: false,
440
332
  });
441
333
  }
@@ -461,7 +353,6 @@ function l2Sections(state, fullStateMarkdown) {
461
353
  ["blockers", "Blockers", state.blockers, null],
462
354
  ["discoveries", "Discoveries", state.discoveries, null],
463
355
  ["rolling_outcomes", "Cross-Round Outcomes", state.rollingOutcomes, null],
464
- ["recurring_issues", "Recurring Issues", state.recurringIssues, null],
465
356
  ["failed_patterns", "Failed Patterns", state.failedPatterns, null],
466
357
  ];
467
358
  for (const [id, title, values, idPrefix] of groups) {
@@ -479,32 +370,26 @@ function l2Sections(state, fullStateMarkdown) {
479
370
  : bullets(values);
480
371
  sections.push({ id, text: section(title, body), mandatory: false });
481
372
  }
482
- // v3.2: Lessons learned — repeated violations / verification failures
483
- // across the whole loop (not just the rolling window).
484
- if (state.lessons.length > 0) {
485
- const lessonLines = state.lessons.slice(0, 8).map((lesson) => {
486
- const icon = lesson.kind === "constraint_violation"
487
- ? "🚫"
488
- : lesson.kind === "verification_error" ? "⚠️" : "ℹ️";
489
- return `- ${icon} ${lesson.text} — ${lesson.count}× (R${lesson.rounds.join(", R")})`;
490
- });
491
- if (state.lessons.length > 8) {
492
- lessonLines.push(`- ... and ${state.lessons.length - 8} more`);
493
- }
373
+ // v3.8.1: Active Warnings — the recent tail of the ONE recurring-fact
374
+ // derivation (see l1Sections). The whole-history recurring set is the state
375
+ // file's job; L2 shows the same recent slice as L1 so the two levels cannot
376
+ // tell different stories about what is going wrong right now.
377
+ const warnings = activeWarnings(state.recurringFlags, state.round);
378
+ if (warnings) {
494
379
  sections.push({
495
- id: "lessons",
496
- text: section("Lessons Learned", lessonLines.join("\n")),
380
+ id: "active_warnings",
381
+ text: section("Active Warnings", warnings),
497
382
  mandatory: false,
498
383
  });
499
384
  }
500
- // v3.3: Forward-looking roadmap — rendered right before the (backward-
501
- // looking) Phase History so the agent sees where it is before where it's
502
- // been. Same buildRoadmap as state.md and L1.
503
- const roadmapLines = buildRoadmap(state);
504
- if (roadmapLines.length > 0) {
385
+ // v3.8.1: one compact phase line, not the Roadmap prose — rendered right
386
+ // before the (backward-looking) Phase History so the agent sees where it is
387
+ // before where it has been.
388
+ const l2Phase = buildPhaseLine(state);
389
+ if (l2Phase) {
505
390
  sections.push({
506
- id: "roadmap",
507
- text: section("Roadmap", roadmapLines.join("\n")),
391
+ id: "phase",
392
+ text: section("Phase", l2Phase),
508
393
  mandatory: false,
509
394
  });
510
395
  }
@@ -555,13 +440,6 @@ function l2Sections(state, fullStateMarkdown) {
555
440
  mandatory: false,
556
441
  });
557
442
  }
558
- if (state.nextAction) {
559
- sections.push({
560
- id: "next_action",
561
- text: section("Next Action", state.nextAction),
562
- mandatory: false,
563
- });
564
- }
565
443
  if (state.externalContext) {
566
444
  sections.push({
567
445
  id: "external_context",
@@ -570,97 +448,13 @@ function l2Sections(state, fullStateMarkdown) {
570
448
  });
571
449
  }
572
450
  // ── v2.8: Path B gap fills (previously only in full state markdown) ──
573
- // Progress Dashboard
574
- if (state.progress.estimate !== null ||
575
- state.progress.criteriaMet.length > 0 ||
576
- state.progress.criteriaRemaining.length > 0 ||
577
- state.progress.filesChanged.length > 0 ||
578
- state.progress.tests !== null ||
579
- state.machineStatus !== undefined) {
580
- const lines = [];
581
- const total = state.progress.criteriaMet.length + state.progress.criteriaRemaining.length;
582
- if (total > 0) {
583
- lines.push(`**Criteria**: ${state.progress.criteriaMet.length}/${total} met`);
584
- }
585
- if (state.progress.estimate !== null) {
586
- lines.push(`**Estimated Completion**: ${(state.progress.estimate * 100).toFixed(0)}%`);
587
- // v3.3: honest labeling — the estimate is the agent's own number.
588
- lines.push("**Signal source**: self-reported estimate (unverified until machine-backed)");
589
- }
590
- // v3.3: machine side of the comparison — git motion over committed
591
- // rounds (v3.6: the object always carries definite values — no
592
- // "unavailable" arm).
593
- if (state.machineStatus) {
594
- const ms = state.machineStatus;
595
- const motion = ms.gitMotion
596
- ? `changes in ${ms.motionRounds}/${ms.windowRounds} recent committed rounds`
597
- : `no git changes in the last ${ms.windowRounds} committed rounds`;
598
- lines.push(`**Machine (git)**: ${motion}`);
599
- }
600
- if (state.criterionStatuses.length > 0) {
601
- const metCount = state.criterionStatuses.filter((cs) => cs.status === "met").length;
602
- lines.push(`**Machine (criteria)**: ${metCount}/${state.criterionStatuses.length} met across committed rounds`);
603
- }
604
- if (state.progress.tests) {
605
- lines.push(`**Tests**: ${state.progress.tests.passed} passed, ` +
606
- `${state.progress.tests.failed} failed, ${state.progress.tests.skipped} skipped`);
607
- }
608
- if (state.progress.filesChanged.length > 0) {
609
- lines.push("**Files Changed**:");
610
- for (const f of state.progress.filesChanged.slice(0, 10))
611
- lines.push(`- ${f}`);
612
- if (state.progress.filesChanged.length > 10) {
613
- lines.push(`- ... and ${state.progress.filesChanged.length - 10} more`);
614
- }
615
- }
616
- // v3.2: Goal → criteria → evidence vertical view — per-criterion status
617
- // with the round it was met and linked sub-goals. IDs follow
618
- // constraint_id_enabled like every other ID-rendering path.
619
- if (state.criterionStatuses.length > 0) {
620
- const idEnabled = getPolicy().evolution.constraint_id_enabled;
621
- lines.push("", "**Goal → Criteria**:");
622
- for (const cs of state.criterionStatuses) {
623
- const icon = cs.status === "met" ? "✅" : cs.status === "remaining" ? "⬜" : "❔";
624
- const idTag = idEnabled ? ` [\`${cs.id}\`]` : "";
625
- const met = cs.met_at_round !== undefined ? `(met R${cs.met_at_round})` : "";
626
- const related = cs.related_subgoal_ids.length > 0
627
- ? ` [↔ ${cs.related_subgoal_ids.join(", ")}]`
628
- : "";
629
- lines.push(`- ${icon}${idTag} ${cs.text} ${met}${related}`);
630
- }
631
- }
632
- sections.push({
633
- id: "progress",
634
- text: section("Progress Dashboard", lines.join("\n")),
635
- mandatory: false,
636
- });
637
- }
638
- // v3.3: Per-round statistics — lets the agent calibrate round granularity
639
- // (files per round, rejected attempts, self-reported progress deltas).
640
- // v3.6: source-labeled — files/Δ are agent-reported; only rejected
641
- // attempts (and the Machine (git) row above) are machine-recorded.
642
- if (state.roundStats && state.roundStats.length > 0) {
643
- const statLines = state.roundStats.map((stat) => {
644
- const parts = [];
645
- if (stat.filesChangedCount !== null) {
646
- parts.push(`${stat.filesChangedCount} file${stat.filesChangedCount === 1 ? "" : "s"}`);
647
- }
648
- if (stat.rejectedAttempts !== null && stat.rejectedAttempts > 0) {
649
- parts.push(`${stat.rejectedAttempts} rejected attempt${stat.rejectedAttempts === 1 ? "" : "s"}`);
650
- }
651
- if (stat.progressDelta !== null) {
652
- parts.push(`Δ${stat.progressDelta >= 0 ? "+" : ""}${stat.progressDelta.toFixed(2)}`);
653
- }
654
- return `- R${stat.round}: ${parts.length > 0 ? parts.join(", ") : "no data"}`;
655
- });
656
- statLines.push("> files & Δ are agent-reported; rejected attempts are machine-recorded " +
657
- "(the Machine (git) row above is machine-observed)");
658
- sections.push({
659
- id: "round_stats",
660
- text: section("Round Stats", statLines.join("\n")),
661
- mandatory: false,
662
- });
663
- }
451
+ // v3.8.1: the L2 Progress Dashboard and Round Stats sections are gone.
452
+ // The dashboard was `criterionStatuses` re-composed into a table and mixed
453
+ // with the agent’s OWN completion estimate and test counts; Round Stats
454
+ // re-listed per-round files and progress deltas, both self-reported. The
455
+ // criterion list and the machine git/criteria rows still render in the
456
+ // state file, where a human or external tool reads them — the prompt keeps
457
+ // only the current round’s own facts.
664
458
  // Retired Constraints
665
459
  if (state.retiredConstraints.length > 0) {
666
460
  const lines = state.retiredConstraints.map((c) => `- ~${c}~ (retired)`);
@@ -670,86 +464,166 @@ function l2Sections(state, fullStateMarkdown) {
670
464
  mandatory: false,
671
465
  });
672
466
  }
673
- // Agent Trust Score + Trend
674
- if (state.agentTrustScore !== undefined) {
675
- const lines = [
676
- trustBarLine(state.agentTrustScore),
677
- ];
678
- if (state.agentTrustTrend.length > 0) {
679
- const avg = (state.agentTrustTrend.reduce((a, b) => a + b, 0)
680
- / state.agentTrustTrend.length).toFixed(2);
681
- lines.push(`Trend (last ${state.agentTrustTrend.length}): ${state.agentTrustTrend.join(" → ")}`);
682
- lines.push(`Average: ${avg}`);
683
- }
684
- sections.push({
685
- id: "agent_trust",
686
- text: section("Agent Trust", lines.join("\n")),
687
- mandatory: false,
688
- });
689
- }
690
467
  return sections;
691
468
  }
692
- function selectSections(input, emphasized) {
469
+ function selectSections(input) {
693
470
  if (input.level === "l0")
694
471
  return l0Sections(input.state);
695
- if (input.level === "l1") {
696
- return l1Sections(input.state, input.presentedBaseline ?? null, emphasized);
697
- }
472
+ if (input.level === "l1")
473
+ return l1Sections(input.state);
698
474
  return l2Sections(input.state, input.fullStateMarkdown);
699
475
  }
476
+ /** v3.8.1: render order, in ONE place.
477
+ *
478
+ * - A section absent from this table falls to DEFAULT_SECTION_PRIORITY.
479
+ * - A Section's `mandatory` flag means PROTECTED: never dropped, never
480
+ * truncated, rendered first regardless of budget.
481
+ * - Everything else is optional and is cut lowest-priority-first.
482
+ *
483
+ * Priority belongs to the SECTION, not to policy: a budget knob must not be
484
+ * able to change which facts count as more important. (v3.2–v3.8 had no
485
+ * order at all — optional sections were appended in declaration order, so a
486
+ * single large early section silently pushed out every later one.)
487
+ */
488
+ const SECTION_PRIORITY = {
489
+ // ── protected ───────────────────────────────────────────────────────────
490
+ objective: 0,
491
+ current_task: 1,
492
+ hard_constraints: 2,
493
+ verification: 3,
494
+ retry_requirements: 4,
495
+ // Zero-token pure reorder, but it is the ONLY place emphasized items render
496
+ // (they are MOVED out of their source sections), so dropping it would delete
497
+ // them rather than just deprioritize them.
498
+ critical_context: 5,
499
+ // ── optional, most to least important ───────────────────────────────────
500
+ success_criteria: 10,
501
+ active_constraints: 11,
502
+ remaining: 12,
503
+ sub_goals: 13,
504
+ blockers: 14,
505
+ changes: 15,
506
+ discoveries: 16,
507
+ rolling_outcomes: 17,
508
+ phase: 18,
509
+ active_warnings: 19,
510
+ external_context: 20,
511
+ failed_patterns: 21,
512
+ retired_constraints: 22,
513
+ milestones: 23,
514
+ sub_goals_full: 24,
515
+ full_state: 25,
516
+ };
517
+ const DEFAULT_SECTION_PRIORITY = 50;
518
+ /** v3.8.1: the smallest remaining room worth spending on a truncated
519
+ * section. Below this a partial render is noise, so the section is dropped
520
+ * whole instead. */
521
+ const TRUNCATION_MIN_CHARS = 256;
522
+ /** Cut at a line boundary so a truncated section cannot end mid-sentence. */
523
+ function truncateAtLine(text, room) {
524
+ const clipped = text.slice(0, room);
525
+ const lastBreak = clipped.lastIndexOf("\n");
526
+ return `${lastBreak > 0 ? clipped.slice(0, lastBreak) : clipped}\n`;
527
+ }
700
528
  function renderWithinBudget(sections, fixedText, budget) {
701
- const mandatory = sections.filter((item) => item.mandatory);
702
- const optional = sections.filter((item) => !item.mandatory);
703
- let rendered = fixedText + mandatory.map((item) => item.text).join("");
704
- const included = mandatory.filter((item) => item.text).map((item) => item.id);
705
- for (const item of optional) {
706
- if (!item.text)
529
+ // Total order: priority, then declaration order. Deterministic by
530
+ // construction — no dependence on map iteration or insertion accidents.
531
+ const ranked = sections
532
+ .map((item, index) => ({ item, index }))
533
+ .sort((a, b) => (SECTION_PRIORITY[a.item.id] ?? DEFAULT_SECTION_PRIORITY) -
534
+ (SECTION_PRIORITY[b.item.id] ?? DEFAULT_SECTION_PRIORITY) ||
535
+ a.index - b.index);
536
+ let rendered = fixedText;
537
+ const included = [];
538
+ const dropped = [];
539
+ for (const { item } of ranked) {
540
+ if (!item.text || !item.mandatory)
707
541
  continue;
708
- if (rendered.length + item.text.length <= budget) {
709
- rendered += item.text;
710
- included.push(item.id);
542
+ rendered += item.text;
543
+ included.push(item.id);
544
+ }
545
+ // Protected content alone can exceed the ceiling. That is RECORDED, never
546
+ // "fixed" by truncating a protected section.
547
+ const protectedOverflow = rendered.length > budget;
548
+ // Optional sections, STRICT priority: find the longest optional PREFIX that
549
+ // fits, and cut only from the tail of that ordering.
550
+ //
551
+ // Greedy first-fit is a different rule, and not the documented one: it can
552
+ // render a small low-priority section into room a larger high-priority
553
+ // section could not use, so "Blockers" could be missing while "Phase" is
554
+ // present. "Cut lowest-priority-first" has to mean what it says.
555
+ const optional = ranked.filter(({ item }) => item.text && !item.mandatory);
556
+ let prefix = optional.length;
557
+ while (prefix > 0) {
558
+ const total = optional
559
+ .slice(0, prefix)
560
+ .reduce((sum, { item }) => sum + item.text.length, 0);
561
+ if (rendered.length + total <= budget)
562
+ break;
563
+ prefix--;
564
+ }
565
+ for (const { item } of optional.slice(0, prefix)) {
566
+ rendered += item.text;
567
+ included.push(item.id);
568
+ }
569
+ for (const { item } of optional.slice(prefix)) {
570
+ dropped.push(item.id);
571
+ }
572
+ // Whatever room is left goes to the FIRST section that did not fit, rendered
573
+ // partially and cut at a line boundary. Exactly one section can be partial,
574
+ // so the cut is explainable ("this one did not fit whole") rather than a
575
+ // scatter of half-rendered sections.
576
+ const next = optional[prefix];
577
+ if (next) {
578
+ const room = budget - rendered.length;
579
+ if (room >= TRUNCATION_MIN_CHARS) {
580
+ rendered += truncateAtLine(next.item.text, room);
581
+ included.push(next.item.id);
582
+ dropped.shift();
711
583
  }
712
584
  }
713
- return { rendered, included };
585
+ return { rendered, included, dropped, protectedOverflow };
714
586
  }
715
587
  // ═══════════════════════════════════════════════════════════════════════════
716
588
  // v2.9: Prompt Requests — model-expressed information needs
717
589
  // ═══════════════════════════════════════════════════════════════════════════
718
- /** Match a free-text emphasis target against state items.
719
- * v2.11: ID-first matching. When a target is an ID (c-/cr-/sg-XXXXXXXX),
720
- * candidates that derive to the same ID get score 1.0. Falls back to
721
- * Jaccard similarity otherwise. */
590
+ /** Match an emphasis target against state items.
591
+ * v2.11: ID-first — a target written as a stable id (`c-`/`cr-`/`sg-` + 8 hex)
592
+ * matches the candidate deriving to the same id.
593
+ * v3.8.1: the other arm is NORMALIZED-EXACT text. The Jaccard fallback is
594
+ * gone, so an emphasis can no longer pull in a near-miss because the two
595
+ * strings happened to share tokens. An unmatched target still renders
596
+ * nothing, keeping this a pure reorder with zero token overhead. */
722
597
  function matchEmphasize(targets, candidates, cap) {
723
598
  if (targets.length === 0 || candidates.length === 0)
724
599
  return [];
725
- const scored = candidates.map((c) => {
726
- let best = 0;
727
- for (const t of targets) {
728
- let s = 0;
729
- // Phase 1: exact ID match (v2.11)
730
- const idMatch = t.match(STABLE_ID_RE);
600
+ const matched = [];
601
+ for (const candidate of candidates) {
602
+ const candidateText = normalizeText(candidate);
603
+ const hit = targets.some((target) => {
604
+ const idMatch = target.match(STABLE_ID_RE);
731
605
  if (idMatch) {
732
- const candidateId = idMatch[1] + "-" + deriveItemId(c);
733
- if (candidateId === t)
734
- s = 1.0;
606
+ return `${idMatch[1]}-${deriveItemId(candidate)}` === target;
735
607
  }
736
- // Phase 2: Jaccard fallback
737
- if (s === 0)
738
- s = jaccardSimilarity(t, c);
739
- if (s > best)
740
- best = s;
741
- }
742
- return { text: c, score: best };
743
- });
744
- return scored
745
- .filter((e) => e.score >= getPolicy().evolution.constraint_match_threshold)
746
- .sort((a, b) => b.score - a.score)
747
- .slice(0, cap)
748
- .map((e) => e.text);
608
+ return normalizeText(target) === candidateText;
609
+ });
610
+ if (hit)
611
+ matched.push(candidate);
612
+ }
613
+ // Candidate order, capped — deterministic, and no score to sort by.
614
+ return matched.slice(0, cap);
749
615
  }
750
616
  /** Render the "Confusion Alerts" section that appears at the prompt top.
751
- * Compiler auto-matches each confusion point against state sections. */
752
- function renderConfusionAlerts(points, state, fullDensity) {
617
+ *
618
+ * v3.8.1: the points are echoed back, truncated and capped — nothing more.
619
+ * The compiler used to score each point against eight section keywords with
620
+ * Jaccard similarity and point at the argmax. That pointer was both a
621
+ * similarity verdict and, in practice, nearly always wrong: "I don't
622
+ * understand milestone tracking" scores 1/8 = 0.125 against the keyword
623
+ * `milestone`, below the 0.15 default, so it fell through to the generic
624
+ * line anyway. The agent names the state file itself; the prompt does not
625
+ * guess where to look. */
626
+ function renderConfusionAlerts(points, fullDensity) {
753
627
  if (points.length === 0)
754
628
  return "";
755
629
  const lines = [
@@ -759,16 +633,6 @@ function renderConfusionAlerts(points, state, fullDensity) {
759
633
  "Address each before proceeding — the state file has full context.",
760
634
  "",
761
635
  ];
762
- const sections = [
763
- { keyword: "milestone", label: "Phase History", ref: "## Phase History" },
764
- { keyword: "sub-goal", label: "Sub-Goal Dashboard", ref: "## Sub-Goal Dashboard" },
765
- { keyword: "constraint", label: "Active Constraints / Constraint Lifecycle", ref: "## Active Constraints" },
766
- { keyword: "trust", label: "Agent Trust", ref: "## Agent Trust" },
767
- { keyword: "progress", label: "Progress Dashboard", ref: "## Progress Dashboard" },
768
- { keyword: "criteria", label: "Success Criteria", ref: "## Success Criteria" },
769
- { keyword: "phase", label: "Phase History", ref: "## Phase History" },
770
- { keyword: "objective", label: "Loop Objective", ref: "## Loop Objective" },
771
- ];
772
636
  // v2.14: L2 renders up to the policy cap (was: every confusion point,
773
637
  // unbounded by max_confusion_points); L1 keeps a single alert.
774
638
  const shown = fullDensity
@@ -776,21 +640,8 @@ function renderConfusionAlerts(points, state, fullDensity) {
776
640
  : points.slice(0, 1);
777
641
  for (const point of shown) {
778
642
  const truncated = point.length > 200 ? point.slice(0, 197) + "…" : point;
779
- // Find best-matching state section
780
- let bestSection = null;
781
- let bestScore = 0;
782
- for (const sec of sections) {
783
- const score = jaccardSimilarity(truncated.toLowerCase(), sec.keyword);
784
- if (score > bestScore) {
785
- bestScore = score;
786
- bestSection = sec;
787
- }
788
- }
789
- const pointer = bestSection && bestScore >= getPolicy().prompt.confusion_section_threshold
790
- ? `→ Check **${bestSection.label}** (\`${bestSection.ref}\`) in the state file.`
791
- : "→ Read the full state file for relevant context.";
792
643
  lines.push(`- **"${truncated}"**`);
793
- lines.push(` ${pointer}`);
644
+ lines.push(" → Read the full state file for relevant context.");
794
645
  lines.push("");
795
646
  }
796
647
  if (!fullDensity && points.length > 1) {
@@ -801,7 +652,7 @@ function renderConfusionAlerts(points, state, fullDensity) {
801
652
  }
802
653
  /** Render the "Critical Context" section — items the model emphasized.
803
654
  * Pulled from active constraints, discoveries, and blocking issues. */
804
- function renderCriticalContext(emphasized, state) {
655
+ function renderCriticalContext(emphasized) {
805
656
  if (emphasized.length === 0)
806
657
  return "";
807
658
  const lines = [
@@ -822,14 +673,13 @@ export function assemblePromptArtifact(input) {
822
673
  const stateHash = hashCanonicalState(input.state);
823
674
  const attempt = Math.max(1, input.attempt ?? 1);
824
675
  // ── State file reference + conditional read instruction ────────────
825
- // L2 rounds (full rehydration), recovery, rejection, and drift all
826
- // benefit from the model reading the durable state file rather than
827
- // relying on potentially-compacted conversation history.
676
+ // L2 rounds (full rehydration), recovery and rejection all benefit from the
677
+ // model reading the durable state file rather than relying on
678
+ // potentially-compacted conversation history.
828
679
  const fullContextReasons = new Set([
829
680
  "first_round", "plan_boundary", "checkpoint_boundary", "goal_changed",
830
681
  "missing_previous_state", "verification_contradicted",
831
- "rejection_rehydrate", "recovery_boundary", "state_drift",
832
- "periodic_refresh",
682
+ "rejection_rehydrate", "recovery_boundary",
833
683
  ]);
834
684
  const needsFullContext = input.level === "l2"
835
685
  || input.reasons.some((r) => fullContextReasons.has(r));
@@ -881,8 +731,8 @@ export function assemblePromptArtifact(input) {
881
731
  "- `should_continue`: Set to **`false` ONLY** when the ENTIRE loop task is done.",
882
732
  " Partial progress or completed subtasks → `true`.",
883
733
  "- `constraint_violations`: Be honest. List every constraint you actually violated.",
884
- "- `execution_evidence.files_changed`: List files you modified — used for verification.",
885
- "- `execution_evidence.test_results`: Report actual test runner output.",
734
+ "- `execution_report.files_changed`: List files you modified — used for verification.",
735
+ "- `execution_report.tests_reported`: Report actual test runner output.",
886
736
  "- `progress_estimate`: A number 0.0–1.0 reflecting overall task completion.",
887
737
  "",
888
738
  "### If the Prompt Says \"REJECTED\"",
@@ -897,11 +747,10 @@ export function assemblePromptArtifact(input) {
897
747
  // ── v2.9: Prompt Requests — model-expressed information needs ──────────
898
748
  const pr = input.promptRequests;
899
749
  const levelIsL0 = input.level === "l0";
900
- const levelIsL1 = input.level === "l1";
901
750
  const levelIsL2 = input.level === "l2";
902
751
  // Confusion alerts: L1/L2 only, rendered at the top (before mandatory sections)
903
752
  const confusionText = (!levelIsL0 && pr?.confusion_points?.length)
904
- ? renderConfusionAlerts(pr.confusion_points, input.state, levelIsL2)
753
+ ? renderConfusionAlerts(pr.confusion_points, levelIsL2)
905
754
  : "";
906
755
  // Emphasize: L1/L2 only, matched against active state items
907
756
  const policy = getPolicy().prompt;
@@ -935,24 +784,13 @@ export function assemblePromptArtifact(input) {
935
784
  changesSinceLastRound: input.state.changesSinceLastRound.filter((item) => !emphasizedSet.has(item)),
936
785
  }
937
786
  : input.state;
938
- const sections = selectSections({ ...input, state: stateForSections }, emphasizedSet);
939
- const criticalContextText = renderCriticalContext(emphasized, input.state);
940
- // v3.2: L1 only — snapshot what this prompt actually presented, so the
941
- // NEXT L1 compile can diff against it (persisted via PromptArtifact →
942
- // lineage). L0/L2 leave it undefined: their presentation semantics differ
943
- // (L2's dashboard includes done/canceled sub-goals, which would
944
- // contaminate the L1 active-set diff).
945
- let presentedState;
946
- if (levelIsL1) {
947
- const activeSubsForSnapshot = stateForSections.subGoals.filter((sg) => sg.status === "in_progress" || sg.status === "pending");
948
- presentedState = {
949
- round: input.state.round,
950
- constraintIds: activeNonHardConstraints(stateForSections)
951
- .map((text) => constraintIdOf(text, stateForSections.constraintMetadata)),
952
- subGoals: activeSubsForSnapshot.map((sg) => [sg.id, sg.status]),
953
- milestoneRanges: input.state.milestones.map((m) => [m.round_range.start, m.round_range.end]),
954
- };
955
- }
787
+ const sections = selectSections({ ...input, state: stateForSections });
788
+ const criticalContextText = renderCriticalContext(emphasized);
789
+ // v3.8.1: no presentation snapshot. The artifact records what THIS prompt
790
+ // rendered (level, hashes, round identity, the sections it emitted) and
791
+ // nothing about what a LATER prompt should do differently — a prompt's
792
+ // content must follow from committed facts, not from what the previous
793
+ // prompt happened to show.
956
794
  // Inject confusion alerts into the header area (before mandatory sections)
957
795
  const fixedText = header + confusionText;
958
796
  // Inject critical context as a mandatory section
@@ -967,18 +805,27 @@ export function assemblePromptArtifact(input) {
967
805
  ...sections,
968
806
  ];
969
807
  }
970
- const selected = renderWithinBudget(augmentedSections, fixedText, budget - footer.length);
808
+ const sectionBudget = budget - footer.length;
809
+ const selected = renderWithinBudget(augmentedSections, fixedText, sectionBudget);
971
810
  const renderedPrompt = selected.rendered + footer;
972
811
  const promptHash = createHash("sha256").update(renderedPrompt).digest("hex");
973
812
  return {
974
813
  schemaVersion: PROMPT_ARTIFACT_SCHEMA_VERSION,
975
814
  roundId: `loop:${input.state.loopId}:round:${input.state.round}`,
815
+ round: input.state.round,
976
816
  attempt,
977
817
  level: input.level,
978
818
  renderedPrompt,
979
819
  promptHash,
980
820
  stateHash,
981
- ...(presentedState ? { presentedState } : {}),
821
+ // v3.8.1: the artifact records what THIS prompt did — which sections it
822
+ // emitted, which the budget rule dropped, and whether the protected set
823
+ // overflowed. It records nothing about what a later prompt should do.
824
+ sections: selected.included,
825
+ droppedSections: selected.dropped,
826
+ protectedOverflow: selected.protectedOverflow,
827
+ budget: sectionBudget,
828
+ renderedChars: selected.rendered.length,
982
829
  };
983
830
  }
984
831
  //# sourceMappingURL=prompt-assembler.js.map