loopforge 3.7.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +171 -157
  3. package/dist/audit.d.ts +53 -1
  4. package/dist/audit.d.ts.map +1 -1
  5. package/dist/audit.js +106 -22
  6. package/dist/audit.js.map +1 -1
  7. package/dist/canonical-state.d.ts +75 -69
  8. package/dist/canonical-state.d.ts.map +1 -1
  9. package/dist/canonical-state.js +276 -168
  10. package/dist/canonical-state.js.map +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +111 -12
  13. package/dist/cli.js.map +1 -1
  14. package/dist/cognitive-facts.d.ts +13 -0
  15. package/dist/cognitive-facts.d.ts.map +1 -1
  16. package/dist/cognitive-facts.js +30 -16
  17. package/dist/cognitive-facts.js.map +1 -1
  18. package/dist/cognitive-governance.d.ts +30 -6
  19. package/dist/cognitive-governance.d.ts.map +1 -1
  20. package/dist/cognitive-governance.js +74 -18
  21. package/dist/cognitive-governance.js.map +1 -1
  22. package/dist/committed-round.d.ts +109 -24
  23. package/dist/committed-round.d.ts.map +1 -1
  24. package/dist/committed-round.js +235 -52
  25. package/dist/committed-round.js.map +1 -1
  26. package/dist/contract-items.d.ts +55 -0
  27. package/dist/contract-items.d.ts.map +1 -0
  28. package/dist/contract-items.js +180 -0
  29. package/dist/contract-items.js.map +1 -0
  30. package/dist/enforcement-gate.d.ts +43 -20
  31. package/dist/enforcement-gate.d.ts.map +1 -1
  32. package/dist/enforcement-gate.js +269 -378
  33. package/dist/enforcement-gate.js.map +1 -1
  34. package/dist/engine.d.ts +11 -6
  35. package/dist/engine.d.ts.map +1 -1
  36. package/dist/engine.js +102 -67
  37. package/dist/engine.js.map +1 -1
  38. package/dist/evidence-claims.d.ts +4 -4
  39. package/dist/evidence-claims.d.ts.map +1 -1
  40. package/dist/evidence-claims.js +16 -14
  41. package/dist/evidence-claims.js.map +1 -1
  42. package/dist/evidence-provider.d.ts +91 -90
  43. package/dist/evidence-provider.d.ts.map +1 -1
  44. package/dist/evidence-provider.js +349 -160
  45. package/dist/evidence-provider.js.map +1 -1
  46. package/dist/explain.d.ts +76 -0
  47. package/dist/explain.d.ts.map +1 -0
  48. package/dist/explain.js +127 -0
  49. package/dist/explain.js.map +1 -0
  50. package/dist/generate-schema.js +250 -53
  51. package/dist/generate-schema.js.map +1 -1
  52. package/dist/index.d.ts +8 -7
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +7 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/loop-compiler.d.ts +41 -40
  57. package/dist/loop-compiler.d.ts.map +1 -1
  58. package/dist/loop-compiler.js +275 -515
  59. package/dist/loop-compiler.js.map +1 -1
  60. package/dist/loop-extras-parser.d.ts +0 -1
  61. package/dist/loop-extras-parser.d.ts.map +1 -1
  62. package/dist/loop-extras-parser.js +0 -4
  63. package/dist/loop-extras-parser.js.map +1 -1
  64. package/dist/loop-projection.d.ts.map +1 -1
  65. package/dist/loop-projection.js +3 -1
  66. package/dist/loop-projection.js.map +1 -1
  67. package/dist/mcp/round-lifecycle.d.ts +40 -21
  68. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  69. package/dist/mcp/round-lifecycle.js +173 -120
  70. package/dist/mcp/round-lifecycle.js.map +1 -1
  71. package/dist/mcp/server.d.ts.map +1 -1
  72. package/dist/mcp/server.js +63 -10
  73. package/dist/mcp/server.js.map +1 -1
  74. package/dist/mcp/session.d.ts +53 -7
  75. package/dist/mcp/session.d.ts.map +1 -1
  76. package/dist/mcp/session.js +240 -139
  77. package/dist/mcp/session.js.map +1 -1
  78. package/dist/mcp/submission-boundary.d.ts +89 -0
  79. package/dist/mcp/submission-boundary.d.ts.map +1 -0
  80. package/dist/mcp/submission-boundary.js +203 -0
  81. package/dist/mcp/submission-boundary.js.map +1 -0
  82. package/dist/mcp/tools.d.ts.map +1 -1
  83. package/dist/mcp/tools.js +377 -125
  84. package/dist/mcp/tools.js.map +1 -1
  85. package/dist/policy-metrics.js +2 -2
  86. package/dist/policy-metrics.js.map +1 -1
  87. package/dist/policy.d.ts +65 -95
  88. package/dist/policy.d.ts.map +1 -1
  89. package/dist/policy.js +151 -39
  90. package/dist/policy.js.map +1 -1
  91. package/dist/prompt-assembler.d.ts +7 -33
  92. package/dist/prompt-assembler.d.ts.map +1 -1
  93. package/dist/prompt-assembler.js +313 -473
  94. package/dist/prompt-assembler.js.map +1 -1
  95. package/dist/prompt-policy.d.ts +1 -4
  96. package/dist/prompt-policy.d.ts.map +1 -1
  97. package/dist/prompt-policy.js +6 -7
  98. package/dist/prompt-policy.js.map +1 -1
  99. package/dist/protocol.d.ts +411 -168
  100. package/dist/protocol.d.ts.map +1 -1
  101. package/dist/protocol.js +23 -69
  102. package/dist/protocol.js.map +1 -1
  103. package/dist/replay.js +2 -2
  104. package/dist/replay.js.map +1 -1
  105. package/dist/round-contract.d.ts +69 -57
  106. package/dist/round-contract.d.ts.map +1 -1
  107. package/dist/round-contract.js +104 -64
  108. package/dist/round-contract.js.map +1 -1
  109. package/dist/round-coordinator.d.ts +55 -16
  110. package/dist/round-coordinator.d.ts.map +1 -1
  111. package/dist/round-coordinator.js +179 -57
  112. package/dist/round-coordinator.js.map +1 -1
  113. package/dist/round-driver.d.ts +7 -5
  114. package/dist/round-driver.d.ts.map +1 -1
  115. package/dist/round-driver.js +2 -1
  116. package/dist/round-driver.js.map +1 -1
  117. package/dist/round-facts.d.ts +96 -0
  118. package/dist/round-facts.d.ts.map +1 -0
  119. package/dist/round-facts.js +216 -0
  120. package/dist/round-facts.js.map +1 -0
  121. package/dist/round-transaction.d.ts +23 -9
  122. package/dist/round-transaction.d.ts.map +1 -1
  123. package/dist/round-transaction.js +99 -22
  124. package/dist/round-transaction.js.map +1 -1
  125. package/dist/self-eval.d.ts +127 -11
  126. package/dist/self-eval.d.ts.map +1 -1
  127. package/dist/self-eval.js +517 -36
  128. package/dist/self-eval.js.map +1 -1
  129. package/dist/subgoal-state.d.ts +77 -0
  130. package/dist/subgoal-state.d.ts.map +1 -0
  131. package/dist/subgoal-state.js +198 -0
  132. package/dist/subgoal-state.js.map +1 -0
  133. package/dist/token-utils.d.ts +52 -19
  134. package/dist/token-utils.d.ts.map +1 -1
  135. package/dist/token-utils.js +83 -72
  136. package/dist/token-utils.js.map +1 -1
  137. package/dist/verification-gate.d.ts +27 -46
  138. package/dist/verification-gate.d.ts.map +1 -1
  139. package/dist/verification-gate.js +308 -557
  140. package/dist/verification-gate.js.map +1 -1
  141. package/dist/version.d.ts +5 -0
  142. package/dist/version.d.ts.map +1 -0
  143. package/dist/version.js +5 -0
  144. package/dist/version.js.map +1 -0
  145. package/dist/workspace.d.ts +9 -0
  146. package/dist/workspace.d.ts.map +1 -1
  147. package/dist/workspace.js +17 -0
  148. package/dist/workspace.js.map +1 -1
  149. package/loop_policy.json +17 -25
  150. package/package.json +7 -7
  151. package/skills/perception/SKILL.md +266 -64
@@ -2,13 +2,18 @@
2
2
  *
3
3
  * L0/L1/L2 control state density only. Reasoning strategy belongs to the
4
4
  * external Agent. Mandatory task, hard-constraint, and verification sections
5
- * are never truncated; budgets are soft and overflow is recorded.
5
+ * are never truncated and always render. Budgets are soft CEILINGS applied
6
+ * to optional sections: optional sections are appended in priority order
7
+ * while the rendered length stays under the level's budget, and the rest are
8
+ * dropped from the prompt (the dropped content stays derivable from the
9
+ * vault). Prompt length can exceed the ceiling when the mandatory sections
10
+ * alone are over it — truncation of mandatory content never happens.
6
11
  */
7
12
  import { createHash } from "node:crypto";
8
- import { buildRoadmap, hashCanonicalState, milestoneHeading, trustBarLine } from "./canonical-state.js";
9
- 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";
10
16
  import { getPolicy } from "./policy.js";
11
- export const PROMPT_ARTIFACT_SCHEMA_VERSION = 1;
12
17
  export const DEFAULT_PROMPT_BUDGETS = {
13
18
  l0: 3000,
14
19
  l1: 7000,
@@ -24,11 +29,9 @@ function bullets(values, prefix = "- ") {
24
29
  * Same hash strategy as computeGoalTextHash in loop-compiler.ts. */
25
30
  /** v2.11: Render a bullet list with ID prefixes for constraints/criteria.
26
31
  * Each item gets a [prefix-XXXXXXXX] tag. Items that already look like IDs
27
- * 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. */
28
34
  function bulletsWithIds(values, prefix) {
29
- const idEnabled = getPolicy().evolution.constraint_id_enabled;
30
- if (!idEnabled)
31
- return bullets(values);
32
35
  return values.map((v) => {
33
36
  const trimmed = v.trim();
34
37
  if (STABLE_ID_RE.test(trimmed)) {
@@ -47,9 +50,6 @@ function bulletsWithIds(values, prefix) {
47
50
  * one prompt. Falls back to a c- derivation only when metadata is missing
48
51
  * (defensive: metadata covers every active text by construction). */
49
52
  function bulletsWithIdentities(values, metadata) {
50
- const idEnabled = getPolicy().evolution.constraint_id_enabled;
51
- if (!idEnabled)
52
- return bullets(values);
53
53
  const idByText = new Map(metadata.map((meta) => [meta.text, meta.id]));
54
54
  return values.map((v) => {
55
55
  const trimmed = v.trim();
@@ -70,7 +70,7 @@ function bulletsWithIdentities(values, metadata) {
70
70
  * list — a new constant can never silently lack an action entry). */
71
71
  const VERIFICATION_ACTIONS = {
72
72
  success_with_remaining_criteria: "Complete the remaining criteria before claiming success, or set success=false and report what remains.",
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_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.",
74
74
  outcome_success_contradiction: "Reconcile the declared outcome with the success flag and resubmit an honest evaluation.",
75
75
  blocked_without_blocker: "State the concrete blocker, or drop the blocked outcome.",
76
76
  retroactive_claim_bad_round: "Correct the referenced round number, or withdraw the retroactive claim.",
@@ -81,21 +81,18 @@ const VERIFICATION_ACTIONS = {
81
81
  evidence_integrity: "Correct the mismatch between your claims and the recorded evidence.",
82
82
  required_command_failed: "Re-run the required verification command and report its actual output before claiming success.",
83
83
  command_evidence_mismatch: "Report the true command output and reconcile the mismatch.",
84
- intent_drift: "Explain the pivot in drift_clarification with concrete references, or redo the round as promised.",
85
- subgoal_drift: "Reconcile the sub-goal list with your reported work before resubmitting.",
86
84
  backtrack_workspace_not_restored: "Restore the skipped-round files and the expected git HEAD, then resubmit.",
87
85
  criteria_claims_unverified: "Provide evidence for each claimed success criterion.",
88
86
  // v3.3 — Round Contract checks (and the three v3.3 checks that predated
89
87
  // this table entry and fell back to the generic action).
90
88
  verification_entrypoint_modified: "Re-run the verification command on the changed entrypoint, or revert the entrypoint change and re-run.",
91
89
  test_files_modified: "Re-run the verification command after the test-file changes and report the fresh output.",
92
- round_underspecified: "Declare this round's contract with a non-empty done_when list — what must be true when the round is done?",
93
- round_unverifiable: "Name only configured, enabled evidence.commands in verification_plan, or drop done_when items that cannot be machine-verified.",
94
- round_scope_drift: "Revert the out-of-scope file changes, or extend the contract scope to cover them and explain why in drift_clarification.",
95
- 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).",
96
- // v3.5 — contract completion machine-backing + premature-replacement warn
97
- 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).",
98
- 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.",
94
+ // v3.7.1 — opt-in gate layer: cited gates need an approved human decision
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.",
99
96
  };
100
97
  /** Fallback for unmapped check names (forward-compat with future checks). */
101
98
  const VERIFICATION_ACTION_FALLBACK = "Re-examine the flagged claim and correct your evaluation before resubmitting.";
@@ -130,79 +127,13 @@ function activeNonHardConstraints(state) {
130
127
  // ═══════════════════════════════════════════════════════════════════════════
131
128
  // v3.2: L1 diff-collapse helpers
132
129
  // ═══════════════════════════════════════════════════════════════════════════
133
- /** v3.2: Collapse only when at least this many items are unchanged — a tiny
134
- * unchanged set renders in full (nothing worth saving). */
135
- export const COLLAPSE_MIN_UNCHANGED = 3;
136
- /** v3.2: Recent-rounds items kept in full when older ones collapse. */
137
- export const KEEP_RECENT_ROUNDS = 3;
138
130
  /** v3.2: Stable ID of a constraint text as rendered by bulletsWithIds —
139
131
  * metadata's own id when present, else the derived hash. */
140
132
  function constraintIdOf(text, metadata) {
141
133
  const meta = metadata.find((m) => m.text === text);
142
134
  return meta?.id ?? `c-${deriveItemId(text)}`;
143
135
  }
144
- export function diffConstraints(baseline, activeTexts, metadata, round, emphasized) {
145
- if (!baseline)
146
- return { changed: activeTexts, unchangedCount: 0, removedCount: 0 };
147
- const baselineIds = new Set(baseline.constraintIds);
148
- const currentIds = new Set(activeTexts.map((text) => constraintIdOf(text, metadata)));
149
- const changed = [];
150
- let unchangedCount = 0;
151
- for (const text of activeTexts) {
152
- const id = constraintIdOf(text, metadata);
153
- const violatedThisRound = metadata.find((m) => m.text === text)
154
- ?.last_violated_at_round === round;
155
- if (!baselineIds.has(id) || violatedThisRound)
156
- changed.push(text);
157
- else
158
- unchangedCount++;
159
- }
160
- let removedCount = 0;
161
- if (emphasized.size > 0) {
162
- const emphasizedIds = new Set([...emphasized].map((text) => constraintIdOf(text, metadata)));
163
- for (const id of baselineIds) {
164
- if (!currentIds.has(id) && !emphasizedIds.has(id))
165
- removedCount++;
166
- }
167
- }
168
- else {
169
- for (const id of baselineIds) {
170
- if (!currentIds.has(id))
171
- removedCount++;
172
- }
173
- }
174
- return { changed, unchangedCount, removedCount };
175
- }
176
- export function diffSubGoals(baseline, activeSubs) {
177
- if (!baseline)
178
- return { changed: activeSubs, unchangedCount: 0, removedCount: 0 };
179
- const baselineById = new Map(baseline.subGoals);
180
- const currentIds = new Set(activeSubs.map((sg) => sg.id));
181
- const changed = [];
182
- let unchangedCount = 0;
183
- for (const sg of activeSubs) {
184
- const prevStatus = baselineById.get(sg.id);
185
- if (prevStatus === undefined || prevStatus !== sg.status)
186
- changed.push(sg);
187
- else
188
- unchangedCount++;
189
- }
190
- let removedCount = 0;
191
- for (const [id] of baseline.subGoals) {
192
- if (!currentIds.has(id))
193
- removedCount++;
194
- }
195
- return { changed, unchangedCount, removedCount };
196
- }
197
- /** v3.2: True when a milestone boundary was crossed since the baseline —
198
- * the Recent Rounds section then renders in full (a phase ended). */
199
- export function milestoneBoundaryChanged(baseline, milestones) {
200
- if (!baseline)
201
- return false;
202
- const baselineRanges = new Set(baseline.milestoneRanges.map(([s, e]) => `${s}:${e}`));
203
- return milestones.some((m) => !baselineRanges.has(`${m.round_range.start}:${m.round_range.end}`));
204
- }
205
- function commonMandatorySections(state,
136
+ function commonMandatorySections(state,
206
137
  /** v2.11: L0 skips ID rendering to stay within tight budget. */
207
138
  level) {
208
139
  const sections = [
@@ -259,10 +190,38 @@ function l0Sections(state) {
259
190
  }
260
191
  return sections;
261
192
  }
262
- 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) {
263
223
  const sections = commonMandatorySections(state, "l1");
264
224
  const active = activeNonHardConstraints(state);
265
- const collapseEnabled = getPolicy().prompt.l1_collapse_enabled;
266
225
  if (state.changesSinceLastRound.length > 0) {
267
226
  sections.push({
268
227
  id: "changes",
@@ -270,36 +229,27 @@ function l1Sections(state, baseline, emphasized) {
270
229
  mandatory: false,
271
230
  });
272
231
  }
273
- // v3.2: Active Constraints — collapse unchanged items against the previous
274
- // round's presentation; new and violated-this-round constraints render in
275
- // full. The collapse line points at the state file (regenerated every
276
- // 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.
277
238
  if (active.length > 0) {
278
- const diff = diffConstraints(baseline, active, state.constraintMetadata, state.round, emphasized);
279
- const renderFull = !collapseEnabled || !baseline
280
- || diff.unchangedCount < COLLAPSE_MIN_UNCHANGED;
281
- if (renderFull) {
282
- sections.push({
283
- id: "active_constraints",
284
- text: section("Active Constraints / Success Criteria", bulletsWithIdentities(active, state.constraintMetadata)),
285
- mandatory: false,
286
- });
287
- }
288
- else {
289
- const lines = diff.changed.map((text) => {
290
- const id = constraintIdOf(text, state.constraintMetadata);
291
- const violated = state.constraintMetadata.find((m) => m.text === text)
292
- ?.last_violated_at_round === state.round;
293
- const suffix = violated ? " (violated this round)" : " (new this round)";
294
- return `- [\`${id}\`] ${text}${suffix}`;
295
- });
296
- lines.push(`- … ${diff.unchangedCount} unchanged constraints, ${diff.removedCount} demoted since R${baseline.round} (see state file)`);
297
- sections.push({
298
- id: "active_constraints",
299
- text: section("Active Constraints / Success Criteria", lines.join("\n")),
300
- mandatory: false,
301
- });
302
- }
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
+ });
303
253
  }
304
254
  if (state.remainingCriteria.length > 0) {
305
255
  sections.push({
@@ -315,44 +265,23 @@ function l1Sections(state, baseline, emphasized) {
315
265
  mandatory: false,
316
266
  });
317
267
  }
318
- // v2.2: Sub-Goal Dashboard (compact — L1). v3.2: unchanged sub-goals
319
- // collapse to a count line; new/transitioned ones render in full.
320
- const activeSubs = state.subGoals.filter((sg) => sg.status === "in_progress" || sg.status === "pending");
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.
271
+ const view = activeSubGoalView(state.subGoals, Number.POSITIVE_INFINITY);
272
+ const activeSubs = view.active;
321
273
  if (activeSubs.length > 0) {
322
- const diff = diffSubGoals(baseline, activeSubs);
323
- const renderFull = !collapseEnabled || !baseline
324
- || diff.unchangedCount < COLLAPSE_MIN_UNCHANGED;
325
- if (renderFull) {
326
- const maxShow = 5;
327
- const items = activeSubs.slice(0, maxShow).map((sg) => {
328
- const icon = sg.status === "in_progress" ? "🔄" : "⏳";
329
- return `${icon} [\`${sg.id}\`] ${sg.description}`;
330
- });
331
- if (activeSubs.length > maxShow) {
332
- items.push(`... and ${activeSubs.length - maxShow} more`);
333
- }
334
- sections.push({
335
- id: "sub_goals",
336
- text: section("Active Sub-Goals", items.join("\n")),
337
- mandatory: false,
338
- });
339
- }
340
- else {
341
- const transitionCount = diff.changed.filter((sg) => {
342
- const prev = baseline.subGoals.find(([id]) => id === sg.id);
343
- return prev !== undefined && prev[1] !== sg.status;
344
- }).length;
345
- const items = diff.changed.slice(0, 5).map((sg) => {
346
- const icon = sg.status === "in_progress" ? "🔄" : "⏳";
347
- return `${icon} [\`${sg.id}\`] ${sg.description}`;
348
- });
349
- items.push(`- … ${diff.unchangedCount} unchanged sub-goals, ${transitionCount + diff.removedCount} changed since R${baseline.round} (see state file)`);
350
- sections.push({
351
- id: "sub_goals",
352
- text: section("Active Sub-Goals", items.join("\n")),
353
- mandatory: false,
354
- });
274
+ const iconOf = (sg) => sg.status === "in_progress" ? "🔄" : sg.status === "blocked" ? "🚫" : "⏳";
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`);
355
279
  }
280
+ sections.push({
281
+ id: "sub_goals",
282
+ text: section("Active Sub-Goals", items.join("\n")),
283
+ mandatory: false,
284
+ });
356
285
  }
357
286
  if (state.discoveries.length > 0) {
358
287
  sections.push({
@@ -361,32 +290,14 @@ function l1Sections(state, baseline, emphasized) {
361
290
  mandatory: false,
362
291
  });
363
292
  }
364
- // v3.2: Recent Rounds — keep the newest KEEP_RECENT_ROUNDS in full, fold
365
- // 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.
366
295
  if (state.rollingOutcomes.length > 0) {
367
- const boundaryChanged = milestoneBoundaryChanged(baseline, state.milestones);
368
- const collapseable = collapseEnabled && baseline !== null
369
- && state.rollingOutcomes.length > KEEP_RECENT_ROUNDS && !boundaryChanged;
370
- if (collapseable) {
371
- const kept = state.rollingOutcomes.slice(-KEEP_RECENT_ROUNDS);
372
- const earlier = state.rollingOutcomes.length - kept.length;
373
- const lines = [
374
- ...kept,
375
- `- … ${earlier} earlier round${earlier === 1 ? "" : "s"} (see state file)`,
376
- ];
377
- sections.push({
378
- id: "rolling_outcomes",
379
- text: section("Recent Rounds", lines.join("\n")),
380
- mandatory: false,
381
- });
382
- }
383
- else {
384
- sections.push({
385
- id: "rolling_outcomes",
386
- text: section("Recent Rounds", bullets(state.rollingOutcomes)),
387
- mandatory: false,
388
- });
389
- }
296
+ sections.push({
297
+ id: "rolling_outcomes",
298
+ text: section("Recent Rounds", bullets(state.rollingOutcomes)),
299
+ mandatory: false,
300
+ });
390
301
  }
391
302
  // v3.2: L1 also renders the explicit external context (previously L2-only).
392
303
  // Placed after the changes delta, within the soft budget.
@@ -397,36 +308,26 @@ function l1Sections(state, baseline, emphasized) {
397
308
  mandatory: false,
398
309
  });
399
310
  }
400
- // v3.3: Forward-looking roadmap (same buildRoadmap as state.md and L2).
401
- const roadmapLines = buildRoadmap(state);
402
- if (roadmapLines.length > 0) {
403
- sections.push({
404
- id: "roadmap",
405
- text: section("Roadmap", roadmapLines.join("\n")),
406
- mandatory: false,
407
- });
408
- }
409
- // v3.2: Recurring Issues carries the deterministic violation lessons (with
410
- // counts and rounds) when available; falls back to the rolling window text.
411
- if (state.recurringIssues.length > 0 || state.lessons.length > 0) {
412
- const violationLessons = state.lessons
413
- .filter((lesson) => lesson.kind === "constraint_violation")
414
- .slice(0, 3);
415
- const body = violationLessons.length > 0
416
- ? violationLessons
417
- .map((lesson) => `- ${lesson.text} (violated ${lesson.count}×: R${lesson.rounds.join(", R")})`)
418
- .join("\n")
419
- : bullets(state.recurringIssues);
311
+ // v3.8.1: one compact phase line, not the Roadmap prose.
312
+ const l1Phase = buildPhaseLine(state);
313
+ if (l1Phase) {
420
314
  sections.push({
421
- id: "recurring_issues",
422
- text: section("Recurring Issues", body),
315
+ id: "phase",
316
+ text: section("Phase", l1Phase),
423
317
  mandatory: false,
424
318
  });
425
319
  }
426
- if (state.nextAction) {
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) {
427
328
  sections.push({
428
- id: "next_action",
429
- text: section("Next Action", state.nextAction),
329
+ id: "active_warnings",
330
+ text: section("Active Warnings", warnings),
430
331
  mandatory: false,
431
332
  });
432
333
  }
@@ -452,7 +353,6 @@ function l2Sections(state, fullStateMarkdown) {
452
353
  ["blockers", "Blockers", state.blockers, null],
453
354
  ["discoveries", "Discoveries", state.discoveries, null],
454
355
  ["rolling_outcomes", "Cross-Round Outcomes", state.rollingOutcomes, null],
455
- ["recurring_issues", "Recurring Issues", state.recurringIssues, null],
456
356
  ["failed_patterns", "Failed Patterns", state.failedPatterns, null],
457
357
  ];
458
358
  for (const [id, title, values, idPrefix] of groups) {
@@ -470,32 +370,26 @@ function l2Sections(state, fullStateMarkdown) {
470
370
  : bullets(values);
471
371
  sections.push({ id, text: section(title, body), mandatory: false });
472
372
  }
473
- // v3.2: Lessons learned — repeated violations / verification failures
474
- // across the whole loop (not just the rolling window).
475
- if (state.lessons.length > 0) {
476
- const lessonLines = state.lessons.slice(0, 8).map((lesson) => {
477
- const icon = lesson.kind === "constraint_violation"
478
- ? "🚫"
479
- : lesson.kind === "verification_error" ? "⚠️" : "ℹ️";
480
- return `- ${icon} ${lesson.text} — ${lesson.count}× (R${lesson.rounds.join(", R")})`;
481
- });
482
- if (state.lessons.length > 8) {
483
- lessonLines.push(`- ... and ${state.lessons.length - 8} more`);
484
- }
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) {
485
379
  sections.push({
486
- id: "lessons",
487
- text: section("Lessons Learned", lessonLines.join("\n")),
380
+ id: "active_warnings",
381
+ text: section("Active Warnings", warnings),
488
382
  mandatory: false,
489
383
  });
490
384
  }
491
- // v3.3: Forward-looking roadmap — rendered right before the (backward-
492
- // looking) Phase History so the agent sees where it is before where it's
493
- // been. Same buildRoadmap as state.md and L1.
494
- const roadmapLines = buildRoadmap(state);
495
- 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) {
496
390
  sections.push({
497
- id: "roadmap",
498
- text: section("Roadmap", roadmapLines.join("\n")),
391
+ id: "phase",
392
+ text: section("Phase", l2Phase),
499
393
  mandatory: false,
500
394
  });
501
395
  }
@@ -519,56 +413,33 @@ function l2Sections(state, fullStateMarkdown) {
519
413
  });
520
414
  }
521
415
  if (state.subGoals.length > 0) {
416
+ // v3.7.1: active-only rows ordered blocked → in_progress → pending
417
+ // (priority asc, recently changed first), capped by policy; done and
418
+ // canceled appear only in the stats line.
419
+ const cap = getPolicy().evolution.max_active_subgoals;
420
+ const view = activeSubGoalView(state.subGoals, cap);
522
421
  const lines = [];
523
- // In progress
524
- const inProgress = state.subGoals.filter((sg) => sg.status === "in_progress");
525
- for (const sg of inProgress) {
526
- lines.push(`🔄 [\`${sg.id}\`] ${sg.description} (since R${sg.status_changed_at_round})`);
527
- }
528
- // Pending (by age)
529
- const pending = state.subGoals.filter((sg) => sg.status === "pending");
530
- const shownPending = pending.slice(0, 10);
531
- for (const sg of shownPending) {
532
- const age = state.round - sg.declared_at_round;
533
- const stale = age >= 10 ? " ⚠️ stale" : "";
534
- lines.push(`⏳ [\`${sg.id}\`] ${sg.description} (pending ${age} rounds)${stale}`);
535
- }
536
- if (pending.length > 10) {
537
- lines.push(`... and ${pending.length - 10} more pending`);
538
- }
539
- // Blocked
540
- const blocked = state.subGoals.filter((sg) => sg.status === "blocked");
541
- for (const sg of blocked) {
542
- lines.push(`🚫 [\`${sg.id}\`] ${sg.description} (blocked since R${sg.status_changed_at_round})`);
543
- }
544
- // Recently done
545
- const done = state.subGoals.filter((sg) => sg.status === "done");
546
- const recentDone = done.slice(0, 5);
547
- for (const sg of recentDone) {
548
- lines.push(`✅ [\`${sg.id}\`] ${sg.description} (done R${sg.completed_at_round ?? sg.status_changed_at_round})`);
549
- }
550
- if (done.length > 5) {
551
- lines.push(`... and ${done.length - 5} more completed`);
422
+ for (const sg of view.active) {
423
+ if (sg.status === "in_progress") {
424
+ lines.push(`🔄 [\`${sg.id}\`] ${sg.description} (since R${sg.status_changed_at_round})`);
425
+ }
426
+ else if (sg.status === "blocked") {
427
+ lines.push(`🚫 [\`${sg.id}\`] ${sg.description} (blocked since R${sg.status_changed_at_round})`);
428
+ }
429
+ else {
430
+ const age = state.round - sg.declared_at_round;
431
+ const stale = age >= 10 ? " ⚠️ stale" : "";
432
+ lines.push(`⏳ [\`${sg.id}\`] ${sg.description} (pending ${age} rounds)${stale}`);
433
+ }
552
434
  }
553
- // Stats line
554
- const total = state.subGoals.length;
555
- const doneCount = done.length;
556
- const canceledCount = state.subGoals.filter((sg) => sg.status === "canceled").length;
557
- const activeCount = inProgress.length + pending.length + blocked.length;
558
- lines.push(`─── ${total} total: ${activeCount} active, ${doneCount} done, ${canceledCount} canceled`);
435
+ // Stats line (full counts — rows may be capped)
436
+ lines.push(`─── ${view.total} total: ${view.activeTotal} active, ${view.done} done, ${view.canceled} canceled`);
559
437
  sections.push({
560
438
  id: "sub_goals_full",
561
439
  text: section("Sub-Goal Dashboard", lines.join("\n")),
562
440
  mandatory: false,
563
441
  });
564
442
  }
565
- if (state.nextAction) {
566
- sections.push({
567
- id: "next_action",
568
- text: section("Next Action", state.nextAction),
569
- mandatory: false,
570
- });
571
- }
572
443
  if (state.externalContext) {
573
444
  sections.push({
574
445
  id: "external_context",
@@ -577,97 +448,13 @@ function l2Sections(state, fullStateMarkdown) {
577
448
  });
578
449
  }
579
450
  // ── v2.8: Path B gap fills (previously only in full state markdown) ──
580
- // Progress Dashboard
581
- if (state.progress.estimate !== null ||
582
- state.progress.criteriaMet.length > 0 ||
583
- state.progress.criteriaRemaining.length > 0 ||
584
- state.progress.filesChanged.length > 0 ||
585
- state.progress.tests !== null ||
586
- state.machineStatus !== undefined) {
587
- const lines = [];
588
- const total = state.progress.criteriaMet.length + state.progress.criteriaRemaining.length;
589
- if (total > 0) {
590
- lines.push(`**Criteria**: ${state.progress.criteriaMet.length}/${total} met`);
591
- }
592
- if (state.progress.estimate !== null) {
593
- lines.push(`**Estimated Completion**: ${(state.progress.estimate * 100).toFixed(0)}%`);
594
- // v3.3: honest labeling — the estimate is the agent's own number.
595
- lines.push("**Signal source**: self-reported estimate (unverified until machine-backed)");
596
- }
597
- // v3.3: machine side of the comparison — git motion over committed
598
- // rounds (v3.6: the object always carries definite values — no
599
- // "unavailable" arm).
600
- if (state.machineStatus) {
601
- const ms = state.machineStatus;
602
- const motion = ms.gitMotion
603
- ? `changes in ${ms.motionRounds}/${ms.windowRounds} recent committed rounds`
604
- : `no git changes in the last ${ms.windowRounds} committed rounds`;
605
- lines.push(`**Machine (git)**: ${motion}`);
606
- }
607
- if (state.criterionStatuses.length > 0) {
608
- const metCount = state.criterionStatuses.filter((cs) => cs.status === "met").length;
609
- lines.push(`**Machine (criteria)**: ${metCount}/${state.criterionStatuses.length} met across committed rounds`);
610
- }
611
- if (state.progress.tests) {
612
- lines.push(`**Tests**: ${state.progress.tests.passed} passed, ` +
613
- `${state.progress.tests.failed} failed, ${state.progress.tests.skipped} skipped`);
614
- }
615
- if (state.progress.filesChanged.length > 0) {
616
- lines.push("**Files Changed**:");
617
- for (const f of state.progress.filesChanged.slice(0, 10))
618
- lines.push(`- ${f}`);
619
- if (state.progress.filesChanged.length > 10) {
620
- lines.push(`- ... and ${state.progress.filesChanged.length - 10} more`);
621
- }
622
- }
623
- // v3.2: Goal → criteria → evidence vertical view — per-criterion status
624
- // with the round it was met and linked sub-goals. IDs follow
625
- // constraint_id_enabled like every other ID-rendering path.
626
- if (state.criterionStatuses.length > 0) {
627
- const idEnabled = getPolicy().evolution.constraint_id_enabled;
628
- lines.push("", "**Goal → Criteria**:");
629
- for (const cs of state.criterionStatuses) {
630
- const icon = cs.status === "met" ? "✅" : cs.status === "remaining" ? "⬜" : "❔";
631
- const idTag = idEnabled ? ` [\`${cs.id}\`]` : "";
632
- const met = cs.met_at_round !== undefined ? `(met R${cs.met_at_round})` : "";
633
- const related = cs.related_subgoal_ids.length > 0
634
- ? ` [↔ ${cs.related_subgoal_ids.join(", ")}]`
635
- : "";
636
- lines.push(`- ${icon}${idTag} ${cs.text} ${met}${related}`);
637
- }
638
- }
639
- sections.push({
640
- id: "progress",
641
- text: section("Progress Dashboard", lines.join("\n")),
642
- mandatory: false,
643
- });
644
- }
645
- // v3.3: Per-round statistics — lets the agent calibrate round granularity
646
- // (files per round, rejected attempts, self-reported progress deltas).
647
- // v3.6: source-labeled — files/Δ are agent-reported; only rejected
648
- // attempts (and the Machine (git) row above) are machine-recorded.
649
- if (state.roundStats && state.roundStats.length > 0) {
650
- const statLines = state.roundStats.map((stat) => {
651
- const parts = [];
652
- if (stat.filesChangedCount !== null) {
653
- parts.push(`${stat.filesChangedCount} file${stat.filesChangedCount === 1 ? "" : "s"}`);
654
- }
655
- if (stat.rejectedAttempts !== null && stat.rejectedAttempts > 0) {
656
- parts.push(`${stat.rejectedAttempts} rejected attempt${stat.rejectedAttempts === 1 ? "" : "s"}`);
657
- }
658
- if (stat.progressDelta !== null) {
659
- parts.push(`Δ${stat.progressDelta >= 0 ? "+" : ""}${stat.progressDelta.toFixed(2)}`);
660
- }
661
- return `- R${stat.round}: ${parts.length > 0 ? parts.join(", ") : "no data"}`;
662
- });
663
- statLines.push("> files & Δ are agent-reported; rejected attempts are machine-recorded " +
664
- "(the Machine (git) row above is machine-observed)");
665
- sections.push({
666
- id: "round_stats",
667
- text: section("Round Stats", statLines.join("\n")),
668
- mandatory: false,
669
- });
670
- }
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.
671
458
  // Retired Constraints
672
459
  if (state.retiredConstraints.length > 0) {
673
460
  const lines = state.retiredConstraints.map((c) => `- ~${c}~ (retired)`);
@@ -677,86 +464,166 @@ function l2Sections(state, fullStateMarkdown) {
677
464
  mandatory: false,
678
465
  });
679
466
  }
680
- // Agent Trust Score + Trend
681
- if (state.agentTrustScore !== undefined) {
682
- const lines = [
683
- trustBarLine(state.agentTrustScore),
684
- ];
685
- if (state.agentTrustTrend.length > 0) {
686
- const avg = (state.agentTrustTrend.reduce((a, b) => a + b, 0)
687
- / state.agentTrustTrend.length).toFixed(2);
688
- lines.push(`Trend (last ${state.agentTrustTrend.length}): ${state.agentTrustTrend.join(" → ")}`);
689
- lines.push(`Average: ${avg}`);
690
- }
691
- sections.push({
692
- id: "agent_trust",
693
- text: section("Agent Trust", lines.join("\n")),
694
- mandatory: false,
695
- });
696
- }
697
467
  return sections;
698
468
  }
699
- function selectSections(input, emphasized) {
469
+ function selectSections(input) {
700
470
  if (input.level === "l0")
701
471
  return l0Sections(input.state);
702
- if (input.level === "l1") {
703
- return l1Sections(input.state, input.presentedBaseline ?? null, emphasized);
704
- }
472
+ if (input.level === "l1")
473
+ return l1Sections(input.state);
705
474
  return l2Sections(input.state, input.fullStateMarkdown);
706
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
+ }
707
528
  function renderWithinBudget(sections, fixedText, budget) {
708
- const mandatory = sections.filter((item) => item.mandatory);
709
- const optional = sections.filter((item) => !item.mandatory);
710
- let rendered = fixedText + mandatory.map((item) => item.text).join("");
711
- const included = mandatory.filter((item) => item.text).map((item) => item.id);
712
- for (const item of optional) {
713
- 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)
714
541
  continue;
715
- if (rendered.length + item.text.length <= budget) {
716
- rendered += item.text;
717
- 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();
718
583
  }
719
584
  }
720
- return { rendered, included };
585
+ return { rendered, included, dropped, protectedOverflow };
721
586
  }
722
587
  // ═══════════════════════════════════════════════════════════════════════════
723
588
  // v2.9: Prompt Requests — model-expressed information needs
724
589
  // ═══════════════════════════════════════════════════════════════════════════
725
- /** Match a free-text emphasis target against state items.
726
- * v2.11: ID-first matching. When a target is an ID (c-/cr-/sg-XXXXXXXX),
727
- * candidates that derive to the same ID get score 1.0. Falls back to
728
- * 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. */
729
597
  function matchEmphasize(targets, candidates, cap) {
730
598
  if (targets.length === 0 || candidates.length === 0)
731
599
  return [];
732
- const scored = candidates.map((c) => {
733
- let best = 0;
734
- for (const t of targets) {
735
- let s = 0;
736
- // Phase 1: exact ID match (v2.11)
737
- 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);
738
605
  if (idMatch) {
739
- const candidateId = idMatch[1] + "-" + deriveItemId(c);
740
- if (candidateId === t)
741
- s = 1.0;
606
+ return `${idMatch[1]}-${deriveItemId(candidate)}` === target;
742
607
  }
743
- // Phase 2: Jaccard fallback
744
- if (s === 0)
745
- s = jaccardSimilarity(t, c);
746
- if (s > best)
747
- best = s;
748
- }
749
- return { text: c, score: best };
750
- });
751
- return scored
752
- .filter((e) => e.score >= getPolicy().evolution.constraint_match_threshold)
753
- .sort((a, b) => b.score - a.score)
754
- .slice(0, cap)
755
- .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);
756
615
  }
757
616
  /** Render the "Confusion Alerts" section that appears at the prompt top.
758
- * Compiler auto-matches each confusion point against state sections. */
759
- 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) {
760
627
  if (points.length === 0)
761
628
  return "";
762
629
  const lines = [
@@ -766,16 +633,6 @@ function renderConfusionAlerts(points, state, fullDensity) {
766
633
  "Address each before proceeding — the state file has full context.",
767
634
  "",
768
635
  ];
769
- const sections = [
770
- { keyword: "milestone", label: "Phase History", ref: "## Phase History" },
771
- { keyword: "sub-goal", label: "Sub-Goal Dashboard", ref: "## Sub-Goal Dashboard" },
772
- { keyword: "constraint", label: "Active Constraints / Constraint Lifecycle", ref: "## Active Constraints" },
773
- { keyword: "trust", label: "Agent Trust", ref: "## Agent Trust" },
774
- { keyword: "progress", label: "Progress Dashboard", ref: "## Progress Dashboard" },
775
- { keyword: "criteria", label: "Success Criteria", ref: "## Success Criteria" },
776
- { keyword: "phase", label: "Phase History", ref: "## Phase History" },
777
- { keyword: "objective", label: "Loop Objective", ref: "## Loop Objective" },
778
- ];
779
636
  // v2.14: L2 renders up to the policy cap (was: every confusion point,
780
637
  // unbounded by max_confusion_points); L1 keeps a single alert.
781
638
  const shown = fullDensity
@@ -783,21 +640,8 @@ function renderConfusionAlerts(points, state, fullDensity) {
783
640
  : points.slice(0, 1);
784
641
  for (const point of shown) {
785
642
  const truncated = point.length > 200 ? point.slice(0, 197) + "…" : point;
786
- // Find best-matching state section
787
- let bestSection = null;
788
- let bestScore = 0;
789
- for (const sec of sections) {
790
- const score = jaccardSimilarity(truncated.toLowerCase(), sec.keyword);
791
- if (score > bestScore) {
792
- bestScore = score;
793
- bestSection = sec;
794
- }
795
- }
796
- const pointer = bestSection && bestScore >= getPolicy().prompt.confusion_section_threshold
797
- ? `→ Check **${bestSection.label}** (\`${bestSection.ref}\`) in the state file.`
798
- : "→ Read the full state file for relevant context.";
799
643
  lines.push(`- **"${truncated}"**`);
800
- lines.push(` ${pointer}`);
644
+ lines.push(" → Read the full state file for relevant context.");
801
645
  lines.push("");
802
646
  }
803
647
  if (!fullDensity && points.length > 1) {
@@ -808,7 +652,7 @@ function renderConfusionAlerts(points, state, fullDensity) {
808
652
  }
809
653
  /** Render the "Critical Context" section — items the model emphasized.
810
654
  * Pulled from active constraints, discoveries, and blocking issues. */
811
- function renderCriticalContext(emphasized, state) {
655
+ function renderCriticalContext(emphasized) {
812
656
  if (emphasized.length === 0)
813
657
  return "";
814
658
  const lines = [
@@ -829,14 +673,13 @@ export function assemblePromptArtifact(input) {
829
673
  const stateHash = hashCanonicalState(input.state);
830
674
  const attempt = Math.max(1, input.attempt ?? 1);
831
675
  // ── State file reference + conditional read instruction ────────────
832
- // L2 rounds (full rehydration), recovery, rejection, and drift all
833
- // benefit from the model reading the durable state file rather than
834
- // 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.
835
679
  const fullContextReasons = new Set([
836
680
  "first_round", "plan_boundary", "checkpoint_boundary", "goal_changed",
837
681
  "missing_previous_state", "verification_contradicted",
838
- "rejection_rehydrate", "recovery_boundary", "state_drift",
839
- "periodic_refresh",
682
+ "rejection_rehydrate", "recovery_boundary",
840
683
  ]);
841
684
  const needsFullContext = input.level === "l2"
842
685
  || input.reasons.some((r) => fullContextReasons.has(r));
@@ -888,8 +731,8 @@ export function assemblePromptArtifact(input) {
888
731
  "- `should_continue`: Set to **`false` ONLY** when the ENTIRE loop task is done.",
889
732
  " Partial progress or completed subtasks → `true`.",
890
733
  "- `constraint_violations`: Be honest. List every constraint you actually violated.",
891
- "- `execution_evidence.files_changed`: List files you modified — used for verification.",
892
- "- `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.",
893
736
  "- `progress_estimate`: A number 0.0–1.0 reflecting overall task completion.",
894
737
  "",
895
738
  "### If the Prompt Says \"REJECTED\"",
@@ -904,11 +747,10 @@ export function assemblePromptArtifact(input) {
904
747
  // ── v2.9: Prompt Requests — model-expressed information needs ──────────
905
748
  const pr = input.promptRequests;
906
749
  const levelIsL0 = input.level === "l0";
907
- const levelIsL1 = input.level === "l1";
908
750
  const levelIsL2 = input.level === "l2";
909
751
  // Confusion alerts: L1/L2 only, rendered at the top (before mandatory sections)
910
752
  const confusionText = (!levelIsL0 && pr?.confusion_points?.length)
911
- ? renderConfusionAlerts(pr.confusion_points, input.state, levelIsL2)
753
+ ? renderConfusionAlerts(pr.confusion_points, levelIsL2)
912
754
  : "";
913
755
  // Emphasize: L1/L2 only, matched against active state items
914
756
  const policy = getPolicy().prompt;
@@ -942,24 +784,13 @@ export function assemblePromptArtifact(input) {
942
784
  changesSinceLastRound: input.state.changesSinceLastRound.filter((item) => !emphasizedSet.has(item)),
943
785
  }
944
786
  : input.state;
945
- const sections = selectSections({ ...input, state: stateForSections }, emphasizedSet);
946
- const criticalContextText = renderCriticalContext(emphasized, input.state);
947
- // v3.2: L1 only — snapshot what this prompt actually presented, so the
948
- // NEXT L1 compile can diff against it (persisted via PromptArtifact →
949
- // lineage). L0/L2 leave it undefined: their presentation semantics differ
950
- // (L2's dashboard includes done/canceled sub-goals, which would
951
- // contaminate the L1 active-set diff).
952
- let presentedState;
953
- if (levelIsL1) {
954
- const activeSubsForSnapshot = stateForSections.subGoals.filter((sg) => sg.status === "in_progress" || sg.status === "pending");
955
- presentedState = {
956
- round: input.state.round,
957
- constraintIds: activeNonHardConstraints(stateForSections)
958
- .map((text) => constraintIdOf(text, stateForSections.constraintMetadata)),
959
- subGoals: activeSubsForSnapshot.map((sg) => [sg.id, sg.status]),
960
- milestoneRanges: input.state.milestones.map((m) => [m.round_range.start, m.round_range.end]),
961
- };
962
- }
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.
963
794
  // Inject confusion alerts into the header area (before mandatory sections)
964
795
  const fixedText = header + confusionText;
965
796
  // Inject critical context as a mandatory section
@@ -974,18 +805,27 @@ export function assemblePromptArtifact(input) {
974
805
  ...sections,
975
806
  ];
976
807
  }
977
- const selected = renderWithinBudget(augmentedSections, fixedText, budget - footer.length);
808
+ const sectionBudget = budget - footer.length;
809
+ const selected = renderWithinBudget(augmentedSections, fixedText, sectionBudget);
978
810
  const renderedPrompt = selected.rendered + footer;
979
811
  const promptHash = createHash("sha256").update(renderedPrompt).digest("hex");
980
812
  return {
981
813
  schemaVersion: PROMPT_ARTIFACT_SCHEMA_VERSION,
982
814
  roundId: `loop:${input.state.loopId}:round:${input.state.round}`,
815
+ round: input.state.round,
983
816
  attempt,
984
817
  level: input.level,
985
818
  renderedPrompt,
986
819
  promptHash,
987
820
  stateHash,
988
- ...(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,
989
829
  };
990
830
  }
991
831
  //# sourceMappingURL=prompt-assembler.js.map