loopforge 3.0.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +126 -143
  2. package/dist/audit.d.ts +50 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +131 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/canonical-state.d.ts +98 -23
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +257 -181
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +40 -33
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts +17 -0
  14. package/dist/cognitive-facts.d.ts.map +1 -0
  15. package/dist/cognitive-facts.js +99 -0
  16. package/dist/cognitive-facts.js.map +1 -0
  17. package/dist/cognitive-governance.d.ts +25 -33
  18. package/dist/cognitive-governance.d.ts.map +1 -1
  19. package/dist/cognitive-governance.js +38 -138
  20. package/dist/cognitive-governance.js.map +1 -1
  21. package/dist/committed-round.d.ts +59 -0
  22. package/dist/committed-round.d.ts.map +1 -0
  23. package/dist/committed-round.js +222 -0
  24. package/dist/committed-round.js.map +1 -0
  25. package/dist/enforcement-gate.d.ts +78 -0
  26. package/dist/enforcement-gate.d.ts.map +1 -0
  27. package/dist/enforcement-gate.js +1109 -0
  28. package/dist/enforcement-gate.js.map +1 -0
  29. package/dist/engine.d.ts +111 -0
  30. package/dist/engine.d.ts.map +1 -0
  31. package/dist/engine.js +887 -0
  32. package/dist/engine.js.map +1 -0
  33. package/dist/evidence-claims.d.ts +50 -0
  34. package/dist/evidence-claims.d.ts.map +1 -0
  35. package/dist/evidence-claims.js +116 -0
  36. package/dist/evidence-claims.js.map +1 -0
  37. package/dist/evidence-provider.d.ts +50 -23
  38. package/dist/evidence-provider.d.ts.map +1 -1
  39. package/dist/evidence-provider.js +167 -50
  40. package/dist/evidence-provider.js.map +1 -1
  41. package/dist/generate-schema.js +1 -84
  42. package/dist/generate-schema.js.map +1 -1
  43. package/dist/index.d.ts +19 -22
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +16 -15
  46. package/dist/index.js.map +1 -1
  47. package/dist/loop-compiler.d.ts +58 -3
  48. package/dist/loop-compiler.d.ts.map +1 -1
  49. package/dist/loop-compiler.js +841 -304
  50. package/dist/loop-compiler.js.map +1 -1
  51. package/dist/loop-extras-parser.d.ts +2 -2
  52. package/dist/loop-extras-parser.d.ts.map +1 -1
  53. package/dist/loop-extras-parser.js +2 -2
  54. package/dist/loop-extras-parser.js.map +1 -1
  55. package/dist/loop-projection.d.ts +6 -0
  56. package/dist/loop-projection.d.ts.map +1 -0
  57. package/dist/loop-projection.js +26 -0
  58. package/dist/loop-projection.js.map +1 -0
  59. package/dist/loop-store.d.ts +115 -24
  60. package/dist/loop-store.d.ts.map +1 -1
  61. package/dist/loop-store.js +365 -218
  62. package/dist/loop-store.js.map +1 -1
  63. package/dist/mcp/round-lifecycle.d.ts +214 -0
  64. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  65. package/dist/mcp/round-lifecycle.js +935 -0
  66. package/dist/mcp/round-lifecycle.js.map +1 -0
  67. package/dist/mcp/server.d.ts +2 -1
  68. package/dist/mcp/server.d.ts.map +1 -1
  69. package/dist/mcp/server.js +29 -43
  70. package/dist/mcp/server.js.map +1 -1
  71. package/dist/mcp/session.d.ts +64 -101
  72. package/dist/mcp/session.d.ts.map +1 -1
  73. package/dist/mcp/session.js +433 -1110
  74. package/dist/mcp/session.js.map +1 -1
  75. package/dist/mcp/tools.d.ts +20 -11
  76. package/dist/mcp/tools.d.ts.map +1 -1
  77. package/dist/mcp/tools.js +587 -413
  78. package/dist/mcp/tools.js.map +1 -1
  79. package/dist/policy-metrics.d.ts +12 -24
  80. package/dist/policy-metrics.d.ts.map +1 -1
  81. package/dist/policy-metrics.js +78 -184
  82. package/dist/policy-metrics.js.map +1 -1
  83. package/dist/policy.d.ts +61 -50
  84. package/dist/policy.d.ts.map +1 -1
  85. package/dist/policy.js +48 -146
  86. package/dist/policy.js.map +1 -1
  87. package/dist/prompt-assembler.d.ts +37 -6
  88. package/dist/prompt-assembler.d.ts.map +1 -1
  89. package/dist/prompt-assembler.js +487 -278
  90. package/dist/prompt-assembler.js.map +1 -1
  91. package/dist/prompt-policy.d.ts +1 -8
  92. package/dist/prompt-policy.d.ts.map +1 -1
  93. package/dist/prompt-policy.js +8 -26
  94. package/dist/prompt-policy.js.map +1 -1
  95. package/dist/protocol.d.ts +398 -236
  96. package/dist/protocol.d.ts.map +1 -1
  97. package/dist/protocol.js +147 -17
  98. package/dist/protocol.js.map +1 -1
  99. package/dist/replay.d.ts +5 -13
  100. package/dist/replay.d.ts.map +1 -1
  101. package/dist/replay.js +32 -69
  102. package/dist/replay.js.map +1 -1
  103. package/dist/round-contract.d.ts +64 -0
  104. package/dist/round-contract.d.ts.map +1 -0
  105. package/dist/round-contract.js +80 -0
  106. package/dist/round-contract.js.map +1 -0
  107. package/dist/round-coordinator.d.ts +23 -25
  108. package/dist/round-coordinator.d.ts.map +1 -1
  109. package/dist/round-coordinator.js +103 -92
  110. package/dist/round-coordinator.js.map +1 -1
  111. package/dist/round-driver.d.ts +13 -13
  112. package/dist/round-driver.d.ts.map +1 -1
  113. package/dist/round-driver.js +25 -14
  114. package/dist/round-driver.js.map +1 -1
  115. package/dist/round-transaction.d.ts +14 -18
  116. package/dist/round-transaction.d.ts.map +1 -1
  117. package/dist/round-transaction.js +37 -47
  118. package/dist/round-transaction.js.map +1 -1
  119. package/dist/self-eval.d.ts +48 -0
  120. package/dist/self-eval.d.ts.map +1 -0
  121. package/dist/self-eval.js +248 -0
  122. package/dist/self-eval.js.map +1 -0
  123. package/dist/storage.d.ts +9 -4
  124. package/dist/storage.d.ts.map +1 -1
  125. package/dist/storage.js +18 -6
  126. package/dist/storage.js.map +1 -1
  127. package/dist/token-utils.d.ts +15 -3
  128. package/dist/token-utils.d.ts.map +1 -1
  129. package/dist/token-utils.js +24 -5
  130. package/dist/token-utils.js.map +1 -1
  131. package/dist/verification-gate.d.ts +149 -0
  132. package/dist/verification-gate.d.ts.map +1 -0
  133. package/dist/verification-gate.js +1343 -0
  134. package/dist/verification-gate.js.map +1 -0
  135. package/dist/workspace.d.ts +29 -0
  136. package/dist/workspace.d.ts.map +1 -0
  137. package/dist/workspace.js +61 -0
  138. package/dist/workspace.js.map +1 -0
  139. package/loop_policy.json +37 -16
  140. package/package.json +5 -10
  141. package/skills/perception/SKILL.md +184 -202
  142. package/dist/canonical-event-writer.d.ts +0 -20
  143. package/dist/canonical-event-writer.d.ts.map +0 -1
  144. package/dist/canonical-event-writer.js +0 -131
  145. package/dist/canonical-event-writer.js.map +0 -1
  146. package/dist/compiler-subgoals.d.ts +0 -7
  147. package/dist/compiler-subgoals.d.ts.map +0 -1
  148. package/dist/compiler-subgoals.js +0 -93
  149. package/dist/compiler-subgoals.js.map +0 -1
  150. package/dist/derive-id.d.ts +0 -12
  151. package/dist/derive-id.d.ts.map +0 -1
  152. package/dist/derive-id.js +0 -31
  153. package/dist/derive-id.js.map +0 -1
  154. package/dist/mcp/session-projection.d.ts +0 -5
  155. package/dist/mcp/session-projection.d.ts.map +0 -1
  156. package/dist/mcp/session-projection.js +0 -18
  157. package/dist/mcp/session-projection.js.map +0 -1
  158. package/dist/mcp/session-queue.d.ts +0 -3
  159. package/dist/mcp/session-queue.d.ts.map +0 -1
  160. package/dist/mcp/session-queue.js +0 -18
  161. package/dist/mcp/session-queue.js.map +0 -1
  162. package/dist/mcp/session-types.d.ts +0 -72
  163. package/dist/mcp/session-types.d.ts.map +0 -1
  164. package/dist/mcp/session-types.js +0 -2
  165. package/dist/mcp/session-types.js.map +0 -1
  166. package/dist/round-commit-store.d.ts +0 -12
  167. package/dist/round-commit-store.d.ts.map +0 -1
  168. package/dist/round-commit-store.js +0 -13
  169. package/dist/round-commit-store.js.map +0 -1
  170. package/dist/round-gate.d.ts +0 -31
  171. package/dist/round-gate.d.ts.map +0 -1
  172. package/dist/round-gate.js +0 -259
  173. package/dist/round-gate.js.map +0 -1
  174. package/dist/round-recovery.d.ts +0 -17
  175. package/dist/round-recovery.d.ts.map +0 -1
  176. package/dist/round-recovery.js +0 -117
  177. package/dist/round-recovery.js.map +0 -1
  178. package/dist/round-report.d.ts +0 -16
  179. package/dist/round-report.d.ts.map +0 -1
  180. package/dist/round-report.js +0 -401
  181. package/dist/round-report.js.map +0 -1
  182. package/dist/runtime-services.d.ts +0 -59
  183. package/dist/runtime-services.d.ts.map +0 -1
  184. package/dist/runtime-services.js +0 -460
  185. package/dist/runtime-services.js.map +0 -1
  186. package/dist/vault-format.d.ts +0 -4
  187. package/dist/vault-format.d.ts.map +0 -1
  188. package/dist/vault-format.js +0 -4
  189. package/dist/vault-format.js.map +0 -1
  190. package/dist/vault-types.d.ts +0 -24
  191. package/dist/vault-types.d.ts.map +0 -1
  192. package/dist/vault-types.js +0 -2
  193. package/dist/vault-types.js.map +0 -1
  194. package/loopforge-policy.json +0 -340
@@ -5,12 +5,11 @@
5
5
  * are never truncated; budgets are soft and overflow is recorded.
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
- import { deriveItemId } from "./derive-id.js";
9
- import { hashCanonicalState } from "./canonical-state.js";
10
- import { jaccardSimilarity } from "./token-utils.js";
8
+ import { buildRoadmap, hashCanonicalState, milestoneHeading, trustBarLine } from "./canonical-state.js";
9
+ import { deriveItemId, STABLE_ID_RE, jaccardSimilarity } from "./token-utils.js";
11
10
  import { getPolicy } from "./policy.js";
12
- export const PROMPT_ARTIFACT_FORMAT = "loopforge.prompt";
13
- export const BASE_PROMPT_VERSION = "stable";
11
+ export const PROMPT_ARTIFACT_SCHEMA_VERSION = 1;
12
+ export const BASE_PROMPT_VERSION = "2.0.0";
14
13
  export const DEFAULT_PROMPT_BUDGETS = {
15
14
  l0: 3000,
16
15
  l1: 7000,
@@ -31,22 +30,97 @@ function bulletsWithIds(values, prefix) {
31
30
  const idEnabled = getPolicy().evolution.constraint_id_enabled;
32
31
  if (!idEnabled)
33
32
  return bullets(values);
34
- const idRegex = /^(c|cr|sg)-[a-f0-9]{8}$/;
35
33
  return values.map((v) => {
36
34
  const trimmed = v.trim();
37
- if (idRegex.test(trimmed)) {
35
+ if (STABLE_ID_RE.test(trimmed)) {
38
36
  return `- [\`${trimmed}\`] ${trimmed}`;
39
37
  }
40
38
  const id = prefix + "-" + deriveItemId(trimmed);
41
39
  return `- [\`${id}\`] ${trimmed}`;
42
40
  }).join("\n");
43
41
  }
44
- function verificationText(state) {
42
+ /** v3.3.1: Render an identity-HETEROGENEOUS list — the merged active
43
+ * constraint set holds hard/plan/criteria/discovered texts together, so no
44
+ * single prefix fits. Each item's authoritative ID comes from constraint
45
+ * metadata (criteria texts carry cr-XXXXXXXX, everything else
46
+ * c-XXXXXXXX), keeping the active list in the same identity namespace as
47
+ * the item's own section — one text never renders under two prefixes in
48
+ * one prompt. Falls back to a c- derivation only when metadata is missing
49
+ * (defensive: metadata covers every active text by construction). */
50
+ function bulletsWithIdentities(values, metadata) {
51
+ const idEnabled = getPolicy().evolution.constraint_id_enabled;
52
+ if (!idEnabled)
53
+ return bullets(values);
54
+ const idByText = new Map(metadata.map((meta) => [meta.text, meta.id]));
55
+ return values.map((v) => {
56
+ const trimmed = v.trim();
57
+ if (STABLE_ID_RE.test(trimmed)) {
58
+ return `- [\`${trimmed}\`] ${trimmed}`;
59
+ }
60
+ const id = idByText.get(trimmed) ?? `c-${deriveItemId(trimmed)}`;
61
+ return `- [\`${id}\`] ${trimmed}`;
62
+ }).join("\n");
63
+ }
64
+ /** v3.2: Short imperative fix/action per verification check, appended to the
65
+ * L1/L2 Verification Gate lines. Deliberately shorter than the rejection-
66
+ * prompt wording in enforcement-gate.ts; keys are the plain check-name
67
+ * strings (NOT imported from verification-gate.ts — it already imports
68
+ * loop-compiler, which imports this module, so importing back would cycle).
69
+ * Full coverage of every CHECK_* constant is enforced by a test that
70
+ * iterates the verification-gate module's CHECK_ exports (no hardcoded
71
+ * list — a new constant can never silently lack an action entry). */
72
+ const VERIFICATION_ACTIONS = {
73
+ progress_regression: "Correct progress_estimate or explain the change in output_summary.",
74
+ empty_change_with_passing: "Report the actual files_changed, or set success=false with the real state.",
75
+ success_with_remaining_criteria: "Complete the remaining criteria before claiming success, or set success=false and report what remains.",
76
+ success_without_verified_evidence: "Back the success claim with machine-verifiable evidence: run tests or a required verification command and report the actual output.",
77
+ outcome_success_contradiction: "Reconcile the declared outcome with the success flag and resubmit an honest evaluation.",
78
+ success_claim_conflict: "Align the success claim with your own evaluation data before resubmitting.",
79
+ blocked_without_blocker: "State the concrete blocker, or drop the blocked outcome.",
80
+ retroactive_claim_bad_round: "Correct the referenced round number, or withdraw the retroactive claim.",
81
+ retroactive_claim_unverified: "Provide evidence for the retroactive claim, or withdraw it.",
82
+ duplicate_constraint_discovery: "Update the existing constraint entry instead of duplicating it.",
83
+ recurring_violation: "Explain why this violation keeps recurring and try a different approach — do not repeat the failed strategy.",
84
+ retract_fresh_constraint: "Justify the retraction explicitly, or restore the constraint.",
85
+ evidence_integrity: "Correct the mismatch between your claims and the recorded evidence.",
86
+ required_command_failed: "Re-run the required verification command and report its actual output before claiming success.",
87
+ command_evidence_mismatch: "Report the true command output and reconcile the mismatch.",
88
+ intent_drift: "Explain the pivot in drift_clarification with concrete references, or redo the round as promised.",
89
+ subgoal_drift: "Reconcile the sub-goal list with your reported work before resubmitting.",
90
+ backtrack_workspace_not_restored: "Restore the skipped-round files and the expected git HEAD, then resubmit.",
91
+ criteria_claims_unverified: "Provide evidence for each claimed success criterion.",
92
+ // v3.3 — Round Contract checks (and the three v3.3 checks that predated
93
+ // this table entry and fell back to the generic action).
94
+ verification_entrypoint_modified: "Re-run the verification command on the changed entrypoint, or revert the entrypoint change and re-run.",
95
+ test_files_modified: "Re-run the verification command after the test-file changes and report the fresh output.",
96
+ round_underspecified: "Declare this round's contract with a non-empty done_when list — what must be true when the round is done?",
97
+ round_unverifiable: "Name only configured, enabled evidence.commands in verification_plan, or drop done_when items that cannot be machine-verified.",
98
+ round_scope_drift: "Revert the out-of-scope file changes, or extend the contract scope to cover them and explain why in drift_clarification.",
99
+ 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).",
100
+ // v3.5 — contract completion machine-backing + premature-replacement warn
101
+ 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).",
102
+ 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.",
103
+ };
104
+ /** Fallback for unmapped check names (forward-compat with future checks). */
105
+ const VERIFICATION_ACTION_FALLBACK = "Re-examine the flagged claim and correct your evaluation before resubmitting.";
106
+ /** v3.2: The actionable instruction for a verification check. Exported for
107
+ * the coverage test (which imports the CHECK_* constants). */
108
+ export function verificationActionFor(check) {
109
+ return VERIFICATION_ACTIONS[check] ?? VERIFICATION_ACTION_FALLBACK;
110
+ }
111
+ function verificationText(state, level) {
45
112
  const lines = state.verificationFlags.map((flag) => {
46
113
  const icon = flag.severity === "error"
47
114
  ? "🚫"
48
115
  : flag.severity === "warn" ? "⚠️" : "ℹ️";
49
- return `- ${icon} [${flag.check}] ${flag.detail}`;
116
+ const base = `- ${icon} [${flag.check}] ${flag.detail}`;
117
+ // v3.2: error/warn flags get an actionable continuation line in L1/L2 —
118
+ // the verdict is translated into what to DO. L0 keeps the compact retry
119
+ // prompt byte-identical; info flags stay informational.
120
+ if (level === "l0" || flag.severity === "info")
121
+ return base;
122
+ const label = flag.severity === "error" ? "Fix" : "Action";
123
+ return `${base}\n → ${label}: ${verificationActionFor(flag.check)}`;
50
124
  });
51
125
  if (state.verificationFlags.some((flag) => flag.severity === "error")) {
52
126
  lines.push("- Gate Verdict: CONTRADICTED — resolve every error before claiming success.");
@@ -57,6 +131,81 @@ function activeNonHardConstraints(state) {
57
131
  const hard = new Set(state.hardConstraints);
58
132
  return state.activeConstraints.filter((value) => !hard.has(value));
59
133
  }
134
+ // ═══════════════════════════════════════════════════════════════════════════
135
+ // v3.2: L1 diff-collapse helpers
136
+ // ═══════════════════════════════════════════════════════════════════════════
137
+ /** v3.2: Collapse only when at least this many items are unchanged — a tiny
138
+ * unchanged set renders in full (nothing worth saving). */
139
+ export const COLLAPSE_MIN_UNCHANGED = 3;
140
+ /** v3.2: Recent-rounds items kept in full when older ones collapse. */
141
+ export const KEEP_RECENT_ROUNDS = 3;
142
+ /** v3.2: Stable ID of a constraint text as rendered by bulletsWithIds —
143
+ * metadata's own id when present, else the derived hash. */
144
+ function constraintIdOf(text, metadata) {
145
+ const meta = metadata.find((m) => m.text === text);
146
+ return meta?.id ?? `c-${deriveItemId(text)}`;
147
+ }
148
+ export function diffConstraints(baseline, activeTexts, metadata, round, emphasized) {
149
+ if (!baseline)
150
+ return { changed: activeTexts, unchangedCount: 0, removedCount: 0 };
151
+ const baselineIds = new Set(baseline.constraintIds);
152
+ const currentIds = new Set(activeTexts.map((text) => constraintIdOf(text, metadata)));
153
+ const changed = [];
154
+ let unchangedCount = 0;
155
+ for (const text of activeTexts) {
156
+ const id = constraintIdOf(text, metadata);
157
+ const violatedThisRound = metadata.find((m) => m.text === text)
158
+ ?.last_violated_at_round === round;
159
+ if (!baselineIds.has(id) || violatedThisRound)
160
+ changed.push(text);
161
+ else
162
+ unchangedCount++;
163
+ }
164
+ let removedCount = 0;
165
+ if (emphasized.size > 0) {
166
+ const emphasizedIds = new Set([...emphasized].map((text) => constraintIdOf(text, metadata)));
167
+ for (const id of baselineIds) {
168
+ if (!currentIds.has(id) && !emphasizedIds.has(id))
169
+ removedCount++;
170
+ }
171
+ }
172
+ else {
173
+ for (const id of baselineIds) {
174
+ if (!currentIds.has(id))
175
+ removedCount++;
176
+ }
177
+ }
178
+ return { changed, unchangedCount, removedCount };
179
+ }
180
+ export function diffSubGoals(baseline, activeSubs) {
181
+ if (!baseline)
182
+ return { changed: activeSubs, unchangedCount: 0, removedCount: 0 };
183
+ const baselineById = new Map(baseline.subGoals);
184
+ const currentIds = new Set(activeSubs.map((sg) => sg.id));
185
+ const changed = [];
186
+ let unchangedCount = 0;
187
+ for (const sg of activeSubs) {
188
+ const prevStatus = baselineById.get(sg.id);
189
+ if (prevStatus === undefined || prevStatus !== sg.status)
190
+ changed.push(sg);
191
+ else
192
+ unchangedCount++;
193
+ }
194
+ let removedCount = 0;
195
+ for (const [id] of baseline.subGoals) {
196
+ if (!currentIds.has(id))
197
+ removedCount++;
198
+ }
199
+ return { changed, unchangedCount, removedCount };
200
+ }
201
+ /** v3.2: True when a milestone boundary was crossed since the baseline —
202
+ * the Recent Rounds section then renders in full (a phase ended). */
203
+ export function milestoneBoundaryChanged(baseline, milestones) {
204
+ if (!baseline)
205
+ return false;
206
+ const baselineRanges = new Set(baseline.milestoneRanges.map(([s, e]) => `${s}:${e}`));
207
+ return milestones.some((m) => !baselineRanges.has(`${m.round_range.start}:${m.round_range.end}`));
208
+ }
60
209
  function commonMandatorySections(state,
61
210
  /** v2.11: L0 skips ID rendering to stay within tight budget. */
62
211
  level) {
@@ -66,41 +215,12 @@ level) {
66
215
  text: section("Objective", state.objective),
67
216
  mandatory: true,
68
217
  },
69
- ];
70
- const phase = state.phaseHistory.at(-1);
71
- if (phase) {
72
- sections.push({
73
- id: "current_phase",
74
- text: section("Current Phase", `${phase.label} (${phase.status})\n${phase.outcome}`),
75
- mandatory: false,
76
- });
77
- }
78
- const focus = state.currentFocus || state.objective;
79
- if (focus.trim()) {
80
- sections.push({
81
- id: "current_focus",
82
- text: section("Current Focus", focus),
218
+ {
219
+ id: "current_task",
220
+ text: section("Current Task", state.currentTask),
83
221
  mandatory: true,
84
- });
85
- }
86
- if (state.todoProjection) {
87
- sections.push({
88
- id: "todo_projection",
89
- text: section("Active Todo Projection", `${state.todoProjection.action}\nCompletion: ${state.todoProjection.completionSignal}`),
90
- mandatory: false,
91
- });
92
- }
93
- sections.push({
94
- id: "verification",
95
- text: section("Verification Gate", verificationText(state) || "No verification contradiction is currently open."),
96
- mandatory: true,
97
- });
98
- if (state.activeGate) {
99
- const body = state.activeGate.kind === "user"
100
- ? [`Owner: user`, `Question: ${state.activeGate.question}`, `Blocked scope: ${state.activeGate.blockedScope.join("; ")}`, `Safe work: ${state.activeGate.allowedWork.join("; ")}`].join("\n")
101
- : [`Owner: agent`, `Problem: ${state.activeGate.problem}`, `Required evidence: ${state.activeGate.requiredEvidence.join("; ")}`, `Resolution: ${state.activeGate.suggestedResolution}`].join("\n");
102
- sections.push({ id: "active_gate", text: section("Active Gate", body), mandatory: true });
103
- }
222
+ },
223
+ ];
104
224
  if (state.hardConstraints.length > 0) {
105
225
  // v2.11: L0 uses plain bullets (tight budget); L1/L2 show constraint IDs
106
226
  const body = level === "l0"
@@ -112,6 +232,13 @@ level) {
112
232
  mandatory: true,
113
233
  });
114
234
  }
235
+ if (state.verificationFlags.length > 0) {
236
+ sections.push({
237
+ id: "verification",
238
+ text: section("Verification Gate", verificationText(state, level)),
239
+ mandatory: true,
240
+ });
241
+ }
115
242
  return sections;
116
243
  }
117
244
  function l0Sections(state) {
@@ -136,17 +263,10 @@ function l0Sections(state) {
136
263
  }
137
264
  return sections;
138
265
  }
139
- function l1Sections(state) {
266
+ function l1Sections(state, baseline, emphasized) {
140
267
  const sections = commonMandatorySections(state, "l1");
141
- const openRegression = state.regressionObligations.filter((obligation) => obligation.status !== "satisfied");
142
- if (openRegression.length > 0) {
143
- sections.push({
144
- id: "regression_obligations",
145
- text: section("Regression Obligations", bullets(openRegression.map((item) => `[${item.status}] ${item.description}`))),
146
- mandatory: true,
147
- });
148
- }
149
268
  const active = activeNonHardConstraints(state);
269
+ const collapseEnabled = getPolicy().prompt.l1_collapse_enabled;
150
270
  if (state.changesSinceLastRound.length > 0) {
151
271
  sections.push({
152
272
  id: "changes",
@@ -154,12 +274,36 @@ function l1Sections(state) {
154
274
  mandatory: false,
155
275
  });
156
276
  }
277
+ // v3.2: Active Constraints — collapse unchanged items against the previous
278
+ // round's presentation; new and violated-this-round constraints render in
279
+ // full. The collapse line points at the state file (regenerated every
280
+ // round), so no information is lost — only presentation is compacted.
157
281
  if (active.length > 0) {
158
- sections.push({
159
- id: "active_constraints",
160
- text: section("Active Constraints / Success Criteria", bulletsWithIds(active, "c")),
161
- mandatory: false,
162
- });
282
+ const diff = diffConstraints(baseline, active, state.constraintMetadata, state.round, emphasized);
283
+ const renderFull = !collapseEnabled || !baseline
284
+ || diff.unchangedCount < COLLAPSE_MIN_UNCHANGED;
285
+ if (renderFull) {
286
+ sections.push({
287
+ id: "active_constraints",
288
+ text: section("Active Constraints / Success Criteria", bulletsWithIdentities(active, state.constraintMetadata)),
289
+ mandatory: false,
290
+ });
291
+ }
292
+ else {
293
+ const lines = diff.changed.map((text) => {
294
+ const id = constraintIdOf(text, state.constraintMetadata);
295
+ const violated = state.constraintMetadata.find((m) => m.text === text)
296
+ ?.last_violated_at_round === state.round;
297
+ const suffix = violated ? " (violated this round)" : " (new this round)";
298
+ return `- [\`${id}\`] ${text}${suffix}`;
299
+ });
300
+ lines.push(`- … ${diff.unchangedCount} unchanged constraints, ${diff.removedCount} demoted since R${baseline.round} (see state file)`);
301
+ sections.push({
302
+ id: "active_constraints",
303
+ text: section("Active Constraints / Success Criteria", lines.join("\n")),
304
+ mandatory: false,
305
+ });
306
+ }
163
307
  }
164
308
  if (state.remainingCriteria.length > 0) {
165
309
  sections.push({
@@ -172,32 +316,47 @@ function l1Sections(state) {
172
316
  sections.push({
173
317
  id: "blockers",
174
318
  text: section("Blockers", bullets(state.blockers)),
175
- mandatory: true,
176
- });
177
- }
178
- if (state.handoff.verifiedFacts.length > 0) {
179
- sections.push({
180
- id: "verified_facts",
181
- text: section("Latest Verified Facts", bulletsWithIds(state.handoff.verifiedFacts, "cr")),
182
- mandatory: true,
319
+ mandatory: false,
183
320
  });
184
321
  }
185
- // v2.2: Sub-Goal Dashboard (compact — L1)
322
+ // v2.2: Sub-Goal Dashboard (compact — L1). v3.2: unchanged sub-goals
323
+ // collapse to a count line; new/transitioned ones render in full.
186
324
  const activeSubs = state.subGoals.filter((sg) => sg.status === "in_progress" || sg.status === "pending");
187
325
  if (activeSubs.length > 0) {
188
- const maxShow = 5;
189
- const items = activeSubs.slice(0, maxShow).map((sg) => {
190
- const icon = sg.status === "in_progress" ? "🔄" : "⏳";
191
- return `${icon} [\`${sg.id}\`] ${sg.description}`;
192
- });
193
- if (activeSubs.length > maxShow) {
194
- items.push(`... and ${activeSubs.length - maxShow} more`);
326
+ const diff = diffSubGoals(baseline, activeSubs);
327
+ const renderFull = !collapseEnabled || !baseline
328
+ || diff.unchangedCount < COLLAPSE_MIN_UNCHANGED;
329
+ if (renderFull) {
330
+ const maxShow = 5;
331
+ const items = activeSubs.slice(0, maxShow).map((sg) => {
332
+ const icon = sg.status === "in_progress" ? "🔄" : "⏳";
333
+ return `${icon} [\`${sg.id}\`] ${sg.description}`;
334
+ });
335
+ if (activeSubs.length > maxShow) {
336
+ items.push(`... and ${activeSubs.length - maxShow} more`);
337
+ }
338
+ sections.push({
339
+ id: "sub_goals",
340
+ text: section("Active Sub-Goals", items.join("\n")),
341
+ mandatory: false,
342
+ });
343
+ }
344
+ else {
345
+ const transitionCount = diff.changed.filter((sg) => {
346
+ const prev = baseline.subGoals.find(([id]) => id === sg.id);
347
+ return prev !== undefined && prev[1] !== sg.status;
348
+ }).length;
349
+ const items = diff.changed.slice(0, 5).map((sg) => {
350
+ const icon = sg.status === "in_progress" ? "🔄" : "⏳";
351
+ return `${icon} [\`${sg.id}\`] ${sg.description}`;
352
+ });
353
+ items.push(`- … ${diff.unchangedCount} unchanged sub-goals, ${transitionCount + diff.removedCount} changed since R${baseline.round} (see state file)`);
354
+ sections.push({
355
+ id: "sub_goals",
356
+ text: section("Active Sub-Goals", items.join("\n")),
357
+ mandatory: false,
358
+ });
195
359
  }
196
- sections.push({
197
- id: "sub_goals",
198
- text: section("Active Sub-Goals", items.join("\n")),
199
- mandatory: false,
200
- });
201
360
  }
202
361
  if (state.discoveries.length > 0) {
203
362
  sections.push({
@@ -206,24 +365,72 @@ function l1Sections(state) {
206
365
  mandatory: false,
207
366
  });
208
367
  }
368
+ // v3.2: Recent Rounds — keep the newest KEEP_RECENT_ROUNDS in full, fold
369
+ // older ones into one pointer line (unless a milestone boundary crossed).
209
370
  if (state.rollingOutcomes.length > 0) {
371
+ const boundaryChanged = milestoneBoundaryChanged(baseline, state.milestones);
372
+ const collapseable = collapseEnabled && baseline !== null
373
+ && state.rollingOutcomes.length > KEEP_RECENT_ROUNDS && !boundaryChanged;
374
+ if (collapseable) {
375
+ const kept = state.rollingOutcomes.slice(-KEEP_RECENT_ROUNDS);
376
+ const earlier = state.rollingOutcomes.length - kept.length;
377
+ const lines = [
378
+ ...kept,
379
+ `- … ${earlier} earlier round${earlier === 1 ? "" : "s"} (see state file)`,
380
+ ];
381
+ sections.push({
382
+ id: "rolling_outcomes",
383
+ text: section("Recent Rounds", lines.join("\n")),
384
+ mandatory: false,
385
+ });
386
+ }
387
+ else {
388
+ sections.push({
389
+ id: "rolling_outcomes",
390
+ text: section("Recent Rounds", bullets(state.rollingOutcomes)),
391
+ mandatory: false,
392
+ });
393
+ }
394
+ }
395
+ // v3.2: L1 also renders the explicit external context (previously L2-only).
396
+ // Placed after the changes delta, within the soft budget.
397
+ if (state.externalContext) {
398
+ sections.push({
399
+ id: "external_context",
400
+ text: section("External Context", state.externalContext),
401
+ mandatory: false,
402
+ });
403
+ }
404
+ // v3.3: Forward-looking roadmap (same buildRoadmap as state.md and L2).
405
+ const roadmapLines = buildRoadmap(state);
406
+ if (roadmapLines.length > 0) {
210
407
  sections.push({
211
- id: "rolling_outcomes",
212
- text: section("Recent Rounds", bullets(state.rollingOutcomes)),
408
+ id: "roadmap",
409
+ text: section("Roadmap", roadmapLines.join("\n")),
213
410
  mandatory: false,
214
411
  });
215
412
  }
216
- if (state.recurringIssues.length > 0) {
413
+ // v3.2: Recurring Issues carries the deterministic violation lessons (with
414
+ // counts and rounds) when available; falls back to the rolling window text.
415
+ if (state.recurringIssues.length > 0 || state.lessons.length > 0) {
416
+ const violationLessons = state.lessons
417
+ .filter((lesson) => lesson.kind === "constraint_violation")
418
+ .slice(0, 3);
419
+ const body = violationLessons.length > 0
420
+ ? violationLessons
421
+ .map((lesson) => `- ${lesson.text} (violated ${lesson.count}×: R${lesson.rounds.join(", R")})`)
422
+ .join("\n")
423
+ : bullets(state.recurringIssues);
217
424
  sections.push({
218
425
  id: "recurring_issues",
219
- text: section("Recurring Issues", bullets(state.recurringIssues)),
426
+ text: section("Recurring Issues", body),
220
427
  mandatory: false,
221
428
  });
222
429
  }
223
- if (state.suggestedNextAction) {
430
+ if (state.nextAction) {
224
431
  sections.push({
225
- id: "agent_intent",
226
- text: section("Suggested Next Action", state.suggestedNextAction),
432
+ id: "next_action",
433
+ text: section("Next Action", state.nextAction),
227
434
  mandatory: false,
228
435
  });
229
436
  }
@@ -231,14 +438,6 @@ function l1Sections(state) {
231
438
  }
232
439
  function l2Sections(state, fullStateMarkdown) {
233
440
  const sections = commonMandatorySections(state, "l2");
234
- const openRegression = state.regressionObligations.filter((obligation) => obligation.status !== "satisfied");
235
- if (openRegression.length > 0) {
236
- sections.push({
237
- id: "regression_obligations",
238
- text: section("Regression Obligations", bullets(openRegression.map((item) => `[${item.status}] ${item.description}`))),
239
- mandatory: true,
240
- });
241
- }
242
441
  const fullState = fullStateMarkdown?.trim();
243
442
  if (fullState) {
244
443
  sections.push({
@@ -255,36 +454,71 @@ function l2Sections(state, fullStateMarkdown) {
255
454
  ["changes", "Changes Since Last Round", state.changesSinceLastRound, null],
256
455
  ["remaining", "Remaining", state.remainingCriteria, "cr"],
257
456
  ["blockers", "Blockers", state.blockers, null],
258
- ["verified_facts", "Latest Verified Facts", state.handoff.verifiedFacts, "cr"],
259
457
  ["discoveries", "Discoveries", state.discoveries, null],
260
458
  ["rolling_outcomes", "Cross-Round Outcomes", state.rollingOutcomes, null],
261
459
  ["recurring_issues", "Recurring Issues", state.recurringIssues, null],
262
460
  ["failed_patterns", "Failed Patterns", state.failedPatterns, null],
263
461
  ];
264
462
  for (const [id, title, values, idPrefix] of groups) {
265
- if (values.length > 0) {
266
- const body = idPrefix
463
+ if (values.length === 0)
464
+ continue;
465
+ // v3.3.1: the active set is identity-heterogeneous (hard/plan/criteria/
466
+ // discovered merged) — render through constraint metadata so criteria
467
+ // texts keep their cr- identity here too (same namespace as their own
468
+ // Success Criteria section). Every other group is homogeneous and keeps
469
+ // its single-prefix renderer.
470
+ const body = id === "active_constraints"
471
+ ? bulletsWithIdentities(values, state.constraintMetadata)
472
+ : idPrefix
267
473
  ? bulletsWithIds(values, idPrefix)
268
474
  : bullets(values);
269
- sections.push({
270
- id,
271
- text: section(title, body),
272
- mandatory: id === "blockers" || id === "verified_facts",
273
- });
475
+ sections.push({ id, text: section(title, body), mandatory: false });
476
+ }
477
+ // v3.2: Lessons learned — repeated violations / verification failures
478
+ // across the whole loop (not just the rolling window).
479
+ if (state.lessons.length > 0) {
480
+ const lessonLines = state.lessons.slice(0, 8).map((lesson) => {
481
+ const icon = lesson.kind === "constraint_violation"
482
+ ? "🚫"
483
+ : lesson.kind === "verification_error" ? "⚠️" : "ℹ️";
484
+ return `- ${icon} ${lesson.text} — ${lesson.count}× (R${lesson.rounds.join(", R")})`;
485
+ });
486
+ if (state.lessons.length > 8) {
487
+ lessonLines.push(`- ... and ${state.lessons.length - 8} more`);
274
488
  }
489
+ sections.push({
490
+ id: "lessons",
491
+ text: section("Lessons Learned", lessonLines.join("\n")),
492
+ mandatory: false,
493
+ });
275
494
  }
276
- if (state.phaseHistory.length > 0) {
277
- const phaseText = state.phaseHistory
278
- .map((phase) => {
279
- const range = phase.closedAtRound
280
- ? `R${phase.enteredAtRound}-R${phase.closedAtRound}`
281
- : `R${phase.enteredAtRound}-current`;
282
- return `**${phase.label}** (${range}, ${phase.status})\n Outcome: ${phase.outcome}`;
495
+ // v3.3: Forward-looking roadmap — rendered right before the (backward-
496
+ // looking) Phase History so the agent sees where it is before where it's
497
+ // been. Same buildRoadmap as state.md and L1.
498
+ const roadmapLines = buildRoadmap(state);
499
+ if (roadmapLines.length > 0) {
500
+ sections.push({
501
+ id: "roadmap",
502
+ text: section("Roadmap", roadmapLines.join("\n")),
503
+ mandatory: false,
504
+ });
505
+ }
506
+ if (state.milestones.length > 0) {
507
+ const milestoneText = state.milestones
508
+ .map((m) => {
509
+ const lines = [
510
+ milestoneHeading(m),
511
+ ` Outcome: ${m.outcome}`,
512
+ ];
513
+ if (m.resolved_constraints.length > 0) {
514
+ lines.push(` Resolved: ${m.resolved_constraints.slice(0, 3).join("; ")}`);
515
+ }
516
+ return lines.join("\n");
283
517
  })
284
518
  .join("\n\n");
285
519
  sections.push({
286
- id: "phase_history",
287
- text: section("Phase History", phaseText),
520
+ id: "milestones",
521
+ text: section("Phase History", milestoneText),
288
522
  mandatory: false,
289
523
  });
290
524
  }
@@ -332,17 +566,10 @@ function l2Sections(state, fullStateMarkdown) {
332
566
  mandatory: false,
333
567
  });
334
568
  }
335
- if (state.loopSynthesis) {
336
- sections.push({
337
- id: "loop_synthesis",
338
- text: section("Loop Summary", state.loopSynthesis),
339
- mandatory: false,
340
- });
341
- }
342
- if (state.suggestedNextAction) {
569
+ if (state.nextAction) {
343
570
  sections.push({
344
- id: "agent_intent",
345
- text: section("Suggested Next Action", state.suggestedNextAction),
571
+ id: "next_action",
572
+ text: section("Next Action", state.nextAction),
346
573
  mandatory: false,
347
574
  });
348
575
  }
@@ -359,7 +586,8 @@ function l2Sections(state, fullStateMarkdown) {
359
586
  state.progress.criteriaMet.length > 0 ||
360
587
  state.progress.criteriaRemaining.length > 0 ||
361
588
  state.progress.filesChanged.length > 0 ||
362
- state.progress.tests !== null) {
589
+ state.progress.tests !== null ||
590
+ state.machineStatus !== undefined) {
363
591
  const lines = [];
364
592
  const total = state.progress.criteriaMet.length + state.progress.criteriaRemaining.length;
365
593
  if (total > 0) {
@@ -367,6 +595,22 @@ function l2Sections(state, fullStateMarkdown) {
367
595
  }
368
596
  if (state.progress.estimate !== null) {
369
597
  lines.push(`**Estimated Completion**: ${(state.progress.estimate * 100).toFixed(0)}%`);
598
+ // v3.3: honest labeling — the estimate is the agent's own number.
599
+ lines.push("**Signal source**: self-reported estimate (unverified until machine-backed)");
600
+ }
601
+ // v3.3: machine side of the comparison — git motion over committed
602
+ // rounds (v3.6: the object always carries definite values — no
603
+ // "unavailable" arm).
604
+ if (state.machineStatus) {
605
+ const ms = state.machineStatus;
606
+ const motion = ms.gitMotion
607
+ ? `changes in ${ms.motionRounds}/${ms.windowRounds} recent committed rounds`
608
+ : `no git changes in the last ${ms.windowRounds} committed rounds`;
609
+ lines.push(`**Machine (git)**: ${motion}`);
610
+ }
611
+ if (state.criterionStatuses.length > 0) {
612
+ const metCount = state.criterionStatuses.filter((cs) => cs.status === "met").length;
613
+ lines.push(`**Machine (criteria)**: ${metCount}/${state.criterionStatuses.length} met across committed rounds`);
370
614
  }
371
615
  if (state.progress.tests) {
372
616
  lines.push(`**Tests**: ${state.progress.tests.passed} passed, ` +
@@ -380,12 +624,54 @@ function l2Sections(state, fullStateMarkdown) {
380
624
  lines.push(`- ... and ${state.progress.filesChanged.length - 10} more`);
381
625
  }
382
626
  }
627
+ // v3.2: Goal → criteria → evidence vertical view — per-criterion status
628
+ // with the round it was met and linked sub-goals. IDs follow
629
+ // constraint_id_enabled like every other ID-rendering path.
630
+ if (state.criterionStatuses.length > 0) {
631
+ const idEnabled = getPolicy().evolution.constraint_id_enabled;
632
+ lines.push("", "**Goal → Criteria**:");
633
+ for (const cs of state.criterionStatuses) {
634
+ const icon = cs.status === "met" ? "✅" : cs.status === "remaining" ? "⬜" : "❔";
635
+ const idTag = idEnabled ? ` [\`${cs.id}\`]` : "";
636
+ const met = cs.met_at_round !== undefined ? `(met R${cs.met_at_round})` : "";
637
+ const related = cs.related_subgoal_ids.length > 0
638
+ ? ` [↔ ${cs.related_subgoal_ids.join(", ")}]`
639
+ : "";
640
+ lines.push(`- ${icon}${idTag} ${cs.text} ${met}${related}`);
641
+ }
642
+ }
383
643
  sections.push({
384
644
  id: "progress",
385
645
  text: section("Progress Dashboard", lines.join("\n")),
386
646
  mandatory: false,
387
647
  });
388
648
  }
649
+ // v3.3: Per-round statistics — lets the agent calibrate round granularity
650
+ // (files per round, rejected attempts, self-reported progress deltas).
651
+ // v3.6: source-labeled — files/Δ are agent-reported; only rejected
652
+ // attempts (and the Machine (git) row above) are machine-recorded.
653
+ if (state.roundStats && state.roundStats.length > 0) {
654
+ const statLines = state.roundStats.map((stat) => {
655
+ const parts = [];
656
+ if (stat.filesChangedCount !== null) {
657
+ parts.push(`${stat.filesChangedCount} file${stat.filesChangedCount === 1 ? "" : "s"}`);
658
+ }
659
+ if (stat.rejectedAttempts !== null && stat.rejectedAttempts > 0) {
660
+ parts.push(`${stat.rejectedAttempts} rejected attempt${stat.rejectedAttempts === 1 ? "" : "s"}`);
661
+ }
662
+ if (stat.progressDelta !== null) {
663
+ parts.push(`Δ${stat.progressDelta >= 0 ? "+" : ""}${stat.progressDelta.toFixed(2)}`);
664
+ }
665
+ return `- R${stat.round}: ${parts.length > 0 ? parts.join(", ") : "no data"}`;
666
+ });
667
+ statLines.push("> files & Δ are agent-reported; rejected attempts are machine-recorded " +
668
+ "(the Machine (git) row above is machine-observed)");
669
+ sections.push({
670
+ id: "round_stats",
671
+ text: section("Round Stats", statLines.join("\n")),
672
+ mandatory: false,
673
+ });
674
+ }
389
675
  // Retired Constraints
390
676
  if (state.retiredConstraints.length > 0) {
391
677
  const lines = state.retiredConstraints.map((c) => `- ~${c}~ (retired)`);
@@ -419,11 +705,8 @@ function l2Sections(state, fullStateMarkdown) {
419
705
  }
420
706
  // Agent Trust Score + Trend
421
707
  if (state.agentTrustScore !== undefined) {
422
- const barLen = 10;
423
- const filled = Math.round(state.agentTrustScore * barLen);
424
- const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
425
708
  const lines = [
426
- `${bar} ${(state.agentTrustScore * 100).toFixed(0)}%`,
709
+ trustBarLine(state.agentTrustScore),
427
710
  ];
428
711
  if (state.agentTrustTrend.length > 0) {
429
712
  const avg = (state.agentTrustTrend.reduce((a, b) => a + b, 0)
@@ -439,27 +722,22 @@ function l2Sections(state, fullStateMarkdown) {
439
722
  }
440
723
  return sections;
441
724
  }
442
- function selectSections(input) {
443
- const sections = input.mode === "full"
444
- ? l2Sections(input.state, input.fullStateMarkdown)
445
- : input.level === "l0" ? l0Sections(input.state)
446
- : input.level === "l1" ? l1Sections(input.state)
447
- : l2Sections(input.state, input.fullStateMarkdown);
448
- if (input.agentIntent?.trim()) {
449
- sections.push({
450
- id: "agent_intent",
451
- text: section("Agent Intent (non-authoritative)", `${input.agentIntent.trim()}\nRuntime-derived Focus, Todo and Suggested Next Action take precedence.`),
452
- mandatory: false,
453
- });
725
+ function selectSections(input, emphasized) {
726
+ if (input.mode === "full") {
727
+ return l2Sections(input.state, input.fullStateMarkdown);
454
728
  }
455
- return sections;
729
+ if (input.level === "l0")
730
+ return l0Sections(input.state);
731
+ if (input.level === "l1") {
732
+ return l1Sections(input.state, input.presentedBaseline ?? null, emphasized);
733
+ }
734
+ return l2Sections(input.state, input.fullStateMarkdown);
456
735
  }
457
736
  function renderWithinBudget(sections, fixedText, budget) {
458
737
  const mandatory = sections.filter((item) => item.mandatory);
459
738
  const optional = sections.filter((item) => !item.mandatory);
460
739
  let rendered = fixedText + mandatory.map((item) => item.text).join("");
461
740
  const included = mandatory.filter((item) => item.text).map((item) => item.id);
462
- const omitted = [];
463
741
  for (const item of optional) {
464
742
  if (!item.text)
465
743
  continue;
@@ -467,11 +745,8 @@ function renderWithinBudget(sections, fixedText, budget) {
467
745
  rendered += item.text;
468
746
  included.push(item.id);
469
747
  }
470
- else {
471
- omitted.push(item.id);
472
- }
473
748
  }
474
- return { rendered, included, omitted };
749
+ return { rendered, included };
475
750
  }
476
751
  // ═══════════════════════════════════════════════════════════════════════════
477
752
  // v2.9: Prompt Requests — model-expressed information needs
@@ -483,13 +758,12 @@ function renderWithinBudget(sections, fixedText, budget) {
483
758
  function matchEmphasize(targets, candidates, cap) {
484
759
  if (targets.length === 0 || candidates.length === 0)
485
760
  return [];
486
- const idRegex = /^(c|cr|sg)-[a-f0-9]{8}$/;
487
761
  const scored = candidates.map((c) => {
488
762
  let best = 0;
489
763
  for (const t of targets) {
490
764
  let s = 0;
491
765
  // Phase 1: exact ID match (v2.11)
492
- const idMatch = t.match(idRegex);
766
+ const idMatch = t.match(STABLE_ID_RE);
493
767
  if (idMatch) {
494
768
  const candidateId = idMatch[1] + "-" + deriveItemId(c);
495
769
  if (candidateId === t)
@@ -511,7 +785,7 @@ function matchEmphasize(targets, candidates, cap) {
511
785
  }
512
786
  /** Render the "Confusion Alerts" section that appears at the prompt top.
513
787
  * Compiler auto-matches each confusion point against state sections. */
514
- function renderConfusionAlerts(points, state, expandAll) {
788
+ function renderConfusionAlerts(points, state, fullDensity) {
515
789
  if (points.length === 0)
516
790
  return "";
517
791
  const lines = [
@@ -530,9 +804,12 @@ function renderConfusionAlerts(points, state, expandAll) {
530
804
  { keyword: "criteria", label: "Success Criteria", ref: "## Success Criteria" },
531
805
  { keyword: "phase", label: "Phase History", ref: "## Phase History" },
532
806
  { keyword: "objective", label: "Loop Objective", ref: "## Loop Objective" },
533
- { keyword: "loop", label: "Loop Summary", ref: "## Loop Summary" },
534
807
  ];
535
- const shown = expandAll ? points : points.slice(0, 1);
808
+ // v2.14: L2 renders up to the policy cap (was: every confusion point,
809
+ // unbounded by max_confusion_points); L1 keeps a single alert.
810
+ const shown = fullDensity
811
+ ? points.slice(0, getPolicy().prompt.max_confusion_points)
812
+ : points.slice(0, 1);
536
813
  for (const point of shown) {
537
814
  const truncated = point.length > 200 ? point.slice(0, 197) + "…" : point;
538
815
  // Find best-matching state section
@@ -545,14 +822,14 @@ function renderConfusionAlerts(points, state, expandAll) {
545
822
  bestSection = sec;
546
823
  }
547
824
  }
548
- const pointer = bestSection && bestScore >= 0.15
825
+ const pointer = bestSection && bestScore >= getPolicy().prompt.confusion_section_threshold
549
826
  ? `→ Check **${bestSection.label}** (\`${bestSection.ref}\`) in the state file.`
550
827
  : "→ Read the full state file for relevant context.";
551
828
  lines.push(`- **"${truncated}"**`);
552
829
  lines.push(` ${pointer}`);
553
830
  lines.push("");
554
831
  }
555
- if (!expandAll && points.length > 1) {
832
+ if (!fullDensity && points.length > 1) {
556
833
  lines.push(`*… and ${points.length - 1} more confusion point(s) — see state file.*`);
557
834
  lines.push("");
558
835
  }
@@ -575,105 +852,6 @@ function renderCriticalContext(emphasized, state) {
575
852
  lines.push("");
576
853
  return lines.join("\n");
577
854
  }
578
- /** Expand a specific section to L2-level detail within a L1 prompt.
579
- * Returns the expanded section text, or empty string if the section
580
- * has no content to expand. */
581
- function renderExpandedSection(sectionName, state) {
582
- switch (sectionName) {
583
- case "milestones": {
584
- if (state.phaseHistory.length === 0)
585
- return "";
586
- const text = state.phaseHistory
587
- .map((phase) => {
588
- const range = phase.closedAtRound
589
- ? `R${phase.enteredAtRound}-R${phase.closedAtRound}`
590
- : `R${phase.enteredAtRound}-current`;
591
- return `**${phase.label}** (${range}, ${phase.status})\n Outcome: ${phase.outcome}`;
592
- })
593
- .join("\n\n");
594
- return section("Expanded Phase History", text);
595
- }
596
- case "sub_goals": {
597
- if (state.subGoals.length === 0)
598
- return "";
599
- const lines = [];
600
- for (const sg of state.subGoals) {
601
- const icon = sg.status === "in_progress" ? "🔄" :
602
- sg.status === "done" ? "✅" :
603
- sg.status === "blocked" ? "🚫" :
604
- sg.status === "canceled" ? "❌" : "⏳";
605
- const stale = sg.status === "pending" && state.round - sg.declared_at_round >= 10 ? " ⚠️ stale" : "";
606
- const detail = sg.status === "done" && sg.completed_at_round
607
- ? ` (done, R${sg.completed_at_round})`
608
- : sg.status === "in_progress" ? ` (since R${sg.status_changed_at_round})`
609
- : sg.status === "pending" ? ` (since R${sg.declared_at_round})`
610
- : sg.status === "blocked" ? ` (blocked R${sg.status_changed_at_round})`
611
- : ` (canceled R${sg.status_changed_at_round})`;
612
- lines.push(`- ${icon} [\`${sg.id}\`] ${sg.description}${detail}${stale}`);
613
- }
614
- return section("Expanded Sub-Goal Dashboard", lines.join("\n"));
615
- }
616
- case "constraint_lifecycle": {
617
- if (state.inactiveConstraints.length === 0 && state.retiredConstraints.length === 0)
618
- return "";
619
- const lines = [];
620
- if (state.inactiveConstraints.length > 0) {
621
- lines.push("**Inactive (auto-decayed):**");
622
- for (const c of state.inactiveConstraints) {
623
- const meta = state.constraintMetadata.find((m) => m.text === c);
624
- const age = meta ? state.round - (meta.last_violated_at_round || meta.discovered_at_round || 0) : "?";
625
- lines.push(`- ${c} (${age} rounds inactive)`);
626
- }
627
- }
628
- if (state.retiredConstraints.length > 0) {
629
- lines.push("**Retired:**");
630
- for (const c of state.retiredConstraints)
631
- lines.push(`- ~${c}~`);
632
- }
633
- return section("Constraint Lifecycle", lines.join("\n"));
634
- }
635
- case "agent_trust": {
636
- if (state.agentTrustScore === undefined)
637
- return "";
638
- const barLen = 10;
639
- const filled = Math.round(state.agentTrustScore * barLen);
640
- const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
641
- const lines = [
642
- `${bar} ${(state.agentTrustScore * 100).toFixed(0)}%`,
643
- ];
644
- if (state.agentTrustTrend.length > 0) {
645
- lines.push(`Trend: ${state.agentTrustTrend.join(" → ")}`);
646
- }
647
- return section("Agent Trust (Expanded)", lines.join("\n"));
648
- }
649
- case "progress": {
650
- const p = state.progress;
651
- if (p.estimate === null && p.criteriaMet.length === 0 && p.criteriaRemaining.length === 0)
652
- return "";
653
- const lines = [];
654
- const total = p.criteriaMet.length + p.criteriaRemaining.length;
655
- if (total > 0)
656
- lines.push(`**Criteria**: ${p.criteriaMet.length}/${total} met`);
657
- if (p.estimate !== null)
658
- lines.push(`**Estimate**: ${(p.estimate * 100).toFixed(0)}%`);
659
- if (p.tests)
660
- lines.push(`**Tests**: ${p.tests.passed} passed, ${p.tests.failed} failed, ${p.tests.skipped} skipped`);
661
- if (p.filesChanged.length > 0) {
662
- lines.push("**Files Changed**:");
663
- for (const f of p.filesChanged.slice(0, 10))
664
- lines.push(`- ${f}`);
665
- }
666
- return section("Progress Dashboard (Expanded)", lines.join("\n"));
667
- }
668
- case "loop_synthesis": {
669
- if (!state.loopSynthesis)
670
- return "";
671
- return section("Loop Summary", state.loopSynthesis);
672
- }
673
- default:
674
- return "";
675
- }
676
- }
677
855
  export function assemblePromptArtifact(input) {
678
856
  const mode = input.mode ?? "adaptive";
679
857
  const budgets = { ...DEFAULT_PROMPT_BUDGETS, ...input.budgets };
@@ -686,7 +864,6 @@ export function assemblePromptArtifact(input) {
686
864
  // than relying on potentially-compacted conversation history.
687
865
  const fullContextReasons = new Set([
688
866
  "first_round", "plan_boundary", "checkpoint_boundary", "goal_changed",
689
- "constraint_changed",
690
867
  "missing_previous_state", "verification_contradicted",
691
868
  "rejection_rehydrate", "recovery_boundary", "state_drift",
692
869
  "periodic_refresh",
@@ -695,8 +872,13 @@ export function assemblePromptArtifact(input) {
695
872
  || input.mode === "pointer"
696
873
  || input.reasons.some((r) => fullContextReasons.has(r));
697
874
  const stateFilePath = input.state.stateFilePath;
875
+ // v3.2: L1 annotates the pointer with the round the freshly-regenerated
876
+ // state file describes (it is rewritten every compile); L0/L2 keep the
877
+ // plain pointer byte-identical.
698
878
  const stateFileRef = stateFilePath
699
- ? `📄 Full state: \`${stateFilePath}\`\n`
879
+ ? (input.level === "l1"
880
+ ? `📄 Full state: \`${stateFilePath}\` (updated round ${input.state.round})\n`
881
+ : `📄 Full state: \`${stateFilePath}\`\n`)
700
882
  : "";
701
883
  const readInstruction = needsFullContext && stateFilePath
702
884
  ? [
@@ -716,13 +898,11 @@ export function assemblePromptArtifact(input) {
716
898
  readInstruction,
717
899
  "",
718
900
  ].join("\n");
719
- const sections = selectSections(input);
720
901
  const pointer = stateFilePath
721
902
  ? section("Full State", stateFilePath)
722
903
  : "";
723
- const reportBlock = input.roundReportBlock ?? "";
724
- const evaluation = reportBlock.trim()
725
- ? `${reportBlock.trim()}\n`
904
+ const evaluation = input.selfEvaluationBlock.trim()
905
+ ? `${input.selfEvaluationBlock.trim()}\n`
726
906
  : "";
727
907
  const howToRespond = [
728
908
  "## How to Complete This Round",
@@ -730,20 +910,24 @@ export function assemblePromptArtifact(input) {
730
910
  "1. Execute the task described above. Do NOT generate another prompt or plan — act.",
731
911
  "2. When finished, call the MCP tool **`loopforge_next`** with these parameters:",
732
912
  " - `sessionId`: the session ID from `loopforge_start`.",
733
- " - `report`: a canonical `RoundReport` object (see schema below).",
913
+ " - `evaluation`: a structured self-assessment object (see schema below).",
734
914
  "",
735
915
  "### Evaluation Rules",
736
916
  "",
737
- "- Use `outcome=success` only when all criteria have runtime-verified evidence; use `continuation=stop` only when you are requesting no further round.",
738
- "- `violations`: list every constraint actually violated.",
739
- "- Put claims in `evidence.claims` and reference configured providers or commands through `evidenceRefs`; provenance is runtime-owned.",
740
- "- Do not estimate progress. LoopForge derives it from verified claims and remaining criteria.",
917
+ "- `success`: Set to **`true` ONLY** when ALL hard constraints are met AND",
918
+ " the task goal is fully achieved. If anything remains incomplete, set `false`.",
919
+ "- `should_continue`: Set to **`false` ONLY** when the ENTIRE loop task is done.",
920
+ " Partial progress or completed subtasks → `true`.",
921
+ "- `constraint_violations`: Be honest. List every constraint you actually violated.",
922
+ "- `execution_evidence.files_changed`: List files you modified — used for verification.",
923
+ "- `execution_evidence.test_results`: Report actual test runner output.",
924
+ "- `progress_estimate`: A number 0.0–1.0 reflecting overall task completion.",
741
925
  "",
742
926
  "### If the Prompt Says \"REJECTED\"",
743
927
  "",
744
928
  "- Re-execute the **same round**. Do NOT advance the round counter.",
745
929
  "- Read the Required Fix section, address every issue, then submit again.",
746
- "- Use `loopforge_next` with a corrected report.",
930
+ "- Use `loopforge_next` with a corrected evaluation.",
747
931
  "",
748
932
  evaluation,
749
933
  ].join("\n");
@@ -768,12 +952,45 @@ export function assemblePromptArtifact(input) {
768
952
  ...input.state.changesSinceLastRound,
769
953
  ], emphasizeCap)
770
954
  : [];
955
+ // v2.14: emphasize is a pure reorder (zero token overhead) — the matched
956
+ // items are MOVED to the Critical Context section, not duplicated. Filter
957
+ // them out of the source arrays before section rendering; unmatched
958
+ // emphasizes are dropped (nothing to reorder → nothing rendered).
959
+ // v3.2.1: hard constraints and success criteria live in BOTH their own
960
+ // sections (Active Hard Constraints / Success Criteria) and the active
961
+ // constraint list (loop-compiler merges objective.hard_constraints and
962
+ // success_criteria into constraints_active), so they must be filtered
963
+ // from BOTH sources too — otherwise the emphasized item renders twice.
964
+ const emphasizedSet = new Set(emphasized);
965
+ const stateForSections = emphasizedSet.size > 0
966
+ ? {
967
+ ...input.state,
968
+ activeConstraints: input.state.activeConstraints.filter((item) => !emphasizedSet.has(item)),
969
+ hardConstraints: input.state.hardConstraints.filter((item) => !emphasizedSet.has(item)),
970
+ successCriteria: input.state.successCriteria.filter((item) => !emphasizedSet.has(item)),
971
+ discoveries: input.state.discoveries.filter((item) => !emphasizedSet.has(item)),
972
+ blockers: input.state.blockers.filter((item) => !emphasizedSet.has(item)),
973
+ changesSinceLastRound: input.state.changesSinceLastRound.filter((item) => !emphasizedSet.has(item)),
974
+ }
975
+ : input.state;
976
+ const sections = selectSections({ ...input, state: stateForSections }, emphasizedSet);
771
977
  const criticalContextText = renderCriticalContext(emphasized, input.state);
772
- // Expand: L1 only, max 1 section (L2 already expanded, L0 ignored)
773
- const expandCap = levelIsL1 ? policy.max_expand_l1 : 0;
774
- const expandedSection = (pr?.expand?.length && expandCap > 0)
775
- ? renderExpandedSection(pr.expand[0], input.state)
776
- : "";
978
+ // v3.2: L1 only — snapshot what this prompt actually presented, so the
979
+ // NEXT L1 compile can diff against it (persisted via PromptArtifact →
980
+ // lineage). L0/L2 leave it undefined: their presentation semantics differ
981
+ // (L2's dashboard includes done/canceled sub-goals, which would
982
+ // contaminate the L1 active-set diff).
983
+ let presentedState;
984
+ if (levelIsL1) {
985
+ const activeSubsForSnapshot = stateForSections.subGoals.filter((sg) => sg.status === "in_progress" || sg.status === "pending");
986
+ presentedState = {
987
+ round: input.state.round,
988
+ constraintIds: activeNonHardConstraints(stateForSections)
989
+ .map((text) => constraintIdOf(text, stateForSections.constraintMetadata)),
990
+ subGoals: activeSubsForSnapshot.map((sg) => [sg.id, sg.status]),
991
+ milestoneRanges: input.state.milestones.map((m) => [m.round_range.start, m.round_range.end]),
992
+ };
993
+ }
777
994
  // Inject confusion alerts into the header area (before mandatory sections)
778
995
  const fixedText = header + confusionText;
779
996
  // Inject critical context as a mandatory section
@@ -790,19 +1007,18 @@ export function assemblePromptArtifact(input) {
790
1007
  }
791
1008
  const selected = mode === "pointer"
792
1009
  ? renderWithinBudget(augmentedSections.filter((item) => item.mandatory), fixedText, budget)
793
- : renderWithinBudget(augmentedSections, fixedText, budget - footer.length - expandedSection.length);
794
- const renderedPrompt = selected.rendered + expandedSection + footer;
1010
+ : renderWithinBudget(augmentedSections, fixedText, budget - footer.length);
1011
+ const renderedPrompt = selected.rendered + footer;
795
1012
  const includedSections = [
796
1013
  ...selected.included,
797
1014
  ...(confusionText ? ["confusion_alerts"] : []),
798
1015
  ...(criticalContextText ? ["critical_context"] : []),
799
- ...(expandedSection ? [`expand:${pr?.expand?.[0] ?? ""}`] : []),
800
1016
  ...(pointer ? ["state_pointer"] : []),
801
1017
  ...(evaluation ? ["self_evaluation"] : []),
802
1018
  ];
803
1019
  const promptHash = createHash("sha256").update(renderedPrompt).digest("hex");
804
1020
  return {
805
- format: PROMPT_ARTIFACT_FORMAT,
1021
+ schemaVersion: PROMPT_ARTIFACT_SCHEMA_VERSION,
806
1022
  roundId: `loop:${input.state.loopId}:round:${input.state.round}`,
807
1023
  attempt,
808
1024
  level: input.level,
@@ -812,18 +1028,11 @@ export function assemblePromptArtifact(input) {
812
1028
  stateHash,
813
1029
  basePromptVersion: BASE_PROMPT_VERSION,
814
1030
  includedSections,
815
- omittedSections: selected.omitted,
816
1031
  budgetChars: budget,
817
1032
  charCount: renderedPrompt.length,
818
1033
  budgetExceeded: renderedPrompt.length > budget,
819
- // lastL2Round / lastMeaningfulProgressRound / stateAgeRounds are NOT
820
- // assembled here: the assembler has no vault history and can only guess,
821
- // so the values were always immediately overwritten by the compiler's
822
- // vault-derived backfill. They stay undefined at this layer.
823
- phaseId: input.state.phaseHistory.at(-1)?.id,
824
- focusId: input.state.todoProjection?.sourceId,
825
- todoId: input.state.todoProjection?.id,
826
1034
  generatedAt: Date.now(),
1035
+ ...(presentedState ? { presentedState } : {}),
827
1036
  };
828
1037
  }
829
1038
  //# sourceMappingURL=prompt-assembler.js.map