loopforge 3.9.4 → 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 (283) 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 +114 -40
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +276 -182
  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 +97 -156
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +223 -419
  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 +26 -38
  42. package/dist/enforcement-gate.d.ts.map +1 -1
  43. package/dist/enforcement-gate.js +196 -319
  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 +27 -897
  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 +166 -27
  70. package/dist/explain.d.ts.map +1 -1
  71. package/dist/explain.js +372 -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 +39 -23
  103. package/dist/loop-compiler.d.ts.map +1 -1
  104. package/dist/loop-compiler.js +368 -565
  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 +255 -81
  111. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  112. package/dist/mcp/round-lifecycle.js +1552 -539
  113. package/dist/mcp/round-lifecycle.js.map +1 -1
  114. package/dist/mcp/server.d.ts.map +1 -1
  115. package/dist/mcp/server.js +42 -18
  116. package/dist/mcp/server.js.map +1 -1
  117. package/dist/mcp/session.d.ts +81 -39
  118. package/dist/mcp/session.d.ts.map +1 -1
  119. package/dist/mcp/session.js +956 -282
  120. package/dist/mcp/session.js.map +1 -1
  121. package/dist/mcp/submission-boundary.d.ts +23 -31
  122. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  123. package/dist/mcp/submission-boundary.js +242 -92
  124. package/dist/mcp/submission-boundary.js.map +1 -1
  125. package/dist/mcp/submission-handle.d.ts +66 -0
  126. package/dist/mcp/submission-handle.d.ts.map +1 -0
  127. package/dist/mcp/submission-handle.js +173 -0
  128. package/dist/mcp/submission-handle.js.map +1 -0
  129. package/dist/mcp/tools.d.ts +17 -7
  130. package/dist/mcp/tools.d.ts.map +1 -1
  131. package/dist/mcp/tools.js +707 -244
  132. package/dist/mcp/tools.js.map +1 -1
  133. package/dist/open-question.d.ts +7 -0
  134. package/dist/open-question.d.ts.map +1 -0
  135. package/dist/open-question.js +14 -0
  136. package/dist/open-question.js.map +1 -0
  137. package/dist/policy-metrics.d.ts +31 -21
  138. package/dist/policy-metrics.d.ts.map +1 -1
  139. package/dist/policy-metrics.js +117 -64
  140. package/dist/policy-metrics.js.map +1 -1
  141. package/dist/policy.d.ts +11 -39
  142. package/dist/policy.d.ts.map +1 -1
  143. package/dist/policy.js +12 -12
  144. package/dist/policy.js.map +1 -1
  145. package/dist/progress.d.ts +45 -0
  146. package/dist/progress.d.ts.map +1 -0
  147. package/dist/progress.js +136 -0
  148. package/dist/progress.js.map +1 -0
  149. package/dist/prompt-assembler.d.ts +20 -6
  150. package/dist/prompt-assembler.d.ts.map +1 -1
  151. package/dist/prompt-assembler.js +475 -479
  152. package/dist/prompt-assembler.js.map +1 -1
  153. package/dist/prompt-coverage.d.ts +0 -6
  154. package/dist/prompt-coverage.d.ts.map +1 -1
  155. package/dist/prompt-coverage.js +4 -8
  156. package/dist/prompt-coverage.js.map +1 -1
  157. package/dist/prompt-freshness.d.ts +6 -17
  158. package/dist/prompt-freshness.d.ts.map +1 -1
  159. package/dist/prompt-freshness.js +27 -22
  160. package/dist/prompt-freshness.js.map +1 -1
  161. package/dist/prompt-policy.d.ts +14 -7
  162. package/dist/prompt-policy.d.ts.map +1 -1
  163. package/dist/prompt-policy.js +21 -5
  164. package/dist/prompt-policy.js.map +1 -1
  165. package/dist/protocol.d.ts +843 -424
  166. package/dist/protocol.d.ts.map +1 -1
  167. package/dist/protocol.js +73 -102
  168. package/dist/protocol.js.map +1 -1
  169. package/dist/recovery-guidance.d.ts +13 -0
  170. package/dist/recovery-guidance.d.ts.map +1 -0
  171. package/dist/recovery-guidance.js +15 -0
  172. package/dist/recovery-guidance.js.map +1 -0
  173. package/dist/recovery.d.ts +68 -0
  174. package/dist/recovery.d.ts.map +1 -0
  175. package/dist/recovery.js +234 -0
  176. package/dist/recovery.js.map +1 -0
  177. package/dist/replay.d.ts +42 -8
  178. package/dist/replay.d.ts.map +1 -1
  179. package/dist/replay.js +311 -84
  180. package/dist/replay.js.map +1 -1
  181. package/dist/result-facts.d.ts +45 -0
  182. package/dist/result-facts.d.ts.map +1 -0
  183. package/dist/result-facts.js +86 -0
  184. package/dist/result-facts.js.map +1 -0
  185. package/dist/round-contract.d.ts +38 -8
  186. package/dist/round-contract.d.ts.map +1 -1
  187. package/dist/round-contract.js +71 -31
  188. package/dist/round-contract.js.map +1 -1
  189. package/dist/round-coordinator.d.ts +41 -89
  190. package/dist/round-coordinator.d.ts.map +1 -1
  191. package/dist/round-coordinator.js +250 -267
  192. package/dist/round-coordinator.js.map +1 -1
  193. package/dist/round-driver.d.ts +56 -16
  194. package/dist/round-driver.d.ts.map +1 -1
  195. package/dist/round-driver.js +225 -39
  196. package/dist/round-driver.js.map +1 -1
  197. package/dist/round-facts.d.ts +33 -52
  198. package/dist/round-facts.d.ts.map +1 -1
  199. package/dist/round-facts.js +51 -160
  200. package/dist/round-facts.js.map +1 -1
  201. package/dist/round-guidance.d.ts +56 -0
  202. package/dist/round-guidance.d.ts.map +1 -0
  203. package/dist/round-guidance.js +161 -0
  204. package/dist/round-guidance.js.map +1 -0
  205. package/dist/round-outcome.d.ts +21 -0
  206. package/dist/round-outcome.d.ts.map +1 -0
  207. package/dist/round-outcome.js +31 -0
  208. package/dist/round-outcome.js.map +1 -0
  209. package/dist/round-transaction.d.ts +168 -21
  210. package/dist/round-transaction.d.ts.map +1 -1
  211. package/dist/round-transaction.js +837 -38
  212. package/dist/round-transaction.js.map +1 -1
  213. package/dist/self-eval.d.ts +37 -61
  214. package/dist/self-eval.d.ts.map +1 -1
  215. package/dist/self-eval.js +413 -392
  216. package/dist/self-eval.js.map +1 -1
  217. package/dist/storage.d.ts +16 -0
  218. package/dist/storage.d.ts.map +1 -1
  219. package/dist/storage.js +54 -4
  220. package/dist/storage.js.map +1 -1
  221. package/dist/subgoal-state.d.ts +2 -3
  222. package/dist/subgoal-state.d.ts.map +1 -1
  223. package/dist/subgoal-state.js +2 -3
  224. package/dist/subgoal-state.js.map +1 -1
  225. package/dist/submission-guidance.d.ts +29 -21
  226. package/dist/submission-guidance.d.ts.map +1 -1
  227. package/dist/submission-guidance.js +308 -260
  228. package/dist/submission-guidance.js.map +1 -1
  229. package/dist/submission-receipt.d.ts +16 -8
  230. package/dist/submission-receipt.d.ts.map +1 -1
  231. package/dist/submission-receipt.js +145 -51
  232. package/dist/submission-receipt.js.map +1 -1
  233. package/dist/token-utils.d.ts +28 -7
  234. package/dist/token-utils.d.ts.map +1 -1
  235. package/dist/token-utils.js +27 -11
  236. package/dist/token-utils.js.map +1 -1
  237. package/dist/transaction-identity.d.ts +9 -0
  238. package/dist/transaction-identity.d.ts.map +1 -0
  239. package/dist/transaction-identity.js +69 -0
  240. package/dist/transaction-identity.js.map +1 -0
  241. package/dist/verification-actions.d.ts.map +1 -1
  242. package/dist/verification-actions.js +10 -11
  243. package/dist/verification-actions.js.map +1 -1
  244. package/dist/verification-gate.d.ts +23 -47
  245. package/dist/verification-gate.d.ts.map +1 -1
  246. package/dist/verification-gate.js +202 -423
  247. package/dist/verification-gate.js.map +1 -1
  248. package/dist/version.d.ts +1 -1
  249. package/dist/version.js +1 -1
  250. package/dist/working-memory.d.ts +187 -0
  251. package/dist/working-memory.d.ts.map +1 -0
  252. package/dist/working-memory.js +767 -0
  253. package/dist/working-memory.js.map +1 -0
  254. package/dist/workspace-checkpoint.d.ts +88 -0
  255. package/dist/workspace-checkpoint.d.ts.map +1 -0
  256. package/dist/workspace-checkpoint.js +326 -0
  257. package/dist/workspace-checkpoint.js.map +1 -0
  258. package/dist/workspace-restore.d.ts +68 -0
  259. package/dist/workspace-restore.d.ts.map +1 -0
  260. package/dist/workspace-restore.js +604 -0
  261. package/dist/workspace-restore.js.map +1 -0
  262. package/dist/workspace.d.ts +0 -9
  263. package/dist/workspace.d.ts.map +1 -1
  264. package/dist/workspace.js +0 -17
  265. package/dist/workspace.js.map +1 -1
  266. package/loop_policy.json +4 -18
  267. package/package.json +68 -68
  268. package/skills/forge/SKILL.md +212 -0
  269. package/skills/forge/references/evidence-and-contracts.md +204 -0
  270. package/skills/forge/references/recovery-and-views.md +188 -0
  271. package/dist/cognitive-facts.d.ts +0 -30
  272. package/dist/cognitive-facts.d.ts.map +0 -1
  273. package/dist/cognitive-facts.js +0 -113
  274. package/dist/cognitive-facts.js.map +0 -1
  275. package/dist/loop-extras-parser.d.ts +0 -81
  276. package/dist/loop-extras-parser.d.ts.map +0 -1
  277. package/dist/loop-extras-parser.js +0 -213
  278. package/dist/loop-extras-parser.js.map +0 -1
  279. package/dist/loop-projection.d.ts +0 -6
  280. package/dist/loop-projection.d.ts.map +0 -1
  281. package/dist/loop-projection.js +0 -28
  282. package/dist/loop-projection.js.map +0 -1
  283. package/skills/perception/SKILL.md +0 -389
@@ -5,219 +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 { decodeRound, entryLineage, entryCriteriaMet, entryCriteriaRemaining, entryActiveConstraints, entryRetractedConstraints, pendingBacktrackRecord, roundResultFromCommittedRound, 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";
11
15
  import { derivePromptFreshness, UNKNOWN_PROMPT_FRESHNESS, } from "./prompt-freshness.js";
12
16
  import { NO_IN_FLIGHT_ROUND, deriveRoundFacts } from "./round-facts.js";
13
17
  import { recoveryBriefLines } from "./enforcement-gate.js";
14
18
  import { deriveSubGoals, duplicateEmergedDeclarations } from "./subgoal-state.js";
15
- import { claimedMetCriteria, claimedRemainingCriteria } from "./self-eval.js";
19
+ import { deriveConstraintProvenance, resolveConstraintRetractions, } from "./constraint-lifecycle.js";
16
20
  import { assemblePromptArtifact } from "./prompt-assembler.js";
21
+ import { derivePromptContextBoundary } from "./context-boundary.js";
22
+ import { deriveWorkingMemory } from "./working-memory.js";
17
23
  import { renderSelfEvalBlock, } from "./submission-guidance.js";
18
24
  import { decidePromptLevel, } from "./prompt-policy.js";
19
- import { criterionRefMatches, deriveCriterionId, deriveItemId, normalizeText, unique, uniqueNormalized, entryRound, } from "./token-utils.js";
25
+ import { criterionRefMatches, deriveConstraintId, deriveCriterionId, normalizeText, unique, uniqueNormalized, } from "./token-utils.js";
20
26
  function contextEntries(context) {
21
27
  if (!context || !Array.isArray(context.results))
22
28
  return [];
23
29
  return context.results.filter((value) => value !== null && typeof value === "object" && !Array.isArray(value));
24
30
  }
25
- function loopEntries(loopId, context) {
26
- return contextEntries(context)
27
- .filter((entry) => entry.loop_id === loopId || entryLineage(entry).loop_id === loopId)
28
- .sort((a, b) => entryRound(a) - entryRound(b));
29
- }
30
- /** v3.3.1: The canonical entry for a round — its compile-time lineage entry
31
- * (task_type "loop_lineage"; engine hydration merges the committed
32
- * decision into it, so one round = one merged lineage entry in the
33
- * compiler's view). Event/journal entries (delegation journals, gate
34
- * records) share lineage.round with the round but carry none of its
35
- * compile-time fields; round-level consumers that matched on the round
36
- * number alone could hit them first (they sort after the lineage entry of
37
- * the same round), producing an empty goal_id / task / constraints_active
38
- * and forcing spurious L2 recovery. A direct :feedback entry is accepted
39
- * when the caller supplies a raw committed view instead of hydration. */
40
- function roundCanonicalEntry(entries, round) {
41
- const candidates = entries.filter((entry) => entryRound(entry) === round);
42
- return (candidates.find((entry) => entry.task_type === "loop_lineage") ??
43
- candidates.find((entry) => String(entry.task_id ?? "").endsWith(":feedback")) ??
44
- // Untyped compiler inputs are treated as round views, not events. Known event
45
- // types (delegation journals, gate records) are excluded explicitly so
46
- // they can never shadow a round again.
47
- candidates.find((entry) => {
48
- const type = String(entry.task_type ?? "");
49
- return type === "" || (!type.startsWith("delegation") && !type.endsWith("journal"));
50
- }) ??
51
- null);
52
- }
53
31
  export function computeGoalTextHash(text) {
54
32
  const normalized = text.trim().replace(/\s+/g, " ").toLowerCase();
55
33
  return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
56
34
  }
57
- export function deriveGoalId(loopId, task, explicit = "") {
58
- 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()
59
40
  .replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
60
41
  .replace(/^-|-$/g, "")
61
- .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)}`;
62
46
  }
63
47
  export function getPreviousRound(loopId, round, context) {
64
- const entry = roundCanonicalEntry(loopEntries(loopId, context), round);
65
- 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)
66
54
  return null;
67
- const data = entryLineage(entry);
68
55
  return {
69
56
  round,
70
- goal_id: typeof data.goal_id === "string" ? data.goal_id : "",
71
- goal_text_hash: typeof data.goal_text_hash === "string" ? data.goal_text_hash : "",
72
- success: typeof entry.success === "boolean"
73
- ? entry.success
74
- : typeof data.success === "boolean" ? data.success : false,
75
- task: typeof data.task === "string"
76
- ? data.task
77
- : typeof entry.task === "string" ? entry.task : "",
78
- constraints_active: Array.isArray(data.constraints_active)
79
- ? data.constraints_active.filter((value) => typeof value === "string")
80
- : [],
81
- output_summary: typeof entry.output_summary === "string"
82
- ? entry.output_summary
83
- : typeof data.output_summary === "string" ? data.output_summary : "",
57
+ goal_id: view.compiled?.goalId ?? "",
84
58
  };
85
59
  }
86
- function latestObjective(loopId, context,
87
- /** Exclusive compile-round bound. A deep-restore redo must never read the
88
- * abandoned branch's objective (and its rolled-back refinements). */
89
- beforeRound,
90
- /** v3.9.4: a round-1 same-round retry has no earlier committed objective.
91
- * Reading the prior compile of the SAME round recovers the initial
92
- * criteria supplied to `loopforge_start`. It is never enabled for a first
93
- * attempt or for later rounds, so it cannot read an abandoned branch. */
94
- includeRoundOne = false) {
95
- const candidates = loopEntries(loopId, context)
96
- .filter((entry) => {
97
- const round = entryRound(entry);
98
- return round >= 1 && (round < beforeRound || (includeRoundOne && round === 1));
99
- })
100
- .reverse();
101
- for (const entry of candidates) {
102
- const raw = entry.loop_objective;
103
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
104
- continue;
105
- const value = raw;
106
- return makeLoopObjective({
107
- objective: typeof value.objective === "string" ? value.objective : "",
108
- success_criteria: Array.isArray(value.success_criteria)
109
- ? value.success_criteria.filter((item) => typeof item === "string")
110
- : [],
111
- hard_constraints: Array.isArray(value.hard_constraints)
112
- ? value.hard_constraints.filter((item) => typeof item === "string")
113
- : [],
114
- created_at_round: typeof value.created_at_round === "number" ? value.created_at_round : 1,
115
- loop_id: loopId,
116
- version: typeof value.version === "number" ? value.version : 1,
117
- refinement_history: Array.isArray(value.refinement_history)
118
- ? value.refinement_history.filter((item) => typeof item === "string")
119
- : [],
120
- });
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
+ }
121
86
  }
122
- 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
+ });
123
94
  }
124
- function evolveObjective(request, context, previous) {
125
- const current = request.loop_objective ??
126
- latestObjective(request.loop_id, context, request.round, request.round === 1 && (request.attempt ?? 1) > 1) ??
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 ??
127
118
  makeLoopObjective({
128
119
  objective: request.task,
129
120
  success_criteria: ["Task goal achieved with verifiable evidence"],
130
- hard_constraints: unique(request.constraints_from_plan),
121
+ hard_constraints: unique(request.round === 1 || committed === null ? request.constraints_from_plan : []),
131
122
  created_at_round: 1,
132
123
  loop_id: request.loop_id,
133
124
  });
134
- const last = previous.result;
135
- const history = [...(current.refinement_history ?? [])];
136
- let objective = current.objective || request.task;
137
- let version = current.version ?? 1;
138
- if (last?.objective_refinement?.trim()) {
139
- const refinement = last.objective_refinement.trim();
140
- // M5: fold each refinement exactly once per persisted objective state.
141
- // A retry recompiles the same round against the same committed
142
- // previous-round projection; without this guard every retry attempt
143
- // re-appends the refinement and inflates the version — the objective text
144
- // and the state file grew "Refinement: X" once per attempt.
145
- if (!history.includes(refinement)) {
146
- history.push(refinement);
147
- objective = `${objective}\nRefinement: ${refinement}`;
148
- version++;
149
- }
150
- }
151
- 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.
152
127
  return makeLoopObjective({
153
128
  ...current,
154
- objective,
155
- version,
156
- refinement_history: history.slice(-getPolicy().evolution.max_objective_versions),
157
- // v3.9.4: one normalized-exact text, one cr-* identity. `unique` was
158
- // trim-only, so case/whitespace variants survived as two entries that
159
- // derived to the SAME criterion id.
160
- success_criteria: uniqueNormalized(current.success_criteria
161
- .filter((item) => typeof item === "string")
162
- .map((item) => revisions.get(item) ?? item)),
129
+ objective: current.objective || request.task,
130
+ success_criteria: uniqueNormalized(current.success_criteria),
163
131
  hard_constraints: uniqueNormalized(current.hard_constraints),
164
132
  loop_id: request.loop_id,
165
133
  });
166
134
  }
167
- function evolveConstraints(request, objective, previous, windowRounds) {
168
- const retired = unique(previous.result?.retracted_constraints ?? []);
169
- // v3.9.4: a revised-away criterion text is no longer an objective criterion.
170
- // It must not survive as a "discovered" active constraint just because the
171
- // previous round's active set carried it. This is a merge-time suppression,
172
- // not an alias and not a constraint retirement.
173
- const revisedAway = unique((previous.result?.revised_success_criteria ?? []).map((item) => item.old));
174
- const immediateRetired = new Set([...retired, ...revisedAway].map((text) => normalizeText(text)));
175
- const discovered = unique(previous.result?.discovered_constraints ?? [])
176
- .slice(0, getPolicy().evolution.max_discovered_constraints_per_round);
177
- // v2.14: agent-declared retractions of plan/hard/criteria constraints
178
- // persist for constraints.retire_window rounds (they are re-merged from
179
- // the request/objective every round, so without this the documented
180
- // "Removed from the active constraint set" never held for them). The
181
- // retraction rounds are derived from the SAME committed window as every
182
- // other compile fact — an abandoned branch above the compile round can
183
- // never suppress a constraint on the restored branch.
184
- const window = getPolicy().constraints.retire_window;
185
- const lastRetractedRound = new Map();
186
- if (window > 0) {
187
- for (const round of windowRounds) {
188
- const retractedList = round.retractedConstraints ?? [];
189
- for (const text of retractedList) {
190
- if (!text)
191
- continue;
192
- const key = normalizeText(text);
193
- const previousRound = lastRetractedRound.get(key);
194
- if (previousRound === undefined || round.round > previousRound) {
195
- lastRetractedRound.set(key, round.round);
196
- }
197
- }
198
- }
199
- }
200
- // v3.9.4: normalized-exact dedupe. The trim-only `unique` let case/space
201
- // variants coexist while deriving the SAME c-/cr- id. The silent
202
- // max_active_constraints slice is gone: every merged active constraint is
203
- // normative, and prompt budget pressure is reported as protectedOverflow.
204
- const active = uniqueNormalized([
205
- ...(previous.view?.activeConstraints ?? []),
206
- ...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,
207
165
  ...objective.hard_constraints,
208
166
  ...objective.success_criteria,
209
- ...discovered,
210
- ]).filter((item) => {
211
- const key = normalizeText(item);
212
- if (immediateRetired.has(key))
213
- return false;
214
- const retractedRound = lastRetractedRound.get(key);
215
- if (retractedRound !== undefined && request.round - retractedRound < window) {
216
- return false;
217
- }
218
- return true;
219
- });
220
- 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 };
221
188
  }
222
189
  // ═══════════════════════════════════════════════════════════════════════════
223
190
  // Hierarchical Summary (v2.1)
@@ -272,18 +239,27 @@ export function deriveRecurringFlags(committedRounds) {
272
239
  * — the dashboard then shows no machine row. Display-only companion to the
273
240
  * R4/R5 exculpatory signal. */
274
241
  function deriveMachineStatus(rounds, currentRound) {
275
- const series = machineGitMotionSeries(rounds, currentRound, 3);
276
- 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")
277
245
  return undefined;
278
- const motionRounds = series.filter(Boolean).length;
279
- 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
+ };
280
253
  }
281
254
  /** v3.2: Derive per-criterion status — the "goal → criteria → evidence"
282
- * vertical view. Each objective criterion gets: met/remaining/unknown
283
- * (from per-round criterion_claims, ID-first or normalized-exact matching),
284
- * the round it was first reported met, and the sub-goals a contract item
285
- * referencing it also names (explicit `subgoal_refs`, never a text guess).
286
- * 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). */
287
263
  export function deriveCriterionStatuses(loopId, context, objective, currentRound, lastRoundResult,
288
264
  /** v3.8.1: the criterion machine facts from the shared bundle. A criterion
289
265
  * is `verified` / `contradicted` / `insufficient` only through an item that
@@ -291,65 +267,13 @@ export function deriveCriterionStatuses(loopId, context, objective, currentRound
291
267
  * is derived over the WHOLE committed history, so it survives its contract
292
268
  * closing (see `deriveCriterionFacts`). */
293
269
  criterionFacts = []) {
294
- if (!objective || objective.success_criteria.length === 0)
295
- return [];
296
- const entries = loopEntries(loopId, context).filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
297
- // The previous round's report, resolved from the committed view (or the
298
- // library fallback), is treated as the report for round currentRound - 1.
299
- const lastEv = lastRoundResult?.execution_report;
300
- const lastMet = claimedMetCriteria(lastEv);
301
- const lastRemaining = claimedRemainingCriteria(lastEv);
302
- const lastEntry = entries[entries.length - 1] ?? null;
303
- const lastEntryRemaining = lastEntry ? entryCriteriaRemaining(lastEntry) : [];
304
- // v3.8.1: criterion → machine status + explicit sub-goal links, from the
305
- // shared `RoundFacts` bundle (whole committed history). It used to be
306
- // re-derived here from the ACTIVE contract, which made both maps empty the
307
- // moment the contract closed — the criterion's machine fact, and the
308
- // sub-goal links carried on the same item, disappeared while the sub-goal
309
- // side of the very same fact survived. One derivation, one window.
310
- const factsByCriterion = new Map(criterionFacts.map((fact) => [fact.criterion_id, fact]));
311
- // v3.9.4: one row per derived id. The objective is authored text; a case or
312
- // whitespace variant derives the same cr-* and must not produce a second
313
- // row (nor a second contract reference space).
314
- const seenIds = new Set();
315
- const statuses = [];
316
- for (const text of objective.success_criteria) {
317
- const id = deriveCriterionId(text);
318
- if (seenIds.has(id))
319
- continue;
320
- seenIds.add(id);
321
- let metAtRound = null;
322
- for (const entry of entries) {
323
- const rnd = entryRound(entry);
324
- if (entryCriteriaMet(entry).some((met) => criteriaMatch(met, text))) {
325
- metAtRound = rnd;
326
- break; // first report wins — chronological scan
327
- }
328
- }
329
- if (metAtRound === null && lastMet.some((met) => criteriaMatch(met, text))) {
330
- metAtRound = currentRound - 1;
331
- }
332
- // v3.8: a claim reaches `claimed` at most. Only a machine fact about a
333
- // contract item that references this criterion can go further.
334
- const claimedStatus = metAtRound !== null
335
- ? "claimed"
336
- : lastRemaining.some((remaining) => criteriaMatch(remaining, text)) ||
337
- lastEntryRemaining.some((remaining) => criteriaMatch(remaining, text))
338
- ? "remaining"
339
- : "unknown";
340
- const fact = factsByCriterion.get(id);
341
- const status = fact?.status ?? claimedStatus;
342
- statuses.push({
343
- id,
344
- text,
345
- status,
346
- ...(metAtRound !== null ? { met_at_round: metAtRound } : {}),
347
- // v3.8.1: explicit refs only — empty when no contract item links this
348
- // criterion to a sub-goal.
349
- related_subgoal_ids: fact?.related_subgoal_ids ?? [],
350
- });
351
- }
352
- return statuses;
270
+ const history = buildCommittedHistory(contextEntries(context));
271
+ return deriveCriterionStatusesFromViews({
272
+ rounds: historyWindow(history, currentRound),
273
+ currentRound,
274
+ objective,
275
+ criterionFacts,
276
+ });
353
277
  }
354
278
  /** v3.8: Rank item statuses so the strongest machine fact wins when several
355
279
  * items reference the same criterion. */
@@ -361,189 +285,16 @@ export function criteriaMatch(a, b) {
361
285
  // v3.9.2: ONE identity rule, shared with the prompt and submission boundary.
362
286
  return criterionRefMatches(a, b);
363
287
  }
364
- /** Detect newly met criteria by comparing the current entry's
365
- * met criterion_claims against the previous entry's.
366
- * v2.11/v3.8.1: id-first (cr-XXXXXXXX), then normalized-exact text —
367
- * no similarity.
368
- * Returns empty array when there is no previous entry — the first
369
- * entry's criteria are the baseline, not a "new" event. */
370
- function detectNewCriteria(current, previous) {
371
- const currMet = entryCriteriaMet(current);
372
- if (currMet.length === 0)
373
- return [];
374
- if (!previous) {
375
- // First round with criteria is the baseline — not a milestone trigger.
376
- return [];
377
- }
378
- const prevMet = entryCriteriaMet(previous);
379
- if (prevMet.length === 0)
380
- return currMet;
381
- return currMet.filter((curr) => prevMet.every((prev) => !criteriaMatch(curr, prev)));
382
- }
383
- /** Build a single MilestoneSummary from a range of completed round entries. */
384
- function buildMilestoneFromEntries(phaseEntries, startRound, endRound, label, kind) {
385
- const last = phaseEntries.at(-1);
386
- const lastView = last ? decodeRound(last) : null;
387
- const outcomeRaw = lastView
388
- ? (lastView.outputSummary ?? "")
389
- : last && typeof last.output_summary === "string" ? last.output_summary : "";
390
- const outcomeMax = getPolicy().checkpoint.outcome_max_chars;
391
- const outcome = outcomeRaw.length > outcomeMax
392
- ? outcomeRaw.slice(0, outcomeMax) + "…"
393
- : outcomeRaw;
394
- const carried = last ? entryActiveConstraints(last) : [];
395
- const resolved = unique(phaseEntries.flatMap((e) => entryRetractedConstraints(e)));
396
- return makeMilestoneSummary({
397
- label,
398
- round_range: { start: startRound, end: endRound },
399
- outcome,
400
- carried_constraints: carried,
401
- resolved_constraints: resolved,
402
- kind,
403
- generated_at_round: endRound,
404
- });
405
- }
406
- /** v3.8.1: the milestone history bound. Applied identically at every prompt
407
- * level and in the state file, so the milestone set is a function of
408
- * committed facts rather than of which level happened to compile. (The
409
- * v3.0.1 sampler chose which milestones to keep from an UNBOUNDED list and
410
- * did nothing at L2 — so the state file’s phase history depended on the
411
- * prompt level and had no bound of its own.) */
412
- const MILESTONE_HISTORY_CAP = 50;
413
- export function buildRollingSummary(loopId, currentRound, context, sinceRound = 0) {
414
- const policy = getPolicy().summary;
415
- const allEntries = loopEntries(loopId, context);
416
- // ── Phase 1: Recent Window (preserves existing behavior) ──
417
- const windowEntries = allEntries
418
- .filter((entry) => {
419
- const round = entryRound(entry);
420
- return round >= sinceRound && round < currentRound;
421
- })
422
- .slice(-policy.window);
423
- const outcomes = [];
424
- for (const entry of windowEntries) {
425
- const view = decodeRound(entry);
426
- const data = entryLineage(entry);
427
- const round = entryRound(entry);
428
- const success = view ? view.success : entry.success ?? data.success;
429
- const summary = view
430
- ? view.outputSummary ?? ""
431
- : typeof entry.output_summary === "string"
432
- ? entry.output_summary
433
- : typeof data.output_summary === "string" ? data.output_summary : "";
434
- // v2.12: Declared outcome wins the label; the default wording stays
435
- // "accepted" for zero regression on existing loop output.
436
- const declared = view ? view.outcome : entry.outcome;
437
- const outcomeLabel = declared === "success" || declared === "partial" ||
438
- declared === "failed" || declared === "blocked"
439
- ? declared
440
- : success === false ? "failed" : "accepted";
441
- if (summary)
442
- outcomes.push(`[R${round}] ${outcomeLabel}: ${summary}`);
443
- }
444
- // ── Phase 2: Milestone Accumulation ──
445
- // Scan all completed entries in round order. For each entry, check three
446
- // trigger signals in priority order: agent_declared > criteria_milestone > auto.
447
- const completedEntries = allEntries.filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
448
- const milestones = [];
449
- let lastMilestoneRound = 0;
450
- for (let i = 0; i < completedEntries.length; i++) {
451
- const entry = completedEntries[i];
452
- const rnd = entryRound(entry);
453
- const lin = entryLineage(entry);
454
- // Signal 1: Agent-declared checkpoint
455
- const isAgentCheckpoint = lin.compression_checkpoint === true;
456
- // Signal 2: New criteria met (compare with previous vault entry)
457
- const prev = i > 0 ? completedEntries[i - 1] : null;
458
- const newCriteria = detectNewCriteria(entry, prev);
459
- if (isAgentCheckpoint || newCriteria.length > 0) {
460
- const startRound = lastMilestoneRound + 1;
461
- const phaseEntries = completedEntries.filter((e) => {
462
- const er = entryRound(e);
463
- return er >= startRound && er <= rnd;
464
- });
465
- const kind = isAgentCheckpoint
466
- ? "agent_declared"
467
- : "criteria_milestone";
468
- const label = isAgentCheckpoint
469
- ? (typeof lin.checkpoint_label === "string" && lin.checkpoint_label.trim()
470
- ? lin.checkpoint_label.trim()
471
- : `Round ${rnd}`)
472
- : `Completed: ${newCriteria.join(", ")}`;
473
- milestones.push(buildMilestoneFromEntries(phaseEntries, startRound, rnd, label, kind));
474
- lastMilestoneRound = rnd;
475
- }
476
- }
477
- // Signal 3: Safety net — auto milestone when gap exceeds milestone_interval.
478
- // v3.3.1: the policy contract (milestone_interval: "only fires when no
479
- // agent_declared or criteria_milestone has been created for this many
480
- // rounds") includes loops that NEVER created a milestone — but the guard
481
- // `lastMilestoneRound > 0` made the gap start from the (nonexistent) first
482
- // milestone, so a checkpoint-less loop could run forever with an empty
483
- // Phase History and no memory skeleton. With lastMilestoneRound == 0 the
484
- // gap is measured from round 1 and the safety net fires from the very
485
- // first interval.
486
- const gapSinceLast = (currentRound - 1) - lastMilestoneRound;
487
- if (gapSinceLast >= policy.milestone_interval &&
488
- completedEntries.length > 0) {
489
- const startRound = lastMilestoneRound + 1;
490
- const endRound = currentRound - 1;
491
- const phaseEntries = completedEntries.filter((e) => {
492
- const er = entryRound(e);
493
- return er >= startRound && er <= endRound;
494
- });
495
- if (phaseEntries.length > 0) {
496
- milestones.push(buildMilestoneFromEntries(phaseEntries, startRound, endRound, `Rounds ${startRound}–${endRound}`, "auto"));
497
- }
498
- }
499
- // v3.8.1: one bound, applied identically at every prompt level. The v3.0.1
500
- // sampler kept head anchors + a newest tail + an even middle sample, and
501
- // deliberately did nothing at L2 — so the milestone set written to the state
502
- // file depended on which level happened to compile, not on committed facts.
503
- // It also did not bound growth at all (it only chose WHICH milestones to
504
- // keep from an unbounded list), which is why the cap has to live here.
505
- const cappedMilestones = milestones.slice(-MILESTONE_HISTORY_CAP);
506
- if (windowEntries.length === 0 && cappedMilestones.length === 0)
507
- return null;
508
- return makeRollingSummary({
509
- key_outcomes: unique(outcomes),
510
- rounds_sampled: windowEntries.length,
511
- generated_at_round: currentRound,
512
- failed_patterns: [],
513
- milestones: cappedMilestones,
514
- });
515
- }
516
288
  // ═══════════════════════════════════════════════════════════════════════════
517
289
  // Sub-Goal Structured Tracking (v2.2)
518
290
  // ═══════════════════════════════════════════════════════════════════════════
519
- /** v2.11: Derive a stable constraint ID from its text hash (c-XXXXXXXX).
520
- * Same hash strategy as SubGoal — deterministic across rounds. */
521
- export function deriveConstraintId(text) {
522
- return "c-" + deriveItemId(text);
523
- }
524
- // v3.8.1: `deriveCriterionId` / `isCriterionId` moved to token-utils.ts, where
525
- // the other stable-id helpers live — the criterion fact derivation in
526
- // round-facts.ts needs them, and defining them here would make
527
- // round-facts ⇄ loop-compiler an import cycle. Re-exported so this module's
528
- // public surface ("./compiler") is unchanged.
529
- 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";
530
295
  // ═══════════════════════════════════════════════════════════════════════════
531
296
  // Time-Aware Constraint Management (v2.3)
532
297
  // ═══════════════════════════════════════════════════════════════════════════
533
- /** Determine the source of a constraint by matching against known sources. */
534
- function constraintSource(text, objective, constraintsFromPlan) {
535
- const norm = normalizeText(text);
536
- // v3.9.4: criteria identity wins. A text that is both a criterion and a
537
- // hard/plan constraint keeps its cr-* id everywhere; the prompt partition
538
- // renders it once, in the criteria section.
539
- if (objective.success_criteria.some((c) => normalizeText(c) === norm))
540
- return "criteria";
541
- if (objective.hard_constraints.some((c) => normalizeText(c) === norm))
542
- return "hard";
543
- if (constraintsFromPlan.some((c) => normalizeText(c) === norm))
544
- return "plan";
545
- return "discovered";
546
- }
547
298
  /** True when a reported violation text matches a constraint text.
548
299
  * v2.11: ID-first — an exact `c-XXXXXXXX` match against the derived id.
549
300
  * v3.8.1: the Jaccard fallback is gone; the remaining arm requires the
@@ -557,22 +308,13 @@ function matchesConstraintText(text, violation) {
557
308
  }
558
309
  return normalizeText(text) === normalizeText(violation);
559
310
  }
560
- function findLastViolatedRound(text, vaultEntries) {
311
+ function findLastViolatedRound(text, rounds) {
561
312
  let latest = 0;
562
- for (const entry of vaultEntries) {
563
- const rnd = entryRound(entry);
564
- if (rnd < 1)
565
- continue;
566
- const view = decodeRound(entry);
567
- const violations = view
568
- ? view.constraintViolations ?? []
569
- : Array.isArray(entry.constraint_violations)
570
- ? entry.constraint_violations.filter((v) => typeof v === "string")
571
- : [];
572
- for (const v of violations) {
313
+ for (const view of rounds) {
314
+ for (const v of view.constraintViolations ?? []) {
573
315
  if (matchesConstraintText(text, v)) {
574
- if (rnd > latest)
575
- latest = rnd;
316
+ if (view.round > latest)
317
+ latest = view.round;
576
318
  break;
577
319
  }
578
320
  }
@@ -590,18 +332,10 @@ function findLastViolatedRound(text, vaultEntries) {
590
332
  * and last_violated_at_round for the L1 collapse "(violated this round)"
591
333
  * annotation (diffConstraints compares it to the current round).
592
334
  */
593
- function manageConstraintLifecycle(active, objective, constraintsFromPlan, vaultContext, currentRound, lastRoundViolations = []) {
594
- const allEntries = loopEntries(objective.loop_id || "", vaultContext);
595
- const completedEntries = allEntries.filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound);
335
+ function manageConstraintLifecycle(active, provenance, windowRounds) {
596
336
  const metadata = [];
597
337
  for (const text of active) {
598
- const source = constraintSource(text, objective, constraintsFromPlan);
599
- // v3.2.1: a violation reported in THIS round (last_round_result) counts
600
- // as a violation at currentRound — the previous rounds' scan cannot see
601
- // it (this round is not committed yet). This makes the L1 collapse
602
- // render "(violated this round)" (diffConstraints compares
603
- // last_violated_at_round === round).
604
- const violatedThisRound = lastRoundViolations.some((v) => matchesConstraintText(text, v));
338
+ const source = provenance.get(text) ?? { source: "unrecorded" };
605
339
  // v3.3.1: one text, one identity — a success-criterion text that also
606
340
  // lives in the merged active set belongs to the cr-XXXXXXXX namespace
607
341
  // (its own Success Criteria section and the verification gate's
@@ -610,34 +344,27 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
610
344
  // and an agent echoing the active-list ID into its criterion claims
611
345
  // could never match.
612
346
  metadata.push(makeConstraintMeta({
613
- id: source === "criteria" ? deriveCriterionId(text) : deriveConstraintId(text),
347
+ id: source.source === "criteria" ? deriveCriterionId(text) : deriveConstraintId(text),
614
348
  text,
615
- last_violated_at_round: violatedThisRound
616
- ? currentRound
617
- : findLastViolatedRound(text, completedEntries),
618
- source,
349
+ last_violated_at_round: findLastViolatedRound(text, windowRounds),
350
+ source: source.source,
351
+ ...(source.source_round !== undefined ? { source_round: source.source_round } : {}),
619
352
  }));
620
353
  }
621
354
  return { active, metadata };
622
355
  }
623
356
  /** Resolve the ONE previous-round fact bundle for a compile.
624
357
  *
625
- * The committed view wins whenever a round is committed. `last_round_result`
626
- * is only the caller's in-flight projection for engine/library callers with
627
- * no committed round in the context. In-flight attempt findings are
628
- * authoritative even when empty: a retry must never inherit the previous
629
- * committed round's flags. */
630
- function resolvePreviousRoundFacts(request, context, windowRounds) {
631
- const rounds = windowRounds ??
632
- derivationRounds(loopEntries(request.loop_id, context), request.round);
633
- const view = rounds.find((round) => round.round === request.round - 1) ?? null;
634
- const recoveryBoundary = pendingBacktrackRecord(loopEntries(request.loop_id, context), request.round) !== null;
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;
635
364
  const inFlight = (request.attempt ?? 1) > 1 || recoveryBoundary;
636
365
  return {
637
366
  view,
638
- result: view
639
- ? roundResultFromCommittedRound(view)
640
- : request.last_round_result,
367
+ result: view ? roundResultFromCommittedRound(view) : null,
641
368
  verificationFlags: inFlight
642
369
  ? (request.verification_flags ?? [])
643
370
  : (view?.verificationFlags ?? request.verification_flags ?? []),
@@ -645,32 +372,45 @@ function resolvePreviousRoundFacts(request, context, windowRounds) {
645
372
  recoveryBoundary,
646
373
  };
647
374
  }
648
- function levelDecision(request, prior, previous) {
375
+ function levelDecision(request, prior, previous, goalId) {
649
376
  return decidePromptLevel({
650
377
  round: request.round,
651
378
  attempt: request.attempt,
652
379
  forceLevel: request.force_level,
653
380
  hasPlanSource: Boolean(request.plan_source),
654
381
  recoveryBoundary: previous.recoveryBoundary,
655
- checkpointBoundary: previous.result?.compression_checkpoint === true,
656
- // An empty goal_id is unobservable, not a goal change: never fabricate L2.
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.
657
386
  goalChanged: prior !== null && prior.goal_id !== "" &&
658
- prior.goal_id !== deriveGoalId(request.loop_id, request.task, request.goal_id),
387
+ prior.goal_id !== goalId,
659
388
  previousStateMissing: request.round > 1 && prior === null,
660
- previousRoundFailed: previous.view
661
- ? previous.view.success === false
662
- : previous.result?.success === false,
663
- previousRoundObservableNewInformation: previous.view
664
- ? previous.freshness.observableNewInformation
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
+ }
665
399
  : null,
666
- verificationContradicted: previous.verificationFlags
667
- .some((flag) => flag.severity === "error"),
668
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,
669
404
  });
670
405
  }
671
- export function decideLevel(request, context) {
672
- const prior = getPreviousRound(request.loop_id, request.round - 1, context);
673
- return levelDecision(request, prior, resolvePreviousRoundFacts(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;
674
414
  }
675
415
  /** The prompt's evaluation block. All content (examples, contract/sub-goal
676
416
  * guidance, flag-specific hints) comes from the one FIELD_GUIDES table in
@@ -678,82 +418,105 @@ export function decideLevel(request, context) {
678
418
  export function buildSelfEvalBlock(ctx) {
679
419
  return renderSelfEvalBlock(ctx);
680
420
  }
681
- // v2.5: checkpoint() removed — agent_declared milestones from
682
- // compression_checkpoint supersede the separate CheckpointSummary.
683
- // See buildRollingSummary() signal 1.
684
421
  /** v3.7.1: Derived Recovery Brief for the state-file Recent tier. Present
685
422
  * ONLY while a committed backtrack decision is the current round's record
686
423
  * (the recovery window); the redo commit replaces that record, so the
687
424
  * brief exits the projection by construction. Sources are the committed
688
425
  * rollback decision's own fields — no new persistence, no rejected
689
426
  * payloads. */
690
- function committedBacktrackBrief(loopId, round, context) {
427
+ function committedBacktrackBrief(loopId, round, context, restoreVerified) {
691
428
  // The SAME pending-backtrack lookup the recovery boundary uses: a deep
692
429
  // restore commits at the failed round while its redo targets
693
430
  // `backtrackTarget + 1`, so an exact-round match loses both the boundary
694
431
  // and this brief.
695
- const view = pendingBacktrackRecord(loopEntries(loopId, context), round);
696
- const record = view?.backtrack;
432
+ const view = pendingBacktrackRecord(contextEntries(context), round);
433
+ const record = view?.recovery;
697
434
  if (!record)
698
435
  return undefined;
699
436
  const lines = recoveryBriefLines({
700
- target: record.target,
701
- triggerRule: record.triggerRule,
702
- // The committed decision's own sentence — the machine's reason for the
703
- // rollback, preserved as a fact (the old prose header's "Why" content).
704
- ...(typeof view?.result?.enforcementReason === "string" &&
705
- view.result.enforcementReason.trim()
706
- ? { failureReason: view.result.enforcementReason }
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 }
707
447
  : {}),
708
- failedRounds: record.failedRounds,
448
+ failedRounds: record.failed_rounds,
709
449
  approaches: record.approaches,
710
- wrongAssumptions: record.wrongAssumptions,
450
+ wrongAssumptions: record.wrong_assumptions,
451
+ preservedDiscoveries: record.skipped_discoveries,
711
452
  // The redo round's stable identity is a fact the old prepended prose
712
453
  // carried; keep it inside the artifact now.
713
- recoveryRoundId: `loop:${loopId}:round:${record.target + 1}`,
454
+ recoveryRoundId: `loop:${loopId}:round:${record.target.redo_round}`,
714
455
  });
715
- // v3.9.4: the restore point's machine facts belong in the prompt, not only
716
- // in the prose backtrack header. Deterministic order and a visible overflow
717
- // count keep the fact complete without making the section unbounded.
718
- if (record.targetGitHead) {
719
- lines.push(`- **Restore HEAD**: \`${record.targetGitHead}\``);
720
- }
721
- if (record.skippedFiles.length > 0) {
722
- const files = [...record.skippedFiles].sort();
723
- const shown = files.slice(0, 15);
724
- lines.push("- **Files changed in failed rounds**:");
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)**:");
725
462
  for (const file of shown)
726
463
  lines.push(` - \`${file}\``);
727
- if (files.length > shown.length) {
728
- lines.push(` - … and ${files.length - shown.length} more files`);
464
+ if (observedFiles.length > shown.length) {
465
+ lines.push(` - … and ${observedFiles.length - shown.length} more files`);
729
466
  }
730
467
  }
731
- // The machine requirement the recovery checkpoint enforces. A fact, not a
732
- // recipe: LoopForge never says HOW to restore the workspace.
733
- if (record.targetGitHead || record.skippedFiles.length > 0) {
734
- lines.push(`- **Restore requirement**: the next submission's workspace is checked ` +
735
- `against round ${record.target}` +
736
- (record.targetGitHead ? ` (HEAD \`${record.targetGitHead}\`)` : "") +
737
- "; a leftover change from the failed rounds rejects the recovery checkpoint.");
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
+ }
738
476
  }
739
477
  return lines;
740
478
  }
741
- export function compileLoop(request, context) {
479
+ export function compileLoop(request, context, options = {}, committedHistory) {
480
+ const operatorAcceptance = options.operatorAcceptance ?? null;
742
481
  // ONE history window and ONE previous-round fact bundle for the whole
743
- // compile. Every consumer below reads these values; none re-reads the
744
- // caller's mutable last_round_result.
745
- const windowRounds = derivationRounds(loopEntries(request.loop_id, context), request.round);
746
- const prior = getPreviousRound(request.loop_id, request.round - 1, context);
747
- const previous = resolvePreviousRoundFacts(request, context, windowRounds);
748
- const decision = levelDecision(request, prior, previous);
749
- const objective = evolveObjective(request, context, previous);
750
- const constraints = evolveConstraints(request, objective, previous, windowRounds);
751
- const constraintLifecycle = manageConstraintLifecycle(constraints.active, objective, request.constraints_from_plan, context, request.round,
752
- // "Violated this round" means the CURRENT compile round's report. A
753
- // committed previous round's violations are found by the committed
754
- // history scan at their real round, not relabelled as this round.
755
- previous.view ? [] : (request.last_round_result?.constraint_violations ?? []));
756
- const rolling = buildRollingSummary(request.loop_id, request.round, context, 0);
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);
757
520
  // v3.8: sub-goal lifecycle lives in subgoal-state.ts and consumes the
758
521
  // shared committed-round view — one history interpretation. The previous
759
522
  // round's report is idempotently replayed; a committed round is already in
@@ -762,31 +525,21 @@ export function compileLoop(request, context) {
762
525
  loopId: request.loop_id,
763
526
  currentRound: request.round,
764
527
  rounds: windowRounds,
765
- currentReport: previous.result
766
- ? {
767
- round: previous.result.round || (request.round - 1),
768
- emergedSubtasks: previous.result.emerged_subtasks ?? [],
769
- subgoalUpdates: previous.result.subgoal_updates ?? [],
770
- }
771
- : null,
772
528
  });
773
- // v3.4/v3.8/v3.8.1: the ACTIVE Round Contract, its per-item statuses and the
774
- // verified sub-goal facts for this compile — ONE `deriveRoundFacts` call,
775
- // the same bundle the projection consumes. Derived from committed rounds, so
776
- // rejection retries, resume, unpause and backtrack compiles (which carry no
777
- // last_round_result) keep showing the same contract, and a verified or
778
- // blocked contract stops being shown. A verified item stays proven after its
779
- // contract closes because the bundle reads the whole history, not the active
780
- // 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.
781
537
  const roundFacts = deriveRoundFacts({
782
538
  rounds: windowRounds,
783
539
  currentRound: request.round,
784
540
  inFlight: NO_IN_FLIGHT_ROUND,
785
- subGoals,
786
541
  commands: getPolicy().evidence.commands ?? [],
787
542
  });
788
- // `roundFacts.verifiedSubGoals` is not read here — it reaches the state file
789
- // and the projection through the bundle itself.
790
543
  const { activeContract } = roundFacts;
791
544
  // v3.9.4: the criterion reference space is the ACTIVE criterion set, not
792
545
  // every text the objective ever carried. A retracted criterion must not be
@@ -809,13 +562,27 @@ export function compileLoop(request, context) {
809
562
  // gone with the rest of the fuzzy matching, and so are the task-alignment
810
563
  // and loop-health warnings: both were Jaccard scores over task/objective
811
564
  // text, never machine facts.
812
- const repeatedDeclarations = duplicateEmergedDeclarations(previous.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
+ : [];
813
570
  const warnings = unique([
814
571
  repeatedDeclarations.length > 0
815
572
  ? `duplicate_declaration: ${repeatedDeclarations.length} sub-goal ` +
816
573
  `declaration(s) repeated an earlier entry in the same round and were ` +
817
574
  `not created: ${repeatedDeclarations.join("; ")}`
818
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
+ : "",
819
586
  ]);
820
587
  // v3.8.1: the agent trust score and trend are deleted. The score was
821
588
  // `1 - errors*0.15 - warns*0.03` — arbitrary weights with no machine
@@ -830,20 +597,28 @@ export function compileLoop(request, context) {
830
597
  // (and therefore into stateHash) and read by nothing. `windowRounds` is the
831
598
  // ONE window computed at the top of this function.
832
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
+ });
833
612
  const response = makeLoopCompileResponse({
834
613
  status: AgentStatus.OK,
835
614
  recompile_level: decision.level,
836
- diff_from_previous: decision.reasons.join(","),
837
- lineage: [`${request.loop_id}:r${request.round}`],
838
615
  constraints_active: constraintLifecycle.active,
839
616
  constraints_retired: constraints.retired,
840
617
  constraint_metadata: constraintLifecycle.metadata,
841
618
  loop_id: request.loop_id,
842
619
  round: request.round,
843
- goal_id: deriveGoalId(request.loop_id, request.task, request.goal_id),
844
- goal_text_hash: computeGoalTextHash(request.task),
620
+ goal_id: goalId,
845
621
  loop_objective: objective,
846
- rolling_summary: rolling,
847
622
  sub_goals: subGoals,
848
623
  criterion_statuses: criterionStatuses,
849
624
  recurring_flags: recurringFlags,
@@ -858,6 +633,10 @@ export function compileLoop(request, context) {
858
633
  const statePath = policy.state_file.enabled
859
634
  ? `${policy.state_file.directory}/${request.loop_id}-state.md`
860
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);
861
640
  const state = createCanonicalLoopState(request, response, statePath, {
862
641
  machineStatus,
863
642
  // v3.4/v3.8.1: the contract-fact bundle — the Current Task's source of
@@ -865,14 +644,20 @@ export function compileLoop(request, context) {
865
644
  // ONE derivation. The static capability rides on the state itself.
866
645
  roundFacts,
867
646
  // 3.9.3: the same previous-round bundle the level decision used. This is
868
- // what keeps changes/discoveries/blockers/remaining/progress identical on
869
- // live, resume, and unpause compiles.
870
- previousRound: previous,
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
+ },
871
660
  });
872
- // The recovery brief is a current-round fact, computed once and shared by
873
- // the state file and the evaluation block. It never becomes prompt control
874
- // state.
875
- const recoveryBrief = committedBacktrackBrief(request.loop_id, request.round, context);
876
661
  const configuredCapability = deriveConfiguredCapability(policy);
877
662
  const contractCommands = configuredCapability.commands
878
663
  .filter((command) => command.enabled)
@@ -884,6 +669,12 @@ export function compileLoop(request, context) {
884
669
  // round's record; the redo commit removes it by construction.
885
670
  attempt: request.attempt,
886
671
  recoveryBrief,
672
+ context: {
673
+ round: request.round,
674
+ attempt: request.attempt,
675
+ mode: contextBoundary.mode,
676
+ reason: contextBoundary.reason,
677
+ },
887
678
  });
888
679
  // v3.8.1: the L2 budget is a FIXED ceiling. The v2.4 adaptive budget
889
680
  // scaled it by round count, milestone count and sub-goal count, which made
@@ -891,25 +682,22 @@ export function compileLoop(request, context) {
891
682
  // rather than on the facts of this round — the same loop could render a
892
683
  // section at round 5 and silently drop it at round 50.
893
684
  //
894
- // v2.8: L2 pointer mode — when enabled, skip the monolithic markdown blob.
895
- // Structured L2 sections (milestones, sub-goals, trust, progress) still
896
- // render. The state file on disk is the durable source of truth.
897
- // L7: pointer mode REQUIRES the file — with state_file disabled it would
898
- // silently drop the full state from L2 prompts and point at nothing.
899
- const l2Pointer = policy.state_file.enabled &&
900
- 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.
901
688
  const artifact = assemblePromptArtifact({
902
689
  state,
903
690
  level: decision.level,
904
- reasons: decision.reasons,
905
691
  budgets: {
906
692
  l0: policy.prompt.l0_max_chars,
907
693
  l1: policy.prompt.l1_max_chars,
908
694
  l2: policy.prompt.l2_max_chars,
909
695
  },
910
696
  attempt: request.attempt,
697
+ warnings,
911
698
  selfEvaluationBlock: buildSelfEvalBlock({
912
699
  round: request.round,
700
+ maxRounds: request.max_rounds ?? policy.engine.max_rounds,
913
701
  level: decision.level,
914
702
  activeContract,
915
703
  itemStatuses: roundFacts.itemStatuses,
@@ -920,19 +708,34 @@ export function compileLoop(request, context) {
920
708
  recovery: recoveryBrief !== undefined,
921
709
  gateEnabled: policy.gate.enabled === true,
922
710
  criteria: criterionStatuses,
923
- currentRequirement: state.currentTask,
924
711
  }),
925
- fullStateMarkdown: l2Pointer ? undefined : markdown,
926
712
  // v3.9.4: the SAME committed-backtrack facts the state file renders. The
927
713
  // recovery section is part of the artifact, so the delivered prompt is
928
714
  // exactly `artifact.renderedPrompt` (no post-compile prepend).
929
715
  recoveryBrief,
930
- // v2.9: Model's information needs from the previous round's SelfEvaluation
931
- promptRequests: previous.result?.prompt_requests,
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,
932
721
  });
933
722
  response.prompt = artifact.renderedPrompt;
934
723
  response.prompt_artifact = artifact;
935
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
+ };
936
739
  return response;
937
740
  }
938
741
  //# sourceMappingURL=loop-compiler.js.map