loopforge 3.9.2 → 4.0.7

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 (285) hide show
  1. package/README.md +236 -112
  2. package/dist/audit.d.ts +73 -15
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +131 -61
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +152 -40
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +382 -206
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +41 -12
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +0 -2
  14. package/dist/cognitive-governance.d.ts.map +1 -1
  15. package/dist/cognitive-governance.js +0 -4
  16. package/dist/cognitive-governance.js.map +1 -1
  17. package/dist/committed-round.d.ts +105 -141
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +249 -372
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/constraint-lifecycle.d.ts +73 -0
  22. package/dist/constraint-lifecycle.d.ts.map +1 -0
  23. package/dist/constraint-lifecycle.js +174 -0
  24. package/dist/constraint-lifecycle.js.map +1 -0
  25. package/dist/context-boundary.d.ts +83 -0
  26. package/dist/context-boundary.d.ts.map +1 -0
  27. package/dist/context-boundary.js +101 -0
  28. package/dist/context-boundary.js.map +1 -0
  29. package/dist/contract-items.d.ts +12 -8
  30. package/dist/contract-items.d.ts.map +1 -1
  31. package/dist/contract-items.js +31 -26
  32. package/dist/contract-items.js.map +1 -1
  33. package/dist/contract-requirement.d.ts +47 -0
  34. package/dist/contract-requirement.d.ts.map +1 -0
  35. package/dist/contract-requirement.js +51 -0
  36. package/dist/contract-requirement.js.map +1 -0
  37. package/dist/criterion-status.d.ts +35 -0
  38. package/dist/criterion-status.d.ts.map +1 -0
  39. package/dist/criterion-status.js +89 -0
  40. package/dist/criterion-status.js.map +1 -0
  41. package/dist/enforcement-gate.d.ts +32 -67
  42. package/dist/enforcement-gate.d.ts.map +1 -1
  43. package/dist/enforcement-gate.js +203 -439
  44. package/dist/enforcement-gate.js.map +1 -1
  45. package/dist/engine.d.ts +9 -103
  46. package/dist/engine.d.ts.map +1 -1
  47. package/dist/engine.js +28 -887
  48. package/dist/engine.js.map +1 -1
  49. package/dist/errors.d.ts +41 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +69 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/evidence-claims.d.ts +33 -31
  54. package/dist/evidence-claims.d.ts.map +1 -1
  55. package/dist/evidence-claims.js +58 -72
  56. package/dist/evidence-claims.js.map +1 -1
  57. package/dist/evidence-posture-view.d.ts +19 -0
  58. package/dist/evidence-posture-view.d.ts.map +1 -0
  59. package/dist/evidence-posture-view.js +116 -0
  60. package/dist/evidence-posture-view.js.map +1 -0
  61. package/dist/evidence-posture.d.ts +73 -0
  62. package/dist/evidence-posture.d.ts.map +1 -0
  63. package/dist/evidence-posture.js +281 -0
  64. package/dist/evidence-posture.js.map +1 -0
  65. package/dist/evidence-provider.d.ts +55 -37
  66. package/dist/evidence-provider.d.ts.map +1 -1
  67. package/dist/evidence-provider.js +182 -332
  68. package/dist/evidence-provider.js.map +1 -1
  69. package/dist/explain.d.ts +167 -27
  70. package/dist/explain.d.ts.map +1 -1
  71. package/dist/explain.js +373 -58
  72. package/dist/explain.js.map +1 -1
  73. package/dist/field-accounting.d.ts.map +1 -1
  74. package/dist/field-accounting.js +3 -22
  75. package/dist/field-accounting.js.map +1 -1
  76. package/dist/generate-schema.js +13 -8
  77. package/dist/generate-schema.js.map +1 -1
  78. package/dist/git-boundary.d.ts +128 -0
  79. package/dist/git-boundary.d.ts.map +1 -0
  80. package/dist/git-boundary.js +625 -0
  81. package/dist/git-boundary.js.map +1 -0
  82. package/dist/git-facts.d.ts +22 -0
  83. package/dist/git-facts.d.ts.map +1 -0
  84. package/dist/git-facts.js +29 -0
  85. package/dist/git-facts.js.map +1 -0
  86. package/dist/git-motion.d.ts +37 -0
  87. package/dist/git-motion.d.ts.map +1 -0
  88. package/dist/git-motion.js +59 -0
  89. package/dist/git-motion.js.map +1 -0
  90. package/dist/history.d.ts +44 -0
  91. package/dist/history.d.ts.map +1 -0
  92. package/dist/history.js +77 -0
  93. package/dist/history.js.map +1 -0
  94. package/dist/index.d.ts +19 -9
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +12 -4
  97. package/dist/index.js.map +1 -1
  98. package/dist/init.d.ts +16 -0
  99. package/dist/init.d.ts.map +1 -1
  100. package/dist/init.js +80 -14
  101. package/dist/init.js.map +1 -1
  102. package/dist/loop-compiler.d.ts +51 -19
  103. package/dist/loop-compiler.d.ts.map +1 -1
  104. package/dist/loop-compiler.js +423 -507
  105. package/dist/loop-compiler.js.map +1 -1
  106. package/dist/loop-store.d.ts +6 -5
  107. package/dist/loop-store.d.ts.map +1 -1
  108. package/dist/loop-store.js +153 -35
  109. package/dist/loop-store.js.map +1 -1
  110. package/dist/mcp/round-lifecycle.d.ts +285 -81
  111. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  112. package/dist/mcp/round-lifecycle.js +1629 -574
  113. package/dist/mcp/round-lifecycle.js.map +1 -1
  114. package/dist/mcp/server.d.ts +5 -0
  115. package/dist/mcp/server.d.ts.map +1 -1
  116. package/dist/mcp/server.js +61 -19
  117. package/dist/mcp/server.js.map +1 -1
  118. package/dist/mcp/session.d.ts +81 -39
  119. package/dist/mcp/session.d.ts.map +1 -1
  120. package/dist/mcp/session.js +963 -271
  121. package/dist/mcp/session.js.map +1 -1
  122. package/dist/mcp/submission-boundary.d.ts +23 -31
  123. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  124. package/dist/mcp/submission-boundary.js +242 -82
  125. package/dist/mcp/submission-boundary.js.map +1 -1
  126. package/dist/mcp/submission-handle.d.ts +66 -0
  127. package/dist/mcp/submission-handle.d.ts.map +1 -0
  128. package/dist/mcp/submission-handle.js +173 -0
  129. package/dist/mcp/submission-handle.js.map +1 -0
  130. package/dist/mcp/tools.d.ts +17 -7
  131. package/dist/mcp/tools.d.ts.map +1 -1
  132. package/dist/mcp/tools.js +763 -244
  133. package/dist/mcp/tools.js.map +1 -1
  134. package/dist/open-question.d.ts +7 -0
  135. package/dist/open-question.d.ts.map +1 -0
  136. package/dist/open-question.js +14 -0
  137. package/dist/open-question.js.map +1 -0
  138. package/dist/policy-metrics.d.ts +31 -21
  139. package/dist/policy-metrics.d.ts.map +1 -1
  140. package/dist/policy-metrics.js +117 -64
  141. package/dist/policy-metrics.js.map +1 -1
  142. package/dist/policy.d.ts +15 -40
  143. package/dist/policy.d.ts.map +1 -1
  144. package/dist/policy.js +13 -13
  145. package/dist/policy.js.map +1 -1
  146. package/dist/progress.d.ts +45 -0
  147. package/dist/progress.d.ts.map +1 -0
  148. package/dist/progress.js +136 -0
  149. package/dist/progress.js.map +1 -0
  150. package/dist/prompt-assembler.d.ts +28 -9
  151. package/dist/prompt-assembler.d.ts.map +1 -1
  152. package/dist/prompt-assembler.js +592 -603
  153. package/dist/prompt-assembler.js.map +1 -1
  154. package/dist/prompt-coverage.d.ts +39 -0
  155. package/dist/prompt-coverage.d.ts.map +1 -0
  156. package/dist/prompt-coverage.js +80 -0
  157. package/dist/prompt-coverage.js.map +1 -0
  158. package/dist/prompt-freshness.d.ts +25 -0
  159. package/dist/prompt-freshness.d.ts.map +1 -0
  160. package/dist/prompt-freshness.js +88 -0
  161. package/dist/prompt-freshness.js.map +1 -0
  162. package/dist/prompt-policy.d.ts +14 -4
  163. package/dist/prompt-policy.d.ts.map +1 -1
  164. package/dist/prompt-policy.js +29 -10
  165. package/dist/prompt-policy.js.map +1 -1
  166. package/dist/protocol.d.ts +875 -419
  167. package/dist/protocol.d.ts.map +1 -1
  168. package/dist/protocol.js +73 -99
  169. package/dist/protocol.js.map +1 -1
  170. package/dist/recovery-guidance.d.ts +13 -0
  171. package/dist/recovery-guidance.d.ts.map +1 -0
  172. package/dist/recovery-guidance.js +15 -0
  173. package/dist/recovery-guidance.js.map +1 -0
  174. package/dist/recovery.d.ts +68 -0
  175. package/dist/recovery.d.ts.map +1 -0
  176. package/dist/recovery.js +234 -0
  177. package/dist/recovery.js.map +1 -0
  178. package/dist/replay.d.ts +42 -8
  179. package/dist/replay.d.ts.map +1 -1
  180. package/dist/replay.js +311 -84
  181. package/dist/replay.js.map +1 -1
  182. package/dist/result-facts.d.ts +45 -0
  183. package/dist/result-facts.d.ts.map +1 -0
  184. package/dist/result-facts.js +86 -0
  185. package/dist/result-facts.js.map +1 -0
  186. package/dist/round-contract.d.ts +38 -8
  187. package/dist/round-contract.d.ts.map +1 -1
  188. package/dist/round-contract.js +71 -31
  189. package/dist/round-contract.js.map +1 -1
  190. package/dist/round-coordinator.d.ts +45 -91
  191. package/dist/round-coordinator.d.ts.map +1 -1
  192. package/dist/round-coordinator.js +254 -271
  193. package/dist/round-coordinator.js.map +1 -1
  194. package/dist/round-driver.d.ts +56 -16
  195. package/dist/round-driver.d.ts.map +1 -1
  196. package/dist/round-driver.js +225 -39
  197. package/dist/round-driver.js.map +1 -1
  198. package/dist/round-facts.d.ts +33 -52
  199. package/dist/round-facts.d.ts.map +1 -1
  200. package/dist/round-facts.js +54 -157
  201. package/dist/round-facts.js.map +1 -1
  202. package/dist/round-guidance.d.ts +56 -0
  203. package/dist/round-guidance.d.ts.map +1 -0
  204. package/dist/round-guidance.js +161 -0
  205. package/dist/round-guidance.js.map +1 -0
  206. package/dist/round-outcome.d.ts +21 -0
  207. package/dist/round-outcome.d.ts.map +1 -0
  208. package/dist/round-outcome.js +31 -0
  209. package/dist/round-outcome.js.map +1 -0
  210. package/dist/round-transaction.d.ts +168 -21
  211. package/dist/round-transaction.d.ts.map +1 -1
  212. package/dist/round-transaction.js +856 -39
  213. package/dist/round-transaction.js.map +1 -1
  214. package/dist/self-eval.d.ts +40 -60
  215. package/dist/self-eval.d.ts.map +1 -1
  216. package/dist/self-eval.js +455 -384
  217. package/dist/self-eval.js.map +1 -1
  218. package/dist/storage.d.ts +16 -0
  219. package/dist/storage.d.ts.map +1 -1
  220. package/dist/storage.js +54 -4
  221. package/dist/storage.js.map +1 -1
  222. package/dist/subgoal-state.d.ts +2 -3
  223. package/dist/subgoal-state.d.ts.map +1 -1
  224. package/dist/subgoal-state.js +2 -3
  225. package/dist/subgoal-state.js.map +1 -1
  226. package/dist/submission-guidance.d.ts +43 -21
  227. package/dist/submission-guidance.d.ts.map +1 -1
  228. package/dist/submission-guidance.js +368 -262
  229. package/dist/submission-guidance.js.map +1 -1
  230. package/dist/submission-receipt.d.ts +16 -8
  231. package/dist/submission-receipt.d.ts.map +1 -1
  232. package/dist/submission-receipt.js +146 -51
  233. package/dist/submission-receipt.js.map +1 -1
  234. package/dist/token-utils.d.ts +47 -7
  235. package/dist/token-utils.d.ts.map +1 -1
  236. package/dist/token-utils.js +66 -15
  237. package/dist/token-utils.js.map +1 -1
  238. package/dist/transaction-identity.d.ts +9 -0
  239. package/dist/transaction-identity.d.ts.map +1 -0
  240. package/dist/transaction-identity.js +69 -0
  241. package/dist/transaction-identity.js.map +1 -0
  242. package/dist/verification-actions.d.ts +13 -0
  243. package/dist/verification-actions.d.ts.map +1 -0
  244. package/dist/verification-actions.js +44 -0
  245. package/dist/verification-actions.js.map +1 -0
  246. package/dist/verification-gate.d.ts +26 -46
  247. package/dist/verification-gate.d.ts.map +1 -1
  248. package/dist/verification-gate.js +218 -427
  249. package/dist/verification-gate.js.map +1 -1
  250. package/dist/version.d.ts +1 -1
  251. package/dist/version.js +1 -1
  252. package/dist/working-memory.d.ts +187 -0
  253. package/dist/working-memory.d.ts.map +1 -0
  254. package/dist/working-memory.js +767 -0
  255. package/dist/working-memory.js.map +1 -0
  256. package/dist/workspace-checkpoint.d.ts +88 -0
  257. package/dist/workspace-checkpoint.d.ts.map +1 -0
  258. package/dist/workspace-checkpoint.js +326 -0
  259. package/dist/workspace-checkpoint.js.map +1 -0
  260. package/dist/workspace-restore.d.ts +68 -0
  261. package/dist/workspace-restore.d.ts.map +1 -0
  262. package/dist/workspace-restore.js +604 -0
  263. package/dist/workspace-restore.js.map +1 -0
  264. package/dist/workspace.d.ts +0 -9
  265. package/dist/workspace.d.ts.map +1 -1
  266. package/dist/workspace.js +0 -17
  267. package/dist/workspace.js.map +1 -1
  268. package/loop_policy.json +5 -19
  269. package/package.json +2 -2
  270. package/skills/forge/SKILL.md +212 -0
  271. package/skills/forge/references/evidence-and-contracts.md +204 -0
  272. package/skills/forge/references/recovery-and-views.md +188 -0
  273. package/dist/cognitive-facts.d.ts +0 -30
  274. package/dist/cognitive-facts.d.ts.map +0 -1
  275. package/dist/cognitive-facts.js +0 -113
  276. package/dist/cognitive-facts.js.map +0 -1
  277. package/dist/loop-extras-parser.d.ts +0 -81
  278. package/dist/loop-extras-parser.d.ts.map +0 -1
  279. package/dist/loop-extras-parser.js +0 -213
  280. package/dist/loop-extras-parser.js.map +0 -1
  281. package/dist/loop-projection.d.ts +0 -6
  282. package/dist/loop-projection.d.ts.map +0 -1
  283. package/dist/loop-projection.js +0 -28
  284. package/dist/loop-projection.js.map +0 -1
  285. package/skills/perception/SKILL.md +0 -370
@@ -5,191 +5,186 @@
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
7
  import { deriveConfiguredCapability, getPolicy } from "./policy.js";
8
- import { AgentStatus, makeLoopCompileResponse, makeLoopObjective, makeConstraintMeta, makeMilestoneSummary, makeRollingSummary, } from "./protocol.js";
8
+ import { AgentStatus, makeLoopCompileResponse, makeLoopObjective, makeConstraintMeta, } from "./protocol.js";
9
9
  import { createCanonicalLoopState, renderCanonicalStateMarkdown, } from "./canonical-state.js";
10
- import { decodeBacktrackRecord, decodeRound, entryLineage, entryCriteriaMet, entryCriteriaRemaining, entryActiveConstraints, entryRetractedConstraints, machineGitMotionSeries, derivationRounds, } from "./committed-round.js";
10
+ import { pendingBacktrackRecord, roundResultFromCommittedRound, } from "./committed-round.js";
11
+ import { buildCommittedHistory, historyWindow, operatorAcceptanceKey, } from "./history.js";
12
+ import { LegacyHistoryError, OperatorAcceptanceConflictError } from "./errors.js";
13
+ import { deriveProgressWindow, } from "./progress.js";
14
+ import { deriveCriterionStatuses as deriveCriterionStatusesFromViews } from "./criterion-status.js";
15
+ import { derivePromptFreshness, UNKNOWN_PROMPT_FRESHNESS, } from "./prompt-freshness.js";
11
16
  import { NO_IN_FLIGHT_ROUND, deriveRoundFacts } from "./round-facts.js";
12
17
  import { recoveryBriefLines } from "./enforcement-gate.js";
13
18
  import { deriveSubGoals, duplicateEmergedDeclarations } from "./subgoal-state.js";
14
- import { claimedMetCriteria, claimedRemainingCriteria } from "./self-eval.js";
19
+ import { deriveConstraintProvenance, resolveConstraintRetractions, } from "./constraint-lifecycle.js";
15
20
  import { assemblePromptArtifact } from "./prompt-assembler.js";
21
+ import { derivePromptContextBoundary } from "./context-boundary.js";
22
+ import { deriveWorkingMemory } from "./working-memory.js";
16
23
  import { renderSelfEvalBlock, } from "./submission-guidance.js";
17
24
  import { decidePromptLevel, } from "./prompt-policy.js";
18
- import { criterionRefMatches, deriveCriterionId, deriveItemId, normalizeText, unique, entryRound, } from "./token-utils.js";
25
+ import { criterionRefMatches, deriveConstraintId, deriveCriterionId, normalizeText, unique, uniqueNormalized, } from "./token-utils.js";
19
26
  function contextEntries(context) {
20
27
  if (!context || !Array.isArray(context.results))
21
28
  return [];
22
29
  return context.results.filter((value) => value !== null && typeof value === "object" && !Array.isArray(value));
23
30
  }
24
- function loopEntries(loopId, context) {
25
- return contextEntries(context)
26
- .filter((entry) => entry.loop_id === loopId || entryLineage(entry).loop_id === loopId)
27
- .sort((a, b) => entryRound(a) - entryRound(b));
28
- }
29
- /** v3.3.1: The canonical entry for a round — its compile-time lineage entry
30
- * (task_type "loop_lineage"; engine hydration merges the committed
31
- * decision into it, so one round = one merged lineage entry in the
32
- * compiler's view). Event/journal entries (delegation journals, gate
33
- * records) share lineage.round with the round but carry none of its
34
- * compile-time fields; round-level consumers that matched on the round
35
- * number alone could hit them first (they sort after the lineage entry of
36
- * the same round), producing an empty goal_id / task / constraints_active
37
- * and forcing spurious L2 recovery. A direct :feedback entry is accepted
38
- * when the caller supplies a raw committed view instead of hydration. */
39
- function roundCanonicalEntry(entries, round) {
40
- const candidates = entries.filter((entry) => entryRound(entry) === round);
41
- return (candidates.find((entry) => entry.task_type === "loop_lineage") ??
42
- candidates.find((entry) => String(entry.task_id ?? "").endsWith(":feedback")) ??
43
- // Untyped compiler inputs are treated as round views, not events. Known event
44
- // types (delegation journals, gate records) are excluded explicitly so
45
- // they can never shadow a round again.
46
- candidates.find((entry) => {
47
- const type = String(entry.task_type ?? "");
48
- return type === "" || (!type.startsWith("delegation") && !type.endsWith("journal"));
49
- }) ??
50
- null);
51
- }
52
31
  export function computeGoalTextHash(text) {
53
32
  const normalized = text.trim().replace(/\s+/g, " ").toLowerCase();
54
33
  return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
55
34
  }
56
- export function deriveGoalId(loopId, task, explicit = "") {
57
- return explicit.trim() || `${loopId}:${task.trim().toLowerCase()
35
+ /** v4.0.6: goal identity derives from the WORKING objective (the latest
36
+ * committed objective_refinement, else Operator Acceptance). There is no
37
+ * caller-supplied goal_id override. */
38
+ export function deriveGoalId(loopId, task) {
39
+ const slug = task.trim().toLowerCase()
58
40
  .replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
59
41
  .replace(/^-|-$/g, "")
60
- .slice(0, 48) || computeGoalTextHash(task)}`;
42
+ .slice(0, 48);
43
+ // Keep the readable prefix while making the identity depend on the full
44
+ // normalized objective; shared prefixes must not collapse distinct goals.
45
+ return `${loopId}:${slug || "goal"}:${computeGoalTextHash(task)}`;
61
46
  }
62
47
  export function getPreviousRound(loopId, round, context) {
63
- const entry = roundCanonicalEntry(loopEntries(loopId, context), round);
64
- if (!entry)
48
+ const history = buildCommittedHistory(contextEntries(context));
49
+ return previousRoundFromWindow(historyWindow(history, Number.POSITIVE_INFINITY), round);
50
+ }
51
+ function previousRoundFromWindow(window, round) {
52
+ const view = window.find((candidate) => candidate.round === round);
53
+ if (!view)
65
54
  return null;
66
- const data = entryLineage(entry);
67
55
  return {
68
56
  round,
69
- goal_id: typeof data.goal_id === "string" ? data.goal_id : "",
70
- goal_text_hash: typeof data.goal_text_hash === "string" ? data.goal_text_hash : "",
71
- success: typeof entry.success === "boolean"
72
- ? entry.success
73
- : typeof data.success === "boolean" ? data.success : false,
74
- task: typeof data.task === "string"
75
- ? data.task
76
- : typeof entry.task === "string" ? entry.task : "",
77
- constraints_active: Array.isArray(data.constraints_active)
78
- ? data.constraints_active.filter((value) => typeof value === "string")
79
- : [],
80
- output_summary: typeof entry.output_summary === "string"
81
- ? entry.output_summary
82
- : typeof data.output_summary === "string" ? data.output_summary : "",
57
+ goal_id: view.compiled?.goalId ?? "",
83
58
  };
84
59
  }
85
- function latestObjective(loopId, context) {
86
- const candidates = loopEntries(loopId, context).reverse();
87
- for (const entry of candidates) {
88
- const raw = entry.loop_objective;
89
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
90
- continue;
91
- const value = raw;
92
- return makeLoopObjective({
93
- objective: typeof value.objective === "string" ? value.objective : "",
94
- success_criteria: Array.isArray(value.success_criteria)
95
- ? value.success_criteria.filter((item) => typeof item === "string")
96
- : [],
97
- hard_constraints: Array.isArray(value.hard_constraints)
98
- ? value.hard_constraints.filter((item) => typeof item === "string")
99
- : [],
100
- created_at_round: typeof value.created_at_round === "number" ? value.created_at_round : 1,
101
- loop_id: loopId,
102
- version: typeof value.version === "number" ? value.version : 1,
103
- refinement_history: Array.isArray(value.refinement_history)
104
- ? value.refinement_history.filter((item) => typeof item === "string")
105
- : [],
106
- });
60
+ /** v4.0.6: THE working objective.
61
+ *
62
+ * - The immutable operator surface is the committed `OperatorAcceptance`.
63
+ * Acceptance is byte-identical across copies (enforced by the history
64
+ * loader), so it does not need a round or branch window.
65
+ * - The objective TEXT is the latest committed `objective_refinement` on the
66
+ * CURRENT BRANCH, else the acceptance task. A refinement from a branch
67
+ * that was rolled back never applies.
68
+ * - Success criteria and hard constraints ALWAYS come from the acceptance; a
69
+ * refinement can never rewrite them.
70
+ *
71
+ * Read-only callers that only need criteria/hard constraints read those
72
+ * fields; they must not treat `objective` as the operator acceptance text. */
73
+ export function latestObjective(loopId, context) {
74
+ const history = buildCommittedHistory(contextEntries(context));
75
+ const acceptanceView = history.rounds.find((view) => view.operatorAcceptance) ??
76
+ history.views.find((view) => view.operatorAcceptance);
77
+ if (!acceptanceView?.operatorAcceptance)
78
+ return null;
79
+ const acceptance = acceptanceView.operatorAcceptance;
80
+ let objective = acceptance.task;
81
+ for (const view of history.rounds) {
82
+ const refinement = view.objectiveRefinement;
83
+ if (typeof refinement === "string" && refinement.trim().length > 0) {
84
+ objective = refinement;
85
+ }
107
86
  }
108
- return null;
87
+ return makeLoopObjective({
88
+ objective,
89
+ success_criteria: [...acceptance.success_criteria],
90
+ hard_constraints: [...acceptance.hard_constraints],
91
+ created_at_round: 1,
92
+ loop_id: loopId,
93
+ });
109
94
  }
110
- function evolveObjective(request, context) {
111
- const current = request.loop_objective ?? latestObjective(request.loop_id, context) ??
95
+ function evolveObjective(request, context, operatorAcceptance) {
96
+ // v3.9.7: committed acceptance is authoritative once it exists. A transient
97
+ // request may bootstrap the objective for round 1 or a library caller with
98
+ // no committed objective; it may not rewrite operator acceptance after a
99
+ // committed objective exists.
100
+ const committed = latestObjective(request.loop_id, context);
101
+ const allowRequestObjective = request.loop_objective !== null &&
102
+ (request.round === 1 || committed === null);
103
+ // v4.0.6: the accepted objective's TEXT is the working objective
104
+ // (committed refinement or acceptance task); criteria/hard constraints come
105
+ // from the immutable acceptance. The refinement never rewrites acceptance.
106
+ const accepted = operatorAcceptance
107
+ ? makeLoopObjective({
108
+ objective: committed?.objective || operatorAcceptance.task,
109
+ success_criteria: [...operatorAcceptance.success_criteria],
110
+ hard_constraints: [...operatorAcceptance.hard_constraints],
111
+ created_at_round: 1,
112
+ loop_id: request.loop_id,
113
+ })
114
+ : null;
115
+ const current = accepted ??
116
+ (allowRequestObjective ? request.loop_objective : null) ??
117
+ committed ??
112
118
  makeLoopObjective({
113
119
  objective: request.task,
114
120
  success_criteria: ["Task goal achieved with verifiable evidence"],
115
- hard_constraints: unique(request.constraints_from_plan),
121
+ hard_constraints: unique(request.round === 1 || committed === null ? request.constraints_from_plan : []),
116
122
  created_at_round: 1,
117
123
  loop_id: request.loop_id,
118
124
  });
119
- const last = request.last_round_result;
120
- const history = [...(current.refinement_history ?? [])];
121
- let objective = current.objective || request.task;
122
- let version = current.version ?? 1;
123
- if (last?.objective_refinement?.trim()) {
124
- const refinement = last.objective_refinement.trim();
125
- // M5: fold each refinement exactly once per persisted objective state.
126
- // A retry recompiles the same round against the same committed
127
- // last_round_result; without this guard every retry attempt re-appends
128
- // the refinement and inflates the version — the objective text and the
129
- // state file grew "Refinement: X" once per attempt.
130
- if (!history.includes(refinement)) {
131
- history.push(refinement);
132
- objective = `${objective}\nRefinement: ${refinement}`;
133
- version++;
134
- }
135
- }
136
- const revisions = new Map((last?.revised_success_criteria ?? []).map((item) => [item.old, item.new]));
125
+ // The operator objective is the committed acceptance; no Agent report
126
+ // rewrites it.
137
127
  return makeLoopObjective({
138
128
  ...current,
139
- objective,
140
- version,
141
- refinement_history: history.slice(-getPolicy().evolution.max_objective_versions),
142
- success_criteria: unique(current.success_criteria.map((item) => revisions.get(item) ?? item)),
143
- hard_constraints: unique(current.hard_constraints),
129
+ objective: current.objective || request.task,
130
+ success_criteria: uniqueNormalized(current.success_criteria),
131
+ hard_constraints: uniqueNormalized(current.hard_constraints),
144
132
  loop_id: request.loop_id,
145
133
  });
146
134
  }
147
- function evolveConstraints(request, objective, previous, context) {
148
- const retired = unique(request.last_round_result?.retracted_constraints ?? []);
149
- const retiredSet = new Set(retired);
150
- const discovered = unique(request.last_round_result?.discovered_constraints ?? [])
151
- .slice(0, getPolicy().evolution.max_discovered_constraints_per_round);
152
- // v2.14: agent-declared retractions of plan/hard/criteria constraints
153
- // persist for constraints.retire_window rounds (they are re-merged from
154
- // the request/objective every round, so without this the documented
155
- // "Removed from the active constraint set" never held for them). The
156
- // retraction rounds are derived from vault entries — no new persistence
157
- // format. After the window the constraint returns (hard/plan/criteria
158
- // constraints never auto-decay — only agent action removes them, and the
159
- // action's memory expires).
160
- const window = getPolicy().constraints.retire_window;
161
- const lastRetractedRound = new Map();
162
- if (window > 0) {
163
- for (const entry of loopEntries(request.loop_id, context)) {
164
- const retractedList = entryLineage(entry).retracted_constraints;
165
- if (!Array.isArray(retractedList))
166
- continue;
167
- const rnd = entryRound(entry);
168
- for (const text of retractedList) {
169
- if (typeof text !== "string" || !text)
170
- continue;
171
- const prev = lastRetractedRound.get(text);
172
- if (prev === undefined || rnd > prev)
173
- lastRetractedRound.set(text, rnd);
174
- }
175
- }
176
- }
177
- const active = unique([
178
- ...(previous?.constraints_active ?? []),
179
- ...request.constraints_from_plan,
135
+ function evolveConstraints(request, objective, previous, windowRounds, roundOnePlan, preservedDiscoveries, preservedRound) {
136
+ // The active set at the START of the round that produced the retraction:
137
+ // the previous committed compile's stamped `constraints_active`.
138
+ const carried = uniqueNormalized(previous.view?.activeConstraints ?? []);
139
+ const activeTargets = carried.map((text) => ({
140
+ text,
141
+ provenance: deriveConstraintProvenance({
142
+ text,
143
+ objective,
144
+ windowRounds,
145
+ roundOnePlan,
146
+ preservedDiscoveries,
147
+ preservedRound,
148
+ }),
149
+ }));
150
+ const previouslyRetired = unique(windowRounds.flatMap((round) => round.retractedConstraints ?? []));
151
+ const retraction = resolveConstraintRetractions({
152
+ targets: previous.result?.retracted_constraints ?? [],
153
+ active: activeTargets,
154
+ // The compiler does not see this submission's new discoveries; the
155
+ // boundary resolves the same target set and reports same-round cases.
156
+ newDiscoveries: [],
157
+ previouslyRetired,
158
+ });
159
+ const effectiveKeys = new Set(retraction.effective.map(normalizeText));
160
+ // Only round 1 accepts a plan input. After round 1 the active set is carried
161
+ // from committed compile facts; a late request value is not a fact.
162
+ const base = uniqueNormalized([
163
+ ...carried,
164
+ ...roundOnePlan,
180
165
  ...objective.hard_constraints,
181
166
  ...objective.success_criteria,
182
- ...discovered,
183
- ]).filter((item) => {
184
- if (retiredSet.has(item))
185
- return false;
186
- const retractedRound = lastRetractedRound.get(item);
187
- if (retractedRound !== undefined && request.round - retractedRound < window) {
188
- return false;
189
- }
190
- return true;
191
- }).slice(0, getPolicy().evolution.max_active_constraints);
192
- return { active, retired };
167
+ ...preservedDiscoveries,
168
+ ]).filter((text) => !effectiveKeys.has(normalizeText(text)));
169
+ // Only the previous round's NEW discoveries are added after retraction:
170
+ // a new declaration in the retraction round survives the retraction.
171
+ const allDiscovered = unique(previous.result?.discovered_constraints ?? []);
172
+ const discovered = allDiscovered
173
+ .slice(0, getPolicy().evolution.max_discovered_constraints_per_round);
174
+ const discoveryDropped = allDiscovered.length - discovered.length;
175
+ const active = uniqueNormalized([...base, ...discovered]);
176
+ const provenance = new Map();
177
+ for (const text of active) {
178
+ provenance.set(text, deriveConstraintProvenance({
179
+ text,
180
+ objective,
181
+ windowRounds,
182
+ roundOnePlan,
183
+ preservedDiscoveries,
184
+ preservedRound,
185
+ }));
186
+ }
187
+ return { active, retired: retraction.effective, provenance, discoveryDropped };
193
188
  }
194
189
  // ═══════════════════════════════════════════════════════════════════════════
195
190
  // Hierarchical Summary (v2.1)
@@ -244,18 +239,27 @@ export function deriveRecurringFlags(committedRounds) {
244
239
  * — the dashboard then shows no machine row. Display-only companion to the
245
240
  * R4/R5 exculpatory signal. */
246
241
  function deriveMachineStatus(rounds, currentRound) {
247
- const series = machineGitMotionSeries(rounds, currentRound, 3);
248
- if (series === null)
242
+ // v3.9.9: the dashboard shows the SAME fact the stall rule consumes.
243
+ const window = deriveProgressWindow(rounds, currentRound, getPolicy().engine.stall_lookback_rounds);
244
+ if (window.incomplete || window.state === "not_applicable")
249
245
  return undefined;
250
- const motionRounds = series.filter(Boolean).length;
251
- return { windowRounds: 3, gitMotion: motionRounds > 0, motionRounds };
246
+ const changedRounds = window.roundStates.filter((entry) => entry.state === "changed").length;
247
+ return {
248
+ windowRounds: window.lookback,
249
+ machineProgress: window.state,
250
+ changedRounds,
251
+ providers: window.providers,
252
+ };
252
253
  }
253
254
  /** v3.2: Derive per-criterion status — the "goal → criteria → evidence"
254
- * vertical view. Each objective criterion gets: met/remaining/unknown
255
- * (from per-round criterion_claims, ID-first or normalized-exact matching),
256
- * the round it was first reported met, and the sub-goals a contract item
257
- * referencing it also names (explicit `subgoal_refs`, never a text guess).
258
- * Zero persistence — re-derived from the vault every compile. */
255
+ * vertical view. Each objective criterion gets claimed/remaining/unknown
256
+ * (from the `cr-` entries of per-round execution_report.claims, ID-first or
257
+ * normalized-exact matching) and the round it was first reported met. Zero
258
+ * persistence — re-derived from committed rounds every compile.
259
+ *
260
+ * v3.9.6: the derivation lives in `criterion-status.ts` so the submission
261
+ * boundary and guidance can read the same rows without importing this module
262
+ * (which would close the `loop-compiler → submission-guidance` cycle). */
259
263
  export function deriveCriterionStatuses(loopId, context, objective, currentRound, lastRoundResult,
260
264
  /** v3.8.1: the criterion machine facts from the shared bundle. A criterion
261
265
  * is `verified` / `contradicted` / `insufficient` only through an item that
@@ -263,54 +267,12 @@ export function deriveCriterionStatuses(loopId, context, objective, currentRound
263
267
  * is derived over the WHOLE committed history, so it survives its contract
264
268
  * closing (see `deriveCriterionFacts`). */
265
269
  criterionFacts = []) {
266
- if (!objective || objective.success_criteria.length === 0)
267
- return [];
268
- const entries = loopEntries(loopId, context).filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
269
- // The current round's report lives in last_round_result (the vault only
270
- // gains it on commit); treat it as the report for round currentRound - 1.
271
- const lastEv = lastRoundResult?.execution_report;
272
- const lastMet = claimedMetCriteria(lastEv);
273
- const lastRemaining = claimedRemainingCriteria(lastEv);
274
- const lastEntry = entries[entries.length - 1] ?? null;
275
- const lastEntryRemaining = lastEntry ? entryCriteriaRemaining(lastEntry) : [];
276
- // v3.8.1: criterion → machine status + explicit sub-goal links, from the
277
- // shared `RoundFacts` bundle (whole committed history). It used to be
278
- // re-derived here from the ACTIVE contract, which made both maps empty the
279
- // moment the contract closed — the criterion's machine fact, and the
280
- // sub-goal links carried on the same item, disappeared while the sub-goal
281
- // side of the very same fact survived. One derivation, one window.
282
- const factsByCriterion = new Map(criterionFacts.map((fact) => [fact.criterion_id, fact]));
283
- return objective.success_criteria.map((text) => {
284
- let metAtRound = null;
285
- for (const entry of entries) {
286
- const rnd = entryRound(entry);
287
- if (entryCriteriaMet(entry).some((met) => criteriaMatch(met, text))) {
288
- metAtRound = rnd;
289
- break; // first report wins — chronological scan
290
- }
291
- }
292
- if (metAtRound === null && lastMet.some((met) => criteriaMatch(met, text))) {
293
- metAtRound = currentRound - 1;
294
- }
295
- // v3.8: a claim reaches `claimed` at most. Only a machine fact about a
296
- // contract item that references this criterion can go further.
297
- const claimedStatus = metAtRound !== null
298
- ? "claimed"
299
- : lastRemaining.some((remaining) => criteriaMatch(remaining, text)) ||
300
- lastEntryRemaining.some((remaining) => criteriaMatch(remaining, text))
301
- ? "remaining"
302
- : "unknown";
303
- const fact = factsByCriterion.get(deriveCriterionId(text));
304
- const status = fact?.status ?? claimedStatus;
305
- return {
306
- id: deriveCriterionId(text),
307
- text,
308
- status,
309
- ...(metAtRound !== null ? { met_at_round: metAtRound } : {}),
310
- // v3.8.1: explicit refs only — empty when no contract item links this
311
- // criterion to a sub-goal.
312
- related_subgoal_ids: fact?.related_subgoal_ids ?? [],
313
- };
270
+ const history = buildCommittedHistory(contextEntries(context));
271
+ return deriveCriterionStatusesFromViews({
272
+ rounds: historyWindow(history, currentRound),
273
+ currentRound,
274
+ objective,
275
+ criterionFacts,
314
276
  });
315
277
  }
316
278
  /** v3.8: Rank item statuses so the strongest machine fact wins when several
@@ -323,186 +285,16 @@ export function criteriaMatch(a, b) {
323
285
  // v3.9.2: ONE identity rule, shared with the prompt and submission boundary.
324
286
  return criterionRefMatches(a, b);
325
287
  }
326
- /** Detect newly met criteria by comparing the current entry's
327
- * met criterion_claims against the previous entry's.
328
- * v2.11/v3.8.1: id-first (cr-XXXXXXXX), then normalized-exact text —
329
- * no similarity.
330
- * Returns empty array when there is no previous entry — the first
331
- * entry's criteria are the baseline, not a "new" event. */
332
- function detectNewCriteria(current, previous) {
333
- const currMet = entryCriteriaMet(current);
334
- if (currMet.length === 0)
335
- return [];
336
- if (!previous) {
337
- // First round with criteria is the baseline — not a milestone trigger.
338
- return [];
339
- }
340
- const prevMet = entryCriteriaMet(previous);
341
- if (prevMet.length === 0)
342
- return currMet;
343
- return currMet.filter((curr) => prevMet.every((prev) => !criteriaMatch(curr, prev)));
344
- }
345
- /** Build a single MilestoneSummary from a range of completed round entries. */
346
- function buildMilestoneFromEntries(phaseEntries, startRound, endRound, label, kind) {
347
- const last = phaseEntries.at(-1);
348
- const lastView = last ? decodeRound(last) : null;
349
- const outcomeRaw = lastView
350
- ? (lastView.outputSummary ?? "")
351
- : last && typeof last.output_summary === "string" ? last.output_summary : "";
352
- const outcomeMax = getPolicy().checkpoint.outcome_max_chars;
353
- const outcome = outcomeRaw.length > outcomeMax
354
- ? outcomeRaw.slice(0, outcomeMax) + "…"
355
- : outcomeRaw;
356
- const carried = last ? entryActiveConstraints(last) : [];
357
- const resolved = unique(phaseEntries.flatMap((e) => entryRetractedConstraints(e)));
358
- return makeMilestoneSummary({
359
- label,
360
- round_range: { start: startRound, end: endRound },
361
- outcome,
362
- carried_constraints: carried,
363
- resolved_constraints: resolved,
364
- kind,
365
- generated_at_round: endRound,
366
- });
367
- }
368
- /** v3.8.1: the milestone history bound. Applied identically at every prompt
369
- * level and in the state file, so the milestone set is a function of
370
- * committed facts rather than of which level happened to compile. (The
371
- * v3.0.1 sampler chose which milestones to keep from an UNBOUNDED list and
372
- * did nothing at L2 — so the state file’s phase history depended on the
373
- * prompt level and had no bound of its own.) */
374
- const MILESTONE_HISTORY_CAP = 50;
375
- export function buildRollingSummary(loopId, currentRound, context, sinceRound = 0) {
376
- const policy = getPolicy().summary;
377
- const allEntries = loopEntries(loopId, context);
378
- // ── Phase 1: Recent Window (preserves existing behavior) ──
379
- const windowEntries = allEntries
380
- .filter((entry) => {
381
- const round = entryRound(entry);
382
- return round >= sinceRound && round < currentRound;
383
- })
384
- .slice(-policy.window);
385
- const outcomes = [];
386
- for (const entry of windowEntries) {
387
- const view = decodeRound(entry);
388
- const data = entryLineage(entry);
389
- const round = entryRound(entry);
390
- const success = view ? view.success : entry.success ?? data.success;
391
- const summary = view
392
- ? view.outputSummary ?? ""
393
- : typeof entry.output_summary === "string"
394
- ? entry.output_summary
395
- : typeof data.output_summary === "string" ? data.output_summary : "";
396
- // v2.12: Declared outcome wins the label; the default wording stays
397
- // "accepted" for zero regression on existing loop output.
398
- const declared = view ? view.outcome : entry.outcome;
399
- const outcomeLabel = declared === "success" || declared === "partial" ||
400
- declared === "failed" || declared === "blocked"
401
- ? declared
402
- : success === false ? "failed" : "accepted";
403
- if (summary)
404
- outcomes.push(`[R${round}] ${outcomeLabel}: ${summary}`);
405
- }
406
- // ── Phase 2: Milestone Accumulation ──
407
- // Scan all completed entries in round order. For each entry, check three
408
- // trigger signals in priority order: agent_declared > criteria_milestone > auto.
409
- const completedEntries = allEntries.filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
410
- const milestones = [];
411
- let lastMilestoneRound = 0;
412
- for (let i = 0; i < completedEntries.length; i++) {
413
- const entry = completedEntries[i];
414
- const rnd = entryRound(entry);
415
- const lin = entryLineage(entry);
416
- // Signal 1: Agent-declared checkpoint
417
- const isAgentCheckpoint = lin.compression_checkpoint === true;
418
- // Signal 2: New criteria met (compare with previous vault entry)
419
- const prev = i > 0 ? completedEntries[i - 1] : null;
420
- const newCriteria = detectNewCriteria(entry, prev);
421
- if (isAgentCheckpoint || newCriteria.length > 0) {
422
- const startRound = lastMilestoneRound + 1;
423
- const phaseEntries = completedEntries.filter((e) => {
424
- const er = entryRound(e);
425
- return er >= startRound && er <= rnd;
426
- });
427
- const kind = isAgentCheckpoint
428
- ? "agent_declared"
429
- : "criteria_milestone";
430
- const label = isAgentCheckpoint
431
- ? (typeof lin.checkpoint_label === "string" && lin.checkpoint_label.trim()
432
- ? lin.checkpoint_label.trim()
433
- : `Round ${rnd}`)
434
- : `Completed: ${newCriteria.join(", ")}`;
435
- milestones.push(buildMilestoneFromEntries(phaseEntries, startRound, rnd, label, kind));
436
- lastMilestoneRound = rnd;
437
- }
438
- }
439
- // Signal 3: Safety net — auto milestone when gap exceeds milestone_interval.
440
- // v3.3.1: the policy contract (milestone_interval: "only fires when no
441
- // agent_declared or criteria_milestone has been created for this many
442
- // rounds") includes loops that NEVER created a milestone — but the guard
443
- // `lastMilestoneRound > 0` made the gap start from the (nonexistent) first
444
- // milestone, so a checkpoint-less loop could run forever with an empty
445
- // Phase History and no memory skeleton. With lastMilestoneRound == 0 the
446
- // gap is measured from round 1 and the safety net fires from the very
447
- // first interval.
448
- const gapSinceLast = (currentRound - 1) - lastMilestoneRound;
449
- if (gapSinceLast >= policy.milestone_interval &&
450
- completedEntries.length > 0) {
451
- const startRound = lastMilestoneRound + 1;
452
- const endRound = currentRound - 1;
453
- const phaseEntries = completedEntries.filter((e) => {
454
- const er = entryRound(e);
455
- return er >= startRound && er <= endRound;
456
- });
457
- if (phaseEntries.length > 0) {
458
- milestones.push(buildMilestoneFromEntries(phaseEntries, startRound, endRound, `Rounds ${startRound}–${endRound}`, "auto"));
459
- }
460
- }
461
- // v3.8.1: one bound, applied identically at every prompt level. The v3.0.1
462
- // sampler kept head anchors + a newest tail + an even middle sample, and
463
- // deliberately did nothing at L2 — so the milestone set written to the state
464
- // file depended on which level happened to compile, not on committed facts.
465
- // It also did not bound growth at all (it only chose WHICH milestones to
466
- // keep from an unbounded list), which is why the cap has to live here.
467
- const cappedMilestones = milestones.slice(-MILESTONE_HISTORY_CAP);
468
- if (windowEntries.length === 0 && cappedMilestones.length === 0)
469
- return null;
470
- return makeRollingSummary({
471
- key_outcomes: unique(outcomes),
472
- rounds_sampled: windowEntries.length,
473
- generated_at_round: currentRound,
474
- failed_patterns: [],
475
- milestones: cappedMilestones,
476
- });
477
- }
478
288
  // ═══════════════════════════════════════════════════════════════════════════
479
289
  // Sub-Goal Structured Tracking (v2.2)
480
290
  // ═══════════════════════════════════════════════════════════════════════════
481
- /** v2.11: Derive a stable constraint ID from its text hash (c-XXXXXXXX).
482
- * Same hash strategy as SubGoal — deterministic across rounds. */
483
- export function deriveConstraintId(text) {
484
- return "c-" + deriveItemId(text);
485
- }
486
- // v3.8.1: `deriveCriterionId` / `isCriterionId` moved to token-utils.ts, where
487
- // the other stable-id helpers live — the criterion fact derivation in
488
- // round-facts.ts needs them, and defining them here would make
489
- // round-facts ⇄ loop-compiler an import cycle. Re-exported so this module's
490
- // public surface ("./compiler") is unchanged.
491
- export { deriveCriterionId, isCriterionId } from "./token-utils.js";
291
+ // v3.8.1/v3.9.7: the stable-id helpers live in token-utils.ts, where the
292
+ // criterion/constraint fact derivations can use them without an import
293
+ // cycle. Re-exported so this module's public surface is unchanged.
294
+ export { deriveConstraintId, deriveCriterionId, isCriterionId, } from "./token-utils.js";
492
295
  // ═══════════════════════════════════════════════════════════════════════════
493
296
  // Time-Aware Constraint Management (v2.3)
494
297
  // ═══════════════════════════════════════════════════════════════════════════
495
- /** Determine the source of a constraint by matching against known sources. */
496
- function constraintSource(text, objective, constraintsFromPlan) {
497
- const norm = text.toLowerCase().trim();
498
- if (objective.hard_constraints.some((c) => c.toLowerCase().trim() === norm))
499
- return "hard";
500
- if (constraintsFromPlan.some((c) => c.toLowerCase().trim() === norm))
501
- return "plan";
502
- if (objective.success_criteria.some((c) => c.toLowerCase().trim() === norm))
503
- return "criteria";
504
- return "discovered";
505
- }
506
298
  /** True when a reported violation text matches a constraint text.
507
299
  * v2.11: ID-first — an exact `c-XXXXXXXX` match against the derived id.
508
300
  * v3.8.1: the Jaccard fallback is gone; the remaining arm requires the
@@ -516,22 +308,13 @@ function matchesConstraintText(text, violation) {
516
308
  }
517
309
  return normalizeText(text) === normalizeText(violation);
518
310
  }
519
- function findLastViolatedRound(text, vaultEntries) {
311
+ function findLastViolatedRound(text, rounds) {
520
312
  let latest = 0;
521
- for (const entry of vaultEntries) {
522
- const rnd = entryRound(entry);
523
- if (rnd < 1)
524
- continue;
525
- const view = decodeRound(entry);
526
- const violations = view
527
- ? view.constraintViolations ?? []
528
- : Array.isArray(entry.constraint_violations)
529
- ? entry.constraint_violations.filter((v) => typeof v === "string")
530
- : [];
531
- for (const v of violations) {
313
+ for (const view of rounds) {
314
+ for (const v of view.constraintViolations ?? []) {
532
315
  if (matchesConstraintText(text, v)) {
533
- if (rnd > latest)
534
- latest = rnd;
316
+ if (view.round > latest)
317
+ latest = view.round;
535
318
  break;
536
319
  }
537
320
  }
@@ -549,18 +332,10 @@ function findLastViolatedRound(text, vaultEntries) {
549
332
  * and last_violated_at_round for the L1 collapse "(violated this round)"
550
333
  * annotation (diffConstraints compares it to the current round).
551
334
  */
552
- function manageConstraintLifecycle(active, objective, constraintsFromPlan, vaultContext, currentRound, lastRoundViolations = []) {
553
- const allEntries = loopEntries(objective.loop_id || "", vaultContext);
554
- const completedEntries = allEntries.filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound);
335
+ function manageConstraintLifecycle(active, provenance, windowRounds) {
555
336
  const metadata = [];
556
337
  for (const text of active) {
557
- const source = constraintSource(text, objective, constraintsFromPlan);
558
- // v3.2.1: a violation reported in THIS round (last_round_result) counts
559
- // as a violation at currentRound — the previous rounds' scan cannot see
560
- // it (this round is not committed yet). This makes the L1 collapse
561
- // render "(violated this round)" (diffConstraints compares
562
- // last_violated_at_round === round).
563
- const violatedThisRound = lastRoundViolations.some((v) => matchesConstraintText(text, v));
338
+ const source = provenance.get(text) ?? { source: "unrecorded" };
564
339
  // v3.3.1: one text, one identity — a success-criterion text that also
565
340
  // lives in the merged active set belongs to the cr-XXXXXXXX namespace
566
341
  // (its own Success Criteria section and the verification gate's
@@ -569,50 +344,73 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
569
344
  // and an agent echoing the active-list ID into its criterion claims
570
345
  // could never match.
571
346
  metadata.push(makeConstraintMeta({
572
- id: source === "criteria" ? deriveCriterionId(text) : deriveConstraintId(text),
347
+ id: source.source === "criteria" ? deriveCriterionId(text) : deriveConstraintId(text),
573
348
  text,
574
- last_violated_at_round: violatedThisRound
575
- ? currentRound
576
- : findLastViolatedRound(text, completedEntries),
577
- source,
349
+ last_violated_at_round: findLastViolatedRound(text, windowRounds),
350
+ source: source.source,
351
+ ...(source.source_round !== undefined ? { source_round: source.source_round } : {}),
578
352
  }));
579
353
  }
580
354
  return { active, metadata };
581
355
  }
582
- function levelDecision(request, context) {
583
- const previous = getPreviousRound(request.loop_id, request.round - 1, context);
584
- const last = request.last_round_result;
585
- const hasNewInformation = Boolean(request.new_since_last_round.trim() ||
586
- last?.discovered_constraints?.length ||
587
- last?.objective_refinement?.trim() ||
588
- last?.emerged_subtasks?.length ||
589
- last?.retracted_constraints?.length ||
590
- last?.revised_success_criteria?.length ||
591
- last?.wrong_assumptions?.length);
592
- // v2.14: a backtrack committed for this round means the loop is
593
- // re-walking restored state — the recovery boundary forces L2
594
- // rehydration (the decidePromptLevel recovery_boundary branch existed
595
- // but no caller ever set it).
596
- const recoveryBoundary = loopEntries(request.loop_id, context)
597
- .some((entry) => entryLineage(entry).committed_action === "backtrack" &&
598
- entryRound(entry) === request.round);
356
+ /** Resolve the ONE previous-round fact bundle for a compile.
357
+ *
358
+ * It is built from the committed window only. A retry's verification findings
359
+ * are authoritative even when empty: the retry must never inherit the
360
+ * previous committed round's flags. */
361
+ function resolvePreviousRoundFacts(request, entries, windowRounds) {
362
+ const view = windowRounds.find((round) => round.round === request.round - 1) ?? null;
363
+ const recoveryBoundary = pendingBacktrackRecord(entries, request.round) !== null;
364
+ const inFlight = (request.attempt ?? 1) > 1 || recoveryBoundary;
365
+ return {
366
+ view,
367
+ result: view ? roundResultFromCommittedRound(view) : null,
368
+ verificationFlags: inFlight
369
+ ? (request.verification_flags ?? [])
370
+ : (view?.verificationFlags ?? request.verification_flags ?? []),
371
+ freshness: view ? derivePromptFreshness(view) : UNKNOWN_PROMPT_FRESHNESS,
372
+ recoveryBoundary,
373
+ };
374
+ }
375
+ function levelDecision(request, prior, previous, goalId) {
599
376
  return decidePromptLevel({
600
377
  round: request.round,
601
378
  attempt: request.attempt,
602
379
  forceLevel: request.force_level,
603
380
  hasPlanSource: Boolean(request.plan_source),
604
- recoveryBoundary,
605
- checkpointBoundary: last?.compression_checkpoint === true,
606
- goalChanged: previous !== null && previous.goal_id !== deriveGoalId(request.loop_id, request.task, request.goal_id),
607
- previousStateMissing: request.round > 1 && previous === null,
608
- previousFailedWithoutNewInformation: last?.success === false && !hasNewInformation,
609
- verificationContradicted: (request.verification_flags ?? [])
610
- .some((flag) => flag.severity === "error"),
381
+ recoveryBoundary: previous.recoveryBoundary,
382
+ subtaskBoundary: previous.result?.subtask_boundary === true,
383
+ // An empty prior goal_id is unobservable, not a goal change: never
384
+ // fabricate L2. `goalId` is derived from the working objective, so an
385
+ // objective_refinement legitimately changes it.
386
+ goalChanged: prior !== null && prior.goal_id !== "" &&
387
+ prior.goal_id !== goalId,
388
+ previousStateMissing: request.round > 1 && prior === null,
389
+ // v4.0.3: prompt density consumes one explicit previous-round bundle.
390
+ // Agent outcome remains semantic input; posture/finding facts remain
391
+ // machine input. The policy owns the combination so callers cannot drift.
392
+ previousRound: previous.view
393
+ ? {
394
+ agentOutcome: previous.view.agentOutcome,
395
+ evidencePosture: previous.view.evidencePosture?.status ?? null,
396
+ observableNewInformation: previous.freshness.observableNewInformation,
397
+ hasVerificationError: previous.verificationFlags.some((flag) => flag.severity === "error"),
398
+ }
399
+ : null,
611
400
  consecutiveRejections: request.consecutive_rejections,
401
+ // v4.0.5: a cold handoff rehydrates at L2; the escalation threshold above
402
+ // still wins because that retry is already clean context.
403
+ invocationRecovery: request.invocation_recovery === true,
612
404
  });
613
405
  }
614
- export function decideLevel(request, context) {
615
- return levelDecision(request, context).level;
406
+ export function decideLevel(request, context, workingObjective) {
407
+ const entries = contextEntries(context);
408
+ const history = buildCommittedHistory(entries);
409
+ const window = historyWindow(history, request.round);
410
+ const prior = previousRoundFromWindow(window, request.round - 1);
411
+ const objective = workingObjective ??
412
+ latestObjective(request.loop_id, context)?.objective ?? request.task;
413
+ return levelDecision(request, prior, resolvePreviousRoundFacts(request, entries, window), deriveGoalId(request.loop_id, objective)).level;
616
414
  }
617
415
  /** The prompt's evaluation block. All content (examples, contract/sub-goal
618
416
  * guidance, flag-specific hints) comes from the one FIELD_GUIDES table in
@@ -620,80 +418,141 @@ export function decideLevel(request, context) {
620
418
  export function buildSelfEvalBlock(ctx) {
621
419
  return renderSelfEvalBlock(ctx);
622
420
  }
623
- // v2.5: checkpoint() removed — agent_declared milestones from
624
- // compression_checkpoint supersede the separate CheckpointSummary.
625
- // See buildRollingSummary() signal 1.
626
421
  /** v3.7.1: Derived Recovery Brief for the state-file Recent tier. Present
627
422
  * ONLY while a committed backtrack decision is the current round's record
628
423
  * (the recovery window); the redo commit replaces that record, so the
629
424
  * brief exits the projection by construction. Sources are the committed
630
425
  * rollback decision's own fields — no new persistence, no rejected
631
426
  * payloads. */
632
- function committedBacktrackBrief(loopId, round, context) {
633
- const entry = loopEntries(loopId, context).find((e) => entryLineage(e).committed_action === "backtrack" && entryRound(e) === round);
634
- if (!entry)
635
- return undefined;
636
- // v3.8.1: one decoder for the committed rollback record and one renderer for
637
- // the brief. The facts ride on the merged lineage entry (engine.ts stamps
638
- // them from the committed decision) and the same `decodeBacktrackRecord`
639
- // reads the durable :feedback envelope, so both carriers decode to one shape
640
- // — and this brief and the rollback prompt no longer render the same
641
- // rollback with two hand-written line builders.
642
- const record = decodeBacktrackRecord(entryLineage(entry));
427
+ function committedBacktrackBrief(loopId, round, context, restoreVerified) {
428
+ // The SAME pending-backtrack lookup the recovery boundary uses: a deep
429
+ // restore commits at the failed round while its redo targets
430
+ // `backtrackTarget + 1`, so an exact-round match loses both the boundary
431
+ // and this brief.
432
+ const view = pendingBacktrackRecord(contextEntries(context), round);
433
+ const record = view?.recovery;
643
434
  if (!record)
644
435
  return undefined;
645
- return recoveryBriefLines({
646
- target: record.target,
647
- triggerRule: record.triggerRule,
648
- failedRounds: record.failedRounds,
436
+ const lines = recoveryBriefLines({
437
+ redoRound: record.target.redo_round,
438
+ checkpointId: record.target.checkpoint_id,
439
+ restoreVerified,
440
+ triggerRule: record.origin === "machine"
441
+ ? (record.trigger_rule ?? "machine_recovery")
442
+ : "agent_recovery",
443
+ origin: record.origin,
444
+ reason: record.reason,
445
+ ...(record.origin === "machine" && record.reason.trim()
446
+ ? { failureReason: record.reason }
447
+ : {}),
448
+ failedRounds: record.failed_rounds,
649
449
  approaches: record.approaches,
650
- wrongAssumptions: record.wrongAssumptions,
450
+ wrongAssumptions: record.wrong_assumptions,
451
+ preservedDiscoveries: record.skipped_discoveries,
452
+ // The redo round's stable identity is a fact the old prepended prose
453
+ // carried; keep it inside the artifact now.
454
+ recoveryRoundId: `loop:${loopId}:round:${record.target.redo_round}`,
651
455
  });
456
+ const observedFiles = [...(record.observed_skipped_files ??
457
+ record.skipped_files ?? [])].sort();
458
+ const reportedFiles = [...(record.reported_skipped_files ?? [])].sort();
459
+ if (observedFiles.length > 0) {
460
+ const shown = observedFiles.slice(0, 15);
461
+ lines.push("- **Observed changes since the checkpoint (machine)**:");
462
+ for (const file of shown)
463
+ lines.push(` - \`${file}\``);
464
+ if (observedFiles.length > shown.length) {
465
+ lines.push(` - … and ${observedFiles.length - shown.length} more files`);
466
+ }
467
+ }
468
+ if (reportedFiles.length > 0) {
469
+ const shown = reportedFiles.slice(0, 15);
470
+ lines.push("- **Agent-reported changed files (report)**:");
471
+ for (const file of shown)
472
+ lines.push(` - \`${file}\``);
473
+ if (reportedFiles.length > shown.length) {
474
+ lines.push(` - … and ${reportedFiles.length - shown.length} more files`);
475
+ }
476
+ }
477
+ return lines;
652
478
  }
653
- export function compileLoop(request, context) {
654
- const decision = levelDecision(request, context);
655
- const previous = getPreviousRound(request.loop_id, request.round - 1, context);
656
- const objective = evolveObjective(request, context);
657
- const constraints = evolveConstraints(request, objective, previous, context);
658
- const constraintLifecycle = manageConstraintLifecycle(constraints.active, objective, request.constraints_from_plan, context, request.round, request.last_round_result?.constraint_violations ?? []);
659
- const rolling = buildRollingSummary(request.loop_id, request.round, context, 0);
660
- // v3.8: ONE committed-round derivation feeds every projection below
661
- // (sub-goals, the active contract, its item statuses, criteria).
662
- const windowRounds = derivationRounds(loopEntries(request.loop_id, context), request.round);
479
+ export function compileLoop(request, context, options = {}, committedHistory) {
480
+ const operatorAcceptance = options.operatorAcceptance ?? null;
481
+ // ONE history window and ONE previous-round fact bundle for the whole
482
+ // compile. Every consumer below reads these values; none re-derives the
483
+ // window with a private rule.
484
+ const entries = contextEntries(context);
485
+ const history = committedHistory ?? buildCommittedHistory(entries);
486
+ if (history.legacyRounds.length > 0) {
487
+ throw new LegacyHistoryError(request.loop_id, history.legacyRounds.map((round) => round.round));
488
+ }
489
+ // The operator surface is immutable. A caller-supplied copy that disagrees
490
+ // with a committed copy is an integrity conflict — never a silent override.
491
+ const committedAcceptance = history.views.find((view) => view.operatorAcceptance !== null)?.operatorAcceptance ?? null;
492
+ if (operatorAcceptance && committedAcceptance &&
493
+ operatorAcceptanceKey(operatorAcceptance) !==
494
+ operatorAcceptanceKey(committedAcceptance)) {
495
+ throw new OperatorAcceptanceConflictError(request.loop_id);
496
+ }
497
+ const windowRounds = historyWindow(history, request.round);
498
+ const prior = previousRoundFromWindow(windowRounds, request.round - 1);
499
+ const previous = resolvePreviousRoundFacts(request, entries, windowRounds);
500
+ // v4.0.6: the working objective is computed BEFORE the level decision —
501
+ // a committed objective_refinement changes goal identity, and that change
502
+ // must be able to select L2.
503
+ const objective = evolveObjective(request, context, operatorAcceptance);
504
+ const goalId = deriveGoalId(request.loop_id, objective.objective);
505
+ const decision = levelDecision(request, prior, previous, goalId);
506
+ const pendingDirective = pendingBacktrackRecord(entries, request.round);
507
+ const preservedDiscoveries = pendingDirective?.recovery &&
508
+ getPolicy().engine.backtrack_preserve_discoveries
509
+ ? pendingDirective.recovery.skipped_discoveries
510
+ : [];
511
+ const preservedRound = pendingDirective?.round;
512
+ // Plan constraints are a round-1 operator/plan input, or a library
513
+ // bootstrap when no committed round exists. Once a committed view exists,
514
+ // the committed active set carries them and a late request is not a fact.
515
+ const planInput = request.round === 1 || previous.view === null
516
+ ? request.constraints_from_plan
517
+ : [];
518
+ const constraints = evolveConstraints(request, objective, previous, windowRounds, planInput, preservedDiscoveries, preservedRound);
519
+ const constraintLifecycle = manageConstraintLifecycle(constraints.active, constraints.provenance, windowRounds);
663
520
  // v3.8: sub-goal lifecycle lives in subgoal-state.ts and consumes the
664
- // shared committed-round view — one history interpretation.
521
+ // shared committed-round view — one history interpretation. The previous
522
+ // round's report is idempotently replayed; a committed round is already in
523
+ // the window, while the library fallback has no view.
665
524
  const subGoals = deriveSubGoals({
666
525
  loopId: request.loop_id,
667
526
  currentRound: request.round,
668
527
  rounds: windowRounds,
669
- currentReport: request.last_round_result
670
- ? {
671
- round: request.last_round_result.round || (request.round - 1),
672
- emergedSubtasks: request.last_round_result.emerged_subtasks ?? [],
673
- subgoalUpdates: request.last_round_result.subgoal_updates ?? [],
674
- }
675
- : null,
676
528
  });
677
- // v3.4/v3.8/v3.8.1: the ACTIVE Round Contract, its per-item statuses and the
678
- // verified sub-goal facts for this compile — ONE `deriveRoundFacts` call,
679
- // the same bundle the projection consumes. Derived from committed rounds, so
680
- // rejection retries, resume, unpause and backtrack compiles (which carry no
681
- // last_round_result) keep showing the same contract, and a verified or
682
- // blocked contract stops being shown. A verified item stays proven after its
683
- // contract closes because the bundle reads the whole history, not the active
684
- // contract. The compile path has no in-flight observations of its own.
529
+ // v3.4/v3.8/v3.8.1: the ACTIVE Round Contract and its per-item statuses for
530
+ // this compile — ONE `deriveRoundFacts` call, the same bundle the projection
531
+ // consumes. Derived from committed rounds, so rejection retries, resume,
532
+ // unpause and backtrack compiles keep showing the same contract, and a
533
+ // verified or blocked contract stops being shown. A verified item stays
534
+ // proven after its contract closes because the bundle reads the whole
535
+ // history, not the active contract. The compile path has no in-flight
536
+ // observations of its own.
685
537
  const roundFacts = deriveRoundFacts({
686
538
  rounds: windowRounds,
687
539
  currentRound: request.round,
688
540
  inFlight: NO_IN_FLIGHT_ROUND,
689
- subGoals,
690
541
  commands: getPolicy().evidence.commands ?? [],
691
542
  });
692
- // `roundFacts.verifiedSubGoals` is not read here — it reaches the state file
693
- // and the projection through the bundle itself.
694
543
  const { activeContract } = roundFacts;
544
+ // v3.9.4: the criterion reference space is the ACTIVE criterion set, not
545
+ // every text the objective ever carried. A retracted criterion must not be
546
+ // claimable or contract-referenceable while the prompt does not render it.
547
+ const activeConstraintKeys = new Set(constraintLifecycle.active.map((text) => normalizeText(text)));
548
+ const activeCriteria = uniqueNormalized(objective.success_criteria)
549
+ .filter((text) => activeConstraintKeys.has(normalizeText(text)));
550
+ const criterionObjective = {
551
+ ...objective,
552
+ success_criteria: activeCriteria,
553
+ };
695
554
  // v3.2: Goal → criteria → evidence vertical view (derived, zero persistence).
696
- const criterionStatuses = deriveCriterionStatuses(request.loop_id, context, objective, request.round, request.last_round_result, roundFacts.criterionFacts);
555
+ const criterionStatuses = deriveCriterionStatuses(request.loop_id, context, criterionObjective, request.round, previous.result, roundFacts.criterionFacts);
697
556
  // v3.8.1: the ONE recurring-fact derivation, over the shared window. The
698
557
  // prompt renders its recent tail; the state file renders the recurring set.
699
558
  const recurringFlags = deriveRecurringFlags(windowRounds);
@@ -703,13 +562,27 @@ export function compileLoop(request, context) {
703
562
  // gone with the rest of the fuzzy matching, and so are the task-alignment
704
563
  // and loop-health warnings: both were Jaccard scores over task/objective
705
564
  // text, never machine facts.
706
- const repeatedDeclarations = duplicateEmergedDeclarations(request.last_round_result?.emerged_subtasks ?? []);
565
+ // v3.9.5: only committed declarations are history. A library fallback is
566
+ // in-flight and must not manufacture a prompt warning.
567
+ const repeatedDeclarations = previous.view
568
+ ? duplicateEmergedDeclarations(previous.view.emergedSubtasks ?? [])
569
+ : [];
707
570
  const warnings = unique([
708
571
  repeatedDeclarations.length > 0
709
572
  ? `duplicate_declaration: ${repeatedDeclarations.length} sub-goal ` +
710
573
  `declaration(s) repeated an earlier entry in the same round and were ` +
711
574
  `not created: ${repeatedDeclarations.join("; ")}`
712
575
  : "",
576
+ request.round > 1 && previous.view !== null && request.loop_objective !== null
577
+ ? "uncommitted_objective_ignored: a committed objective exists; the request objective is ignored"
578
+ : "",
579
+ request.round > 1 && previous.view !== null && request.constraints_from_plan.length > 0
580
+ ? "uncommitted_plan_ignored: a committed active set exists; request plan constraints are ignored"
581
+ : "",
582
+ constraints.discoveryDropped > 0
583
+ ? `discovery_cap: ${constraints.discoveryDropped} discovered constraint(s) exceeded ` +
584
+ `max_discovered_constraints_per_round and were not expanded`
585
+ : "",
713
586
  ]);
714
587
  // v3.8.1: the agent trust score and trend are deleted. The score was
715
588
  // `1 - errors*0.15 - warns*0.03` — arbitrary weights with no machine
@@ -718,28 +591,34 @@ export function compileLoop(request, context) {
718
591
  // `constraint_violations`, i.e. a DIFFERENT source from the score it was
719
592
  // displayed next to. "Trend" was therefore not the score's history at all.
720
593
  // Neither fed a decision.
721
- const completedEntries = loopEntries(request.loop_id, context)
722
- .filter((e) => entryRound(e) >= 1 && entryRound(e) < request.round);
723
- const committedRounds = derivationRounds(completedEntries, request.round);
724
594
  // v3.3/v3.8.1: machine git-motion (display-only, derived). The per-round
725
595
  // "round stats" table is deleted: its only renderer was the L2 prompt
726
596
  // section removed above, so it was computed, put into the canonical state
727
- // (and therefore into stateHash) and read by nothing.
728
- const machineStatus = deriveMachineStatus(committedRounds, request.round);
597
+ // (and therefore into stateHash) and read by nothing. `windowRounds` is the
598
+ // ONE window computed at the top of this function.
599
+ const machineStatus = deriveMachineStatus(windowRounds, request.round);
600
+ // v4.0.2: ONE derivation of the bounded Working Memory and the context
601
+ // boundary for the whole compile. State, prompt, artifact and every
602
+ // prompt-bearing response project these values; none re-derives them.
603
+ const workingMemory = deriveWorkingMemory(history, request.round);
604
+ const contextBoundary = derivePromptContextBoundary({
605
+ round: request.round,
606
+ attempt: request.attempt,
607
+ consecutiveRejections: request.consecutive_rejections,
608
+ recoveryBoundary: previous.recoveryBoundary,
609
+ hasPreviousCommittedRound: previous.view !== null,
610
+ invocationRecovery: request.invocation_recovery === true,
611
+ });
729
612
  const response = makeLoopCompileResponse({
730
613
  status: AgentStatus.OK,
731
614
  recompile_level: decision.level,
732
- diff_from_previous: decision.reasons.join(","),
733
- lineage: [`${request.loop_id}:r${request.round}`],
734
615
  constraints_active: constraintLifecycle.active,
735
616
  constraints_retired: constraints.retired,
736
617
  constraint_metadata: constraintLifecycle.metadata,
737
618
  loop_id: request.loop_id,
738
619
  round: request.round,
739
- goal_id: deriveGoalId(request.loop_id, request.task, request.goal_id),
740
- goal_text_hash: computeGoalTextHash(request.task),
620
+ goal_id: goalId,
741
621
  loop_objective: objective,
742
- rolling_summary: rolling,
743
622
  sub_goals: subGoals,
744
623
  criterion_statuses: criterionStatuses,
745
624
  recurring_flags: recurringFlags,
@@ -754,17 +633,31 @@ export function compileLoop(request, context) {
754
633
  const statePath = policy.state_file.enabled
755
634
  ? `${policy.state_file.directory}/${request.loop_id}-state.md`
756
635
  : "";
636
+ // The recovery brief is a current-round fact, computed once and shared by
637
+ // the state file, the prompt and the evaluation block. It never becomes
638
+ // prompt control state.
639
+ const recoveryBrief = committedBacktrackBrief(request.loop_id, request.round, context, request.restore_verified === true);
757
640
  const state = createCanonicalLoopState(request, response, statePath, {
758
641
  machineStatus,
759
642
  // v3.4/v3.8.1: the contract-fact bundle — the Current Task's source of
760
643
  // truth, its verification view and the machine-verified sub-goal facts as
761
644
  // ONE derivation. The static capability rides on the state itself.
762
645
  roundFacts,
646
+ // 3.9.3: the same previous-round bundle the level decision used. This is
647
+ // what keeps corrections/blockers/remaining/progress identical on live,
648
+ // resume, and unpause compiles.
649
+ // v3.9.5: the committed flag is what keeps a caller fallback out of the
650
+ // prompt/state narrative.
651
+ previousRound: { ...previous, committed: previous.view !== null },
652
+ recoveryBrief,
653
+ workingMemory,
654
+ context: {
655
+ round: request.round,
656
+ attempt: request.attempt,
657
+ mode: contextBoundary.mode,
658
+ reason: contextBoundary.reason,
659
+ },
763
660
  });
764
- // The recovery brief is a current-round fact, computed once and shared by
765
- // the state file and the evaluation block. It never becomes prompt control
766
- // state.
767
- const recoveryBrief = committedBacktrackBrief(request.loop_id, request.round, context);
768
661
  const configuredCapability = deriveConfiguredCapability(policy);
769
662
  const contractCommands = configuredCapability.commands
770
663
  .filter((command) => command.enabled)
@@ -776,6 +669,12 @@ export function compileLoop(request, context) {
776
669
  // round's record; the redo commit removes it by construction.
777
670
  attempt: request.attempt,
778
671
  recoveryBrief,
672
+ context: {
673
+ round: request.round,
674
+ attempt: request.attempt,
675
+ mode: contextBoundary.mode,
676
+ reason: contextBoundary.reason,
677
+ },
779
678
  });
780
679
  // v3.8.1: the L2 budget is a FIXED ceiling. The v2.4 adaptive budget
781
680
  // scaled it by round count, milestone count and sub-goal count, which made
@@ -783,43 +682,60 @@ export function compileLoop(request, context) {
783
682
  // rather than on the facts of this round — the same loop could render a
784
683
  // section at round 5 and silently drop it at round 50.
785
684
  //
786
- // v2.8: L2 pointer mode — when enabled, skip the monolithic markdown blob.
787
- // Structured L2 sections (milestones, sub-goals, trust, progress) still
788
- // render. The state file on disk is the durable source of truth.
789
- // L7: pointer mode REQUIRES the file — with state_file disabled it would
790
- // silently drop the full state from L2 prompts and point at nothing.
791
- const l2Pointer = policy.state_file.enabled &&
792
- policy.prompt.l2_pointer_enabled && decision.level === "l2";
685
+ // v3.9.5: L2 never embeds the monolithic state markdown. The prompt is a
686
+ // current-governance projection; full history stays in the optional state
687
+ // file / replay / audit.
793
688
  const artifact = assemblePromptArtifact({
794
689
  state,
795
690
  level: decision.level,
796
- reasons: decision.reasons,
797
691
  budgets: {
798
692
  l0: policy.prompt.l0_max_chars,
799
693
  l1: policy.prompt.l1_max_chars,
800
694
  l2: policy.prompt.l2_max_chars,
801
695
  },
802
696
  attempt: request.attempt,
697
+ warnings,
803
698
  selfEvaluationBlock: buildSelfEvalBlock({
804
699
  round: request.round,
700
+ maxRounds: request.max_rounds ?? policy.engine.max_rounds,
805
701
  level: decision.level,
806
702
  activeContract,
807
703
  itemStatuses: roundFacts.itemStatuses,
808
704
  commands: contractCommands,
809
705
  contractVerificationAvailable: configuredCapability.contractVerificationAvailable,
810
706
  subGoals,
811
- verificationFlags: request.verification_flags ?? [],
707
+ verificationFlags: previous.verificationFlags,
812
708
  recovery: recoveryBrief !== undefined,
813
709
  gateEnabled: policy.gate.enabled === true,
814
710
  criteria: criterionStatuses,
815
711
  }),
816
- fullStateMarkdown: l2Pointer ? undefined : markdown,
817
- // v2.9: Model's information needs from the previous round's SelfEvaluation
818
- promptRequests: request.last_round_result?.prompt_requests,
712
+ // v3.9.4: the SAME committed-backtrack facts the state file renders. The
713
+ // recovery section is part of the artifact, so the delivered prompt is
714
+ // exactly `artifact.renderedPrompt` (no post-compile prepend).
715
+ recoveryBrief,
716
+ // v4.0.2: the ONE context boundary and the SAME bounded Working Memory
717
+ // the state carries. A retry renders no Working Memory section.
718
+ contextMode: contextBoundary.mode,
719
+ contextReason: contextBoundary.reason,
720
+ workingMemory,
819
721
  });
820
722
  response.prompt = artifact.renderedPrompt;
821
723
  response.prompt_artifact = artifact;
822
724
  response.state_file_content = policy.state_file.enabled ? markdown : undefined;
725
+ // v4.0.6: the fallback acceptance uses the immutable facts, never the
726
+ // working objective text: `objective.objective` may be an Agent refinement
727
+ // and must not be written back as Operator Acceptance.
728
+ response.operatorAcceptance = operatorAcceptance ?? committedAcceptance ?? {
729
+ task: request.task,
730
+ success_criteria: [...objective.success_criteria],
731
+ hard_constraints: [...objective.hard_constraints],
732
+ };
733
+ response.compiled = {
734
+ task: objective.objective,
735
+ goalId: response.goal_id,
736
+ constraintsActive: [...constraintLifecycle.active],
737
+ constraintsRetired: [...constraints.retired],
738
+ };
823
739
  return response;
824
740
  }
825
741
  //# sourceMappingURL=loop-compiler.js.map