loopforge 3.7.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +171 -157
  3. package/dist/audit.d.ts +53 -1
  4. package/dist/audit.d.ts.map +1 -1
  5. package/dist/audit.js +106 -22
  6. package/dist/audit.js.map +1 -1
  7. package/dist/canonical-state.d.ts +75 -69
  8. package/dist/canonical-state.d.ts.map +1 -1
  9. package/dist/canonical-state.js +276 -168
  10. package/dist/canonical-state.js.map +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +111 -12
  13. package/dist/cli.js.map +1 -1
  14. package/dist/cognitive-facts.d.ts +13 -0
  15. package/dist/cognitive-facts.d.ts.map +1 -1
  16. package/dist/cognitive-facts.js +30 -16
  17. package/dist/cognitive-facts.js.map +1 -1
  18. package/dist/cognitive-governance.d.ts +30 -6
  19. package/dist/cognitive-governance.d.ts.map +1 -1
  20. package/dist/cognitive-governance.js +74 -18
  21. package/dist/cognitive-governance.js.map +1 -1
  22. package/dist/committed-round.d.ts +109 -24
  23. package/dist/committed-round.d.ts.map +1 -1
  24. package/dist/committed-round.js +235 -52
  25. package/dist/committed-round.js.map +1 -1
  26. package/dist/contract-items.d.ts +55 -0
  27. package/dist/contract-items.d.ts.map +1 -0
  28. package/dist/contract-items.js +180 -0
  29. package/dist/contract-items.js.map +1 -0
  30. package/dist/enforcement-gate.d.ts +43 -20
  31. package/dist/enforcement-gate.d.ts.map +1 -1
  32. package/dist/enforcement-gate.js +269 -378
  33. package/dist/enforcement-gate.js.map +1 -1
  34. package/dist/engine.d.ts +11 -6
  35. package/dist/engine.d.ts.map +1 -1
  36. package/dist/engine.js +102 -67
  37. package/dist/engine.js.map +1 -1
  38. package/dist/evidence-claims.d.ts +4 -4
  39. package/dist/evidence-claims.d.ts.map +1 -1
  40. package/dist/evidence-claims.js +16 -14
  41. package/dist/evidence-claims.js.map +1 -1
  42. package/dist/evidence-provider.d.ts +91 -90
  43. package/dist/evidence-provider.d.ts.map +1 -1
  44. package/dist/evidence-provider.js +349 -160
  45. package/dist/evidence-provider.js.map +1 -1
  46. package/dist/explain.d.ts +76 -0
  47. package/dist/explain.d.ts.map +1 -0
  48. package/dist/explain.js +127 -0
  49. package/dist/explain.js.map +1 -0
  50. package/dist/generate-schema.js +250 -53
  51. package/dist/generate-schema.js.map +1 -1
  52. package/dist/index.d.ts +8 -7
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +7 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/loop-compiler.d.ts +41 -40
  57. package/dist/loop-compiler.d.ts.map +1 -1
  58. package/dist/loop-compiler.js +275 -515
  59. package/dist/loop-compiler.js.map +1 -1
  60. package/dist/loop-extras-parser.d.ts +0 -1
  61. package/dist/loop-extras-parser.d.ts.map +1 -1
  62. package/dist/loop-extras-parser.js +0 -4
  63. package/dist/loop-extras-parser.js.map +1 -1
  64. package/dist/loop-projection.d.ts.map +1 -1
  65. package/dist/loop-projection.js +3 -1
  66. package/dist/loop-projection.js.map +1 -1
  67. package/dist/mcp/round-lifecycle.d.ts +40 -21
  68. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  69. package/dist/mcp/round-lifecycle.js +173 -120
  70. package/dist/mcp/round-lifecycle.js.map +1 -1
  71. package/dist/mcp/server.d.ts.map +1 -1
  72. package/dist/mcp/server.js +63 -10
  73. package/dist/mcp/server.js.map +1 -1
  74. package/dist/mcp/session.d.ts +53 -7
  75. package/dist/mcp/session.d.ts.map +1 -1
  76. package/dist/mcp/session.js +240 -139
  77. package/dist/mcp/session.js.map +1 -1
  78. package/dist/mcp/submission-boundary.d.ts +89 -0
  79. package/dist/mcp/submission-boundary.d.ts.map +1 -0
  80. package/dist/mcp/submission-boundary.js +203 -0
  81. package/dist/mcp/submission-boundary.js.map +1 -0
  82. package/dist/mcp/tools.d.ts.map +1 -1
  83. package/dist/mcp/tools.js +377 -125
  84. package/dist/mcp/tools.js.map +1 -1
  85. package/dist/policy-metrics.js +2 -2
  86. package/dist/policy-metrics.js.map +1 -1
  87. package/dist/policy.d.ts +65 -95
  88. package/dist/policy.d.ts.map +1 -1
  89. package/dist/policy.js +151 -39
  90. package/dist/policy.js.map +1 -1
  91. package/dist/prompt-assembler.d.ts +7 -33
  92. package/dist/prompt-assembler.d.ts.map +1 -1
  93. package/dist/prompt-assembler.js +313 -473
  94. package/dist/prompt-assembler.js.map +1 -1
  95. package/dist/prompt-policy.d.ts +1 -4
  96. package/dist/prompt-policy.d.ts.map +1 -1
  97. package/dist/prompt-policy.js +6 -7
  98. package/dist/prompt-policy.js.map +1 -1
  99. package/dist/protocol.d.ts +411 -168
  100. package/dist/protocol.d.ts.map +1 -1
  101. package/dist/protocol.js +23 -69
  102. package/dist/protocol.js.map +1 -1
  103. package/dist/replay.js +2 -2
  104. package/dist/replay.js.map +1 -1
  105. package/dist/round-contract.d.ts +69 -57
  106. package/dist/round-contract.d.ts.map +1 -1
  107. package/dist/round-contract.js +104 -64
  108. package/dist/round-contract.js.map +1 -1
  109. package/dist/round-coordinator.d.ts +55 -16
  110. package/dist/round-coordinator.d.ts.map +1 -1
  111. package/dist/round-coordinator.js +179 -57
  112. package/dist/round-coordinator.js.map +1 -1
  113. package/dist/round-driver.d.ts +7 -5
  114. package/dist/round-driver.d.ts.map +1 -1
  115. package/dist/round-driver.js +2 -1
  116. package/dist/round-driver.js.map +1 -1
  117. package/dist/round-facts.d.ts +96 -0
  118. package/dist/round-facts.d.ts.map +1 -0
  119. package/dist/round-facts.js +216 -0
  120. package/dist/round-facts.js.map +1 -0
  121. package/dist/round-transaction.d.ts +23 -9
  122. package/dist/round-transaction.d.ts.map +1 -1
  123. package/dist/round-transaction.js +99 -22
  124. package/dist/round-transaction.js.map +1 -1
  125. package/dist/self-eval.d.ts +127 -11
  126. package/dist/self-eval.d.ts.map +1 -1
  127. package/dist/self-eval.js +517 -36
  128. package/dist/self-eval.js.map +1 -1
  129. package/dist/subgoal-state.d.ts +77 -0
  130. package/dist/subgoal-state.d.ts.map +1 -0
  131. package/dist/subgoal-state.js +198 -0
  132. package/dist/subgoal-state.js.map +1 -0
  133. package/dist/token-utils.d.ts +52 -19
  134. package/dist/token-utils.d.ts.map +1 -1
  135. package/dist/token-utils.js +83 -72
  136. package/dist/token-utils.js.map +1 -1
  137. package/dist/verification-gate.d.ts +27 -46
  138. package/dist/verification-gate.d.ts.map +1 -1
  139. package/dist/verification-gate.js +308 -557
  140. package/dist/verification-gate.js.map +1 -1
  141. package/dist/version.d.ts +5 -0
  142. package/dist/version.d.ts.map +1 -0
  143. package/dist/version.js +5 -0
  144. package/dist/version.js.map +1 -0
  145. package/dist/workspace.d.ts +9 -0
  146. package/dist/workspace.d.ts.map +1 -1
  147. package/dist/workspace.js +17 -0
  148. package/dist/workspace.js.map +1 -1
  149. package/loop_policy.json +17 -25
  150. package/package.json +7 -7
  151. package/skills/perception/SKILL.md +266 -64
@@ -5,8 +5,32 @@
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
7
  import { deriveItemId, STABLE_ID_RE, unique } from "./token-utils.js";
8
+ import { claimedMetCriteria, claimedRemainingCriteria } from "./self-eval.js";
8
9
  import { getPolicy } from "./policy.js";
10
+ import { deriveConfiguredCapability } from "./policy.js";
9
11
  export const CANONICAL_STATE_SCHEMA_VERSION = 1;
12
+ /** v3.7.1: Presentation view of sub-goals shared by prompts and the state
13
+ * file (one derivation, no second copy). Only ACTIVE items render as rows
14
+ * — pending/in_progress/blocked — ordered blocked → in_progress → pending
15
+ * (priority ascending, then most recently changed first) and trimmed to
16
+ * `cap`. done/canceled never render as items: they stay in the vault, in
17
+ * replay, and in these counts. */
18
+ export function activeSubGoalView(subGoals, cap) {
19
+ const rank = (s) => s.status === "blocked" ? 0 : s.status === "in_progress" ? 1 : 2;
20
+ const active = subGoals
21
+ .filter((s) => s.status !== "done" && s.status !== "canceled")
22
+ .sort((a, b) => rank(a) - rank(b) ||
23
+ a.priority - b.priority ||
24
+ b.status_changed_at_round - a.status_changed_at_round ||
25
+ (a.id < b.id ? -1 : 1));
26
+ return {
27
+ active: active.slice(0, cap),
28
+ activeTotal: active.length,
29
+ done: subGoals.filter((s) => s.status === "done").length,
30
+ canceled: subGoals.filter((s) => s.status === "canceled").length,
31
+ total: subGoals.length,
32
+ };
33
+ }
10
34
  // unique() imported from token-utils.ts
11
35
  /** Deterministic JSON serialization used by state and prompt hashes. */
12
36
  export function stableStringify(value) {
@@ -23,6 +47,16 @@ export function stableStringify(value) {
23
47
  export function hashCanonicalState(state) {
24
48
  return createHash("sha256").update(stableStringify(state)).digest("hex");
25
49
  }
50
+ /** v3.8.1: how many recurring flags the state file lists before folding the
51
+ * rest into a count. The prompt keeps its own, smaller cap (Active Warnings)
52
+ * over a different slice of the same list. */
53
+ const RECURRING_RENDER_CAP = 8;
54
+ /** v3.8.1: the state file's recurring slice — only facts that actually
55
+ * RECURRED (present in >= 2 rounds). The prompt shows the recent tail
56
+ * instead; both read the same `RecurringFlag` list. */
57
+ function recurringOnly(flags) {
58
+ return flags.filter((flag) => flag.rounds.length >= 2);
59
+ }
26
60
  function addList(lines, title, values) {
27
61
  if (values.length === 0)
28
62
  return;
@@ -33,13 +67,16 @@ function addList(lines, title, values) {
33
67
  }
34
68
  /** v2.11: Derive a stable 8-char hex ID from text using SHA-256.
35
69
  * Same hash strategy as computeGoalTextHash in loop-compiler.ts. */
36
- /** v2.11: Render a list with ID prefixes when constraint_id_enabled is true.
37
- * Each item gets a [prefix-XXXXXXXX] tag derived from its text hash.
38
- * Items that already look like IDs (e.g. "cr-a3f2b1c0") are rendered as-is. */
70
+ /** v2.11: Render a list with ID prefixes. Each item gets a
71
+ * [prefix-XXXXXXXX] tag derived from its text hash. Items that already look
72
+ * like IDs (e.g. "cr-a3f2b1c0") are rendered as-is.
73
+ * v3.8.1: IDs are always rendered — the `constraint_id_enabled` escape hatch
74
+ * was deleted (it never switched the MATCHING strategy, only the rendering,
75
+ * so it could only ever produce a prompt whose items the agent could not
76
+ * name). */
39
77
  function addListWithIds(lines, title, values, prefix) {
40
78
  if (values.length === 0)
41
79
  return;
42
- const idEnabled = getPolicy().evolution.constraint_id_enabled;
43
80
  lines.push(`## ${title}`, "");
44
81
  for (const value of values) {
45
82
  const trimmed = value.trim();
@@ -49,12 +86,8 @@ function addListWithIds(lines, title, values, prefix) {
49
86
  if (STABLE_ID_RE.test(trimmed)) {
50
87
  lines.push(`- [\`${trimmed}\`] ${trimmed}`);
51
88
  }
52
- else if (idEnabled) {
53
- const id = prefix + "-" + deriveItemId(trimmed);
54
- lines.push(`- [\`${id}\`] ${trimmed}`);
55
- }
56
89
  else {
57
- lines.push(`- ${trimmed}`);
90
+ lines.push(`- [\`${prefix}-${deriveItemId(trimmed)}\`] ${trimmed}`);
58
91
  }
59
92
  }
60
93
  lines.push("");
@@ -69,7 +102,6 @@ function addListWithIds(lines, title, values, prefix) {
69
102
  function addListWithIdentities(lines, title, values, metadata) {
70
103
  if (values.length === 0)
71
104
  return;
72
- const idEnabled = getPolicy().evolution.constraint_id_enabled;
73
105
  const idByText = new Map(metadata.map((meta) => [meta.text, meta.id]));
74
106
  lines.push(`## ${title}`, "");
75
107
  for (const value of values) {
@@ -79,110 +111,82 @@ function addListWithIdentities(lines, title, values, metadata) {
79
111
  if (STABLE_ID_RE.test(trimmed)) {
80
112
  lines.push(`- [\`${trimmed}\`] ${trimmed}`);
81
113
  }
82
- else if (idEnabled) {
114
+ else {
83
115
  const id = idByText.get(trimmed) ?? `c-${deriveItemId(trimmed)}`;
84
116
  lines.push(`- [\`${id}\`] ${trimmed}`);
85
117
  }
86
- else {
87
- lines.push(`- ${trimmed}`);
88
- }
89
118
  }
90
119
  lines.push("");
91
120
  }
92
- /** Human/Agent-readable materialized view. It is always reproducible from the
93
- * canonical state and is never consulted as transaction truth. */
94
- /** v3.3: Forward-looking "roadmap" view derived from existing state — loop
95
- * position, met/remaining criteria (with IDs), and sub-goal activity.
121
+ /** v3.8.1: the one-line phase position — which phase the loop is in, and how
122
+ * far the next boundary is.
96
123
  *
97
- * Deliberately label-free: milestone labels/ranges are collapsed content in
98
- * L1 (the diff-collapse invariant) and are rendered in full by Phase
99
- * History everywhere else — the roadmap only reports "N rounds since the
100
- * last milestone boundary", never the boundary's identity. Next action is
101
- * likewise excluded: every level renders its own Next Action section.
102
- * Shared by the state-file and prompt renderers so they cannot silently
103
- * drift apart (module contract above). Returns [] — renders nothing —
104
- * when the state carries none of these. Presentation only; never feeds
105
- * enforcement. */
106
- export function buildRoadmap(state) {
124
+ * This is all that survives of v3.3's "roadmap": of its four lines, three
125
+ * (met/remaining criteria with ids, sub-goal activity) were already rendered
126
+ * in full by the sections that own them. Returns "" when no milestone exists,
127
+ * so the caller renders no section at all — bare position is already in the
128
+ * prompt header.
129
+ *
130
+ * Shared by the state-file and prompt renderers so they cannot drift apart
131
+ * (module contract above). Presentation only; never feeds enforcement. */
132
+ export function buildPhaseLine(state) {
107
133
  const lastMilestone = state.milestones.length > 0
108
134
  ? state.milestones[state.milestones.length - 1]
109
135
  : null;
110
- const roundsSinceMilestone = lastMilestone !== null &&
111
- lastMilestone.round_range.end <= state.round
136
+ // No phase boundary yet → no phase to report. Position alone ("round 3/200")
137
+ // is already in the prompt header, so rendering it again would be noise.
138
+ if (!lastMilestone)
139
+ return "";
140
+ const roundsSince = lastMilestone.round_range.end <= state.round
112
141
  ? state.round - lastMilestone.round_range.end
113
142
  : null;
114
- const met = state.criterionStatuses.filter((cs) => cs.status === "met").length;
115
- const total = state.criterionStatuses.length;
116
- const remaining = state.criterionStatuses.filter((cs) => cs.status === "remaining" || cs.status === "unknown");
117
- const inProgress = state.subGoals.filter((sg) => sg.status === "in_progress").length;
118
- const pending = state.subGoals.filter((sg) => sg.status === "pending").length;
119
- // Bare position alone is not forward information — no milestone distance,
120
- // criteria, remaining criteria, or sub-goal activity means nothing to
121
- // look ahead to. Returns [] so empty states render no section at all.
122
- if (roundsSinceMilestone === null && total === 0 &&
123
- state.remainingCriteria.length === 0 && inProgress === 0 && pending === 0) {
124
- return [];
125
- }
126
- const lines = [];
127
- let position = `Position: round ${state.round}/${state.maxRounds}`;
128
- if (roundsSinceMilestone !== null) {
129
- position += ` · ${roundsSinceMilestone} round${roundsSinceMilestone === 1 ? "" : "s"} since the last milestone boundary`;
130
- }
131
- lines.push(`- ${position}`);
132
- if (total > 0) {
133
- lines.push(`- Criteria: ${met}/${total} met · ${remaining.length} remaining`);
134
- }
135
- else if (state.remainingCriteria.length > 0) {
136
- lines.push(`- Criteria: ${state.remainingCriteria.length} remaining`);
137
- }
138
- if (remaining.length > 0) {
139
- const idEnabled = getPolicy().evolution.constraint_id_enabled;
140
- for (const cs of remaining.slice(0, 8)) {
141
- const idTag = idEnabled ? ` [\`${cs.id}\`]` : "";
142
- const icon = cs.status === "remaining" ? "⬜" : "❔";
143
- lines.push(` - ${icon}${idTag} ${cs.text}`);
144
- }
145
- if (remaining.length > 8) {
146
- lines.push(` - ... and ${remaining.length - 8} more`);
147
- }
148
- }
149
- if (inProgress > 0 || pending > 0) {
150
- lines.push(`- Sub-goals: ${inProgress} in progress · ${pending} pending`);
143
+ const parts = [
144
+ `phase "${lastMilestone.label}"`,
145
+ `round ${state.round}/${state.maxRounds}`,
146
+ ];
147
+ if (roundsSince !== null) {
148
+ parts.push(`${roundsSince} round${roundsSince === 1 ? "" : "s"} since the last boundary`);
151
149
  }
152
- return lines;
150
+ return parts.join(" · ");
153
151
  }
154
152
  /** v3.3/v3.4: Render the ACTIVE Round Contract as the Current Task section
155
153
  * body — the single formatting source shared by prompts and the state
156
154
  * file (module contract above). The original objective is NOT here: it
157
155
  * lives in the Objective section. Empty arrays render no line. */
158
- export function formatRoundContract(contract) {
156
+ export function formatRoundContract(contract,
157
+ /** v3.8: the derived item statuses. When given, each item renders with its
158
+ * machine status — the agent sees the verification debt in its own task. */
159
+ statuses) {
159
160
  const lines = [];
160
161
  const heading = contract.work_item?.trim();
161
- lines.push(`**${heading || "Round Contract"}**`);
162
- for (const item of contract.done_when) {
163
- lines.push(`- Done when: ${item}`);
164
- }
165
- for (const item of contract.verification_plan) {
166
- lines.push(`- Verify via: ${item}`);
162
+ lines.push(`**${heading || "Round Contract"}** (${contract.id})`);
163
+ const statusById = new Map((statuses?.items ?? []).map((item) => [item.itemId, item]));
164
+ for (const item of contract.items) {
165
+ const machine = statusById.get(item.id);
166
+ const mark = machine?.status === "verified" ? "✅"
167
+ : machine?.status === "insufficient" ? "🟠"
168
+ : machine?.status === "contradicted" ? "⛔"
169
+ : machine?.status === "pending" ? "⬜"
170
+ : "";
171
+ lines.push(`- ${mark ? `${mark} ` : ""}[\`${item.id}\`] ${item.description}${machine ? ` — ${machine.status}` : ""}`);
172
+ if (item.verify_with.length > 0) {
173
+ lines.push(` - Verify via: ${item.verify_with.join(", ")}`);
174
+ }
175
+ if (item.criterion_refs.length > 0) {
176
+ lines.push(` - Criteria: ${item.criterion_refs.join(", ")}`);
177
+ }
178
+ if (machine?.reasons.length) {
179
+ lines.push(` - Unverified because: ${machine.reasons[0]}`);
180
+ }
167
181
  }
168
- for (const item of contract.scope) {
169
- lines.push(`- Scope: ${item}`);
182
+ for (const entry of contract.scope) {
183
+ lines.push(`- Scope: ${entry}`);
170
184
  }
171
185
  return lines.join("\n");
172
186
  }
173
187
  // ═══════════════════════════════════════════════════════════════════════════
174
188
  // v3.3.1: Shared presentation atoms
175
189
  // ═══════════════════════════════════════════════════════════════════════════
176
- /** The trust bar line ("██████░░░░ 60%"). The L2 Agent Trust section and the
177
- * detailed L1 renderer used to carry private copies of this formula — shared
178
- * here so a formatting change is made once (module contract: renderers must
179
- * not silently drift apart). */
180
- export function trustBarLine(score) {
181
- const barLen = 10;
182
- const filled = Math.round(score * barLen);
183
- const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
184
- return `${bar} ${(score * 100).toFixed(0)}%`;
185
- }
186
190
  /** Milestone heading line ("**🏁 Round 7** (Rounds 3–7, 60%)"). Shared by the
187
191
  * L2 Phase History and the detailed L1 renderer; the L1 copy previously
188
192
  * rendered the range as "R3–R7", a format only this heading used. */
@@ -192,22 +196,90 @@ export function milestoneHeading(milestone) {
192
196
  : milestone.kind === "criteria_milestone" ? "✅" : "📍";
193
197
  return `**${kindIcon} ${milestone.label}** (Rounds ${milestone.round_range.start}–${milestone.round_range.end}, ${(milestone.progress_at_boundary * 100).toFixed(0)}%)`;
194
198
  }
195
- export function renderCanonicalStateMarkdown(state) {
196
- const lines = [
199
+ const STATE_SECTION_TIER = {
200
+ // Current — what the next round must hold in working memory.
201
+ "Loop Objective": "Current",
202
+ "Current Task": "Current",
203
+ "Success Criteria": "Current",
204
+ "Hard Constraints": "Current",
205
+ "Active Constraints": "Current",
206
+ Remaining: "Current",
207
+ Blockers: "Current",
208
+ "Sub-Goal Dashboard": "Current",
209
+ "External Context": "Current",
210
+ // v3.8.1: the one-line phase position (the only fact the deleted Roadmap
211
+ // section carried that no other section renders). It is orientation for the
212
+ // NEXT round, so it belongs with the working-memory tier — and it must be
213
+ // listed here at all: an unlisted title is not a heading, so `## Phase` was
214
+ // absorbed into whatever section happened to precede it (its body even kept
215
+ // a raw `## ` line) and inherited that section's tier.
216
+ Phase: "Current",
217
+ // Recent — this round's boundary facts (and, in a recovery window, the
218
+ // derived Recovery Brief injected at the top of this tier).
219
+ "Progress Dashboard": "Recent",
220
+ Verification: "Recent",
221
+ "Changes Since Last Round": "Recent",
222
+ Discoveries: "Recent",
223
+ // Historical Summary — the loop's memory skeleton, kept under existing
224
+ // milestone/window caps.
225
+ "Goal → Criteria": "Historical Summary",
226
+ "Cross-Round Outcomes": "Historical Summary",
227
+ "Recurring Flags": "Historical Summary",
228
+ "Failed Patterns": "Historical Summary",
229
+ "Retired Constraints": "Historical Summary",
230
+ "Phase History": "Historical Summary",
231
+ };
232
+ const STATE_TIER_ORDER = ["Current", "Recent", "Historical Summary"];
233
+ /** Human/Agent-readable materialized view. It is always reproducible from the
234
+ * canonical state and is never consulted as transaction truth.
235
+ *
236
+ * v3.8.1: this is where the diagnostics live — the progress dashboard, the
237
+ * criterion list, round stats, the FULL recurring-flag history and the phase
238
+ * history. The prompt carries only what the next round needs. */
239
+ export function renderCanonicalStateMarkdown(state, options) {
240
+ const preamble = [
197
241
  `# LoopForge State — ${state.loopId}`,
198
242
  "",
199
243
  `**Schema**: ${state.schemaVersion}`,
200
244
  `**Round**: ${state.round}/${state.maxRounds}`,
201
245
  `**Goal ID**: ${state.goalId}`,
246
+ "**Derived**: true",
247
+ `**Source**: round ${state.round} (attempt ${options?.attempt ?? 1})`,
248
+ `**State hash**: ${hashCanonicalState(state).slice(0, 12)}`,
202
249
  "",
203
- "## Loop Objective",
204
- "",
205
- state.objective,
206
- "",
207
- "## Current Task",
208
- "",
209
- state.currentTask,
210
250
  ];
251
+ const sections = flatStateSections(state);
252
+ if (options?.recoveryBrief && options.recoveryBrief.length > 0) {
253
+ sections.unshift({ title: "Recovery Brief", body: options.recoveryBrief });
254
+ }
255
+ const byTier = new Map();
256
+ for (const tier of STATE_TIER_ORDER)
257
+ byTier.set(tier, []);
258
+ for (const section of sections) {
259
+ const tier = STATE_SECTION_TIER[section.title] ?? "Recent";
260
+ byTier.get(tier).push(section);
261
+ }
262
+ const out = [...preamble];
263
+ for (const tier of STATE_TIER_ORDER) {
264
+ const tierSections = byTier.get(tier);
265
+ if (tierSections.length === 0)
266
+ continue;
267
+ out.push(`## ${tier}`, "");
268
+ for (const section of tierSections) {
269
+ out.push(`### ${section.title}`, "");
270
+ for (const line of section.body)
271
+ out.push(line);
272
+ out.push("");
273
+ }
274
+ }
275
+ return out.join("\n").trimEnd() + "\n";
276
+ }
277
+ /** Render the flat section list (title + body) that the tier wrapper
278
+ * groups. Each builder keeps its original heading text; only the heading
279
+ * level is demoted by the wrapper. */
280
+ function flatStateSections(state) {
281
+ const lines = [];
282
+ lines.push("## Loop Objective", "", state.objective, "", "## Current Task", "", state.currentTask);
211
283
  // v2.11: Success Criteria and Constraints render with stable IDs for exact agent matching
212
284
  addListWithIds(lines, "Success Criteria", state.successCriteria, "cr");
213
285
  addListWithIds(lines, "Hard Constraints", state.hardConstraints, "c");
@@ -239,7 +311,7 @@ export function renderCanonicalStateMarkdown(state) {
239
311
  lines.push(`**Machine (git)**: ${motion}`);
240
312
  }
241
313
  if (state.criterionStatuses.length > 0) {
242
- const metCount = state.criterionStatuses.filter((cs) => cs.status === "met").length;
314
+ const metCount = state.criterionStatuses.filter((cs) => cs.status === "verified" || cs.status === "claimed").length;
243
315
  lines.push(`**Machine (criteria)**: ${metCount}/${state.criterionStatuses.length} met across committed rounds`);
244
316
  }
245
317
  if (state.progress.tests) {
@@ -253,14 +325,16 @@ export function renderCanonicalStateMarkdown(state) {
253
325
  }
254
326
  lines.push("");
255
327
  }
256
- // v3.2: Goal → criteria → evidence vertical view. IDs follow
257
- // constraint_id_enabled like every other ID-rendering path.
328
+ // v3.2: Goal → criteria → evidence vertical view. IDs are always rendered.
258
329
  if (state.criterionStatuses.length > 0) {
259
- const idEnabled = getPolicy().evolution.constraint_id_enabled;
260
330
  lines.push("## Goal → Criteria", "");
261
331
  for (const cs of state.criterionStatuses) {
262
- const icon = cs.status === "met" ? "✅" : cs.status === "remaining" ? "⬜" : "❔";
263
- const idTag = idEnabled ? ` [\`${cs.id}\`]` : "";
332
+ const icon = cs.status === "verified" ? "✅"
333
+ : cs.status === "claimed" ? "🟡"
334
+ : cs.status === "insufficient" ? "🟠"
335
+ : cs.status === "contradicted" ? "⛔"
336
+ : cs.status === "remaining" ? "⬜" : "❔";
337
+ const idTag = ` [\`${cs.id}\`]`;
264
338
  const met = cs.met_at_round !== undefined ? ` (met R${cs.met_at_round})` : "";
265
339
  const related = cs.related_subgoal_ids.length > 0
266
340
  ? ` (related: ${cs.related_subgoal_ids.join(", ")})`
@@ -271,34 +345,35 @@ export function renderCanonicalStateMarkdown(state) {
271
345
  }
272
346
  addListWithIds(lines, "Remaining", state.remainingCriteria, "cr");
273
347
  addList(lines, "Blockers", state.blockers);
348
+ const recurring = recurringOnly(state.recurringFlags);
274
349
  addList(lines, "Discoveries", state.discoveries);
275
350
  addList(lines, "Cross-Round Outcomes", state.rollingOutcomes);
276
- addList(lines, "Recurring Issues", state.recurringIssues);
277
351
  addList(lines, "Failed Patterns", state.failedPatterns);
278
- // v3.2: Lessons learned — repeated violations / verification failures.
279
- if (state.lessons.length > 0) {
280
- lines.push("## Lessons Learned", "");
281
- for (const lesson of state.lessons.slice(0, 8)) {
282
- const icon = lesson.kind === "constraint_violation"
352
+ // v3.8.1: ONE recurring-facts section. "Recurring Issues" (the last 5
353
+ // rounds' raw violations, no threshold) and "Lessons Learned" (the whole
354
+ // history, count >= 2) were two windows over one fact set rendered as two
355
+ // headings. The state file keeps the genuinely recurring set; the prompt
356
+ // shows the recent tail of the same derivation.
357
+ if (recurring.length > 0) {
358
+ lines.push("## Recurring Flags", "");
359
+ for (const flag of recurring.slice(0, RECURRING_RENDER_CAP)) {
360
+ const icon = flag.kind === "constraint_violation"
283
361
  ? "🚫"
284
- : lesson.kind === "verification_error" ? "⚠️" : "ℹ️";
285
- lines.push(`- ${icon} ${lesson.text} — ${lesson.count}× (R${lesson.rounds.join(", R")})`);
362
+ : flag.kind === "verification_error" ? "⚠️" : "ℹ️";
363
+ const subject = flag.ref ? `${flag.subject} [${flag.ref}]` : flag.subject;
364
+ lines.push(`- ${icon} ${subject} — ${flag.count}× (R${flag.rounds.join(", R")})`);
286
365
  }
287
- if (state.lessons.length > 8) {
288
- lines.push(`- ... and ${state.lessons.length - 8} more`);
366
+ if (recurring.length > RECURRING_RENDER_CAP) {
367
+ lines.push(`- ... and ${recurring.length - RECURRING_RENDER_CAP} more`);
289
368
  }
290
369
  lines.push("");
291
370
  }
292
371
  addList(lines, "Retired Constraints", state.retiredConstraints);
293
- if (state.nextAction) {
294
- lines.push("## Next Action", "", state.nextAction, "");
295
- }
296
- // v3.3: Forward-looking roadmap — same buildRoadmap the prompts render.
297
- const roadmap = buildRoadmap(state);
298
- if (roadmap.length > 0) {
299
- lines.push("## Roadmap", "");
300
- for (const line of roadmap)
301
- lines.push(line);
372
+ // v3.8.1: the Roadmap section is gone. Only its non-duplicated fact — the
373
+ // phase position — survives, as the one-line phase line the prompts render.
374
+ const phaseLine = buildPhaseLine(state);
375
+ if (phaseLine) {
376
+ lines.push("## Phase", "", `- ${phaseLine}`);
302
377
  lines.push("");
303
378
  }
304
379
  if (state.verificationFlags.length > 0) {
@@ -331,50 +406,81 @@ export function renderCanonicalStateMarkdown(state) {
331
406
  }
332
407
  }
333
408
  if (state.subGoals.length > 0) {
409
+ // v3.7.1: only ACTIVE items render as rows; done/canceled survive in
410
+ // the vault, replay, and the counts line below.
411
+ const cap = getPolicy().evolution.max_active_subgoals;
412
+ const view = activeSubGoalView(state.subGoals, cap);
413
+ // L8: the heading is emitted whenever sub-goals exist — with every
414
+ // sub-goal terminal (0 active rows) the counts line must still live
415
+ // under its own section instead of leaking into the previous one.
334
416
  lines.push("## Sub-Goal Dashboard", "");
335
- for (const sg of state.subGoals) {
336
- const statusIcon = sg.status === "in_progress" ? "🔄" :
337
- sg.status === "done" ? "✅" :
338
- sg.status === "blocked" ? "🚫" :
339
- sg.status === "canceled" ? "❌" : "⏳";
340
- const stale = sg.status === "pending" &&
341
- state.round - sg.declared_at_round >= 10 ? " ⚠️ stale" : "";
342
- const detail = sg.status === "done" && sg.completed_at_round
343
- ? ` (done, R${sg.completed_at_round})`
344
- : sg.status === "in_progress"
417
+ if (view.active.length > 0) {
418
+ for (const sg of view.active) {
419
+ const statusIcon = sg.status === "in_progress" ? "🔄" :
420
+ sg.status === "blocked" ? "🚫" : "⏳";
421
+ const stale = sg.status === "pending" &&
422
+ state.round - sg.declared_at_round >= 10 ? " ⚠️ stale" : "";
423
+ const detail = sg.status === "in_progress"
345
424
  ? ` (since R${sg.status_changed_at_round})`
346
425
  : sg.status === "pending"
347
426
  ? ` (since R${sg.declared_at_round})`
348
- : sg.status === "blocked"
349
- ? ` (blocked R${sg.status_changed_at_round})`
350
- : ` (canceled R${sg.status_changed_at_round})`;
351
- lines.push(`- ${statusIcon} [\`${sg.id}\`] ${sg.description}${detail}${stale}`);
427
+ : ` (blocked R${sg.status_changed_at_round})`;
428
+ lines.push(`- ${statusIcon} [\`${sg.id}\`] ${sg.description}${detail}${stale}`);
429
+ }
430
+ lines.push("");
352
431
  }
353
- lines.push("");
354
- }
355
- if (state.agentTrustScore !== undefined) {
356
- const barLen = 10;
357
- const filled = Math.round(state.agentTrustScore * barLen);
358
- const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
359
- const avg = state.agentTrustTrend.length > 0
360
- ? (state.agentTrustTrend.reduce((a, b) => a + b, 0) / state.agentTrustTrend.length).toFixed(2)
361
- : "—";
362
- lines.push("## Agent Trust", "", `${bar} ${(state.agentTrustScore * 100).toFixed(0)}%`, `Trend (last ${state.agentTrustTrend.length}): ${state.agentTrustTrend.join(" → ")}`, `Average: ${avg}`, "");
432
+ lines.push(`> ${view.total} total · ${view.activeTotal} active · ` +
433
+ `${view.done} done · ${view.canceled} canceled`, "");
363
434
  }
364
435
  if (state.externalContext) {
365
436
  lines.push("## External Context", "", state.externalContext, "");
366
437
  }
367
- return lines.join("\n").trimEnd() + "\n";
438
+ // Split the flat markdown into titled sections for tier grouping. Every
439
+ // builder above emits `## <Title>` headings over their content — but only
440
+ // the KNOWN section titles are headings. Free-text bodies (objective,
441
+ // external context, milestone outcomes) may contain their own markdown
442
+ // lines starting with "## "; splitting on any such line would cut the
443
+ // body into a phantom section and re-tier everything that follows.
444
+ const knownSectionTitles = new Set([
445
+ ...Object.keys(STATE_SECTION_TIER),
446
+ "Recovery Brief",
447
+ ]);
448
+ const sections = [];
449
+ let current = null;
450
+ let inBody = false;
451
+ for (const line of lines) {
452
+ const heading = /^## (.+)$/.exec(line);
453
+ if (heading && knownSectionTitles.has(heading[1])) {
454
+ current = { title: heading[1], body: [] };
455
+ sections.push(current);
456
+ inBody = false;
457
+ continue;
458
+ }
459
+ if (!current)
460
+ continue;
461
+ if (!inBody) {
462
+ if (line.trim() === "")
463
+ continue; // drop leading blanks after the heading
464
+ inBody = true;
465
+ }
466
+ current.body.push(line);
467
+ }
468
+ for (const section of sections) {
469
+ while (section.body.length > 0 && section.body[section.body.length - 1] === "") {
470
+ section.body.pop();
471
+ }
472
+ }
473
+ return sections;
368
474
  }
369
- export function createCanonicalLoopState(request, response, stateFilePath,
370
- /** v3.3: Display-only derived data (round stats, machine git-motion).
371
- * Optional 4th param — callers that predate v3.3 stay on 3-arg calls. */
475
+ export function createCanonicalLoopState(request, response, stateFilePath,
476
+ /** v3.3: Display-only derived data. Optional 4th param — callers that
477
+ * predate v3.3 stay on 3-arg calls. */
372
478
  derived) {
373
479
  const last = request.last_round_result;
374
480
  const objective = response.loop_objective;
375
481
  const verificationFlags = request.verification_flags ?? [];
376
482
  const rolling = response.rolling_summary;
377
- const executionEvidence = last?.execution_evidence;
483
+ const executionReport = last?.execution_report;
378
484
  const changes = unique([
379
485
  request.new_since_last_round,
380
486
  last?.output_summary,
@@ -395,54 +501,56 @@ derived) {
395
501
  // to do this round"; the original objective stays in the Objective
396
502
  // section above. No active contract → the original task text is used,
397
503
  // byte-identical to pre-v3.3 behavior.
398
- const roundContract = derived?.roundContract ?? null;
504
+ const roundContract = derived?.roundFacts?.activeContract ?? null;
399
505
  return {
400
506
  schemaVersion: CANONICAL_STATE_SCHEMA_VERSION,
401
507
  loopId: response.loop_id || request.loop_id,
402
508
  round: response.round || request.round,
403
- maxRounds: request.max_rounds ?? 20,
509
+ maxRounds: request.max_rounds ?? getPolicy().engine.max_rounds,
404
510
  goalId: response.goal_id,
405
511
  objective: objective?.objective || request.task,
406
512
  objectiveVersion: objective?.version ?? 1,
407
- currentTask: roundContract ? formatRoundContract(roundContract) : request.task,
513
+ currentTask: roundContract
514
+ ? formatRoundContract(roundContract, derived?.roundFacts?.itemStatuses ?? null)
515
+ : request.task,
408
516
  successCriteria: unique(objective?.success_criteria ?? []),
409
517
  hardConstraints: unique(objective?.hard_constraints ?? []),
410
518
  activeConstraints: unique(response.constraints_active),
411
519
  retiredConstraints: unique(response.constraints_retired),
412
520
  constraintMetadata: response.constraint_metadata ?? [],
413
521
  changesSinceLastRound: changes,
414
- remainingCriteria: unique(last?.execution_evidence?.success_criteria_remaining ?? []),
522
+ remainingCriteria: unique(claimedRemainingCriteria(last?.execution_report)),
415
523
  blockers,
416
524
  verificationFlags,
417
525
  discoveries,
418
- nextAction: last?.next_action?.trim() || response.suggested_next_task,
419
526
  rollingOutcomes: unique(rolling?.key_outcomes ?? []),
420
- recurringIssues: unique(rolling?.recurring_issues ?? []),
421
527
  failedPatterns: unique(rolling?.failed_patterns ?? []),
422
528
  milestones: rolling?.milestones ?? [],
423
529
  subGoals: response.sub_goals ?? [],
424
530
  criterionStatuses: response.criterion_statuses ?? [],
425
- lessons: response.lessons ?? [],
426
- agentTrustScore: response.agent_trust_score,
427
- agentTrustTrend: response.agent_trust_trend ?? [],
428
- suggestedNextTask: response.suggested_next_task,
531
+ recurringFlags: response.recurring_flags ?? [],
429
532
  externalContext: request.external_context?.trim() ?? "",
430
533
  stateFilePath,
431
534
  progress: {
432
- estimate: executionEvidence?.progress_estimate ?? null,
433
- criteriaMet: unique(executionEvidence?.success_criteria_met ?? []),
434
- criteriaRemaining: unique(executionEvidence?.success_criteria_remaining ?? []),
435
- filesChanged: unique(executionEvidence?.files_changed ?? []),
436
- tests: executionEvidence?.test_results ?? null,
535
+ estimate: executionReport?.progress_estimate ?? null,
536
+ criteriaMet: unique(claimedMetCriteria(executionReport)),
537
+ criteriaRemaining: unique(claimedRemainingCriteria(executionReport)),
538
+ filesChanged: unique(executionReport?.files_changed ?? []),
539
+ tests: executionReport?.tests_reported ?? null,
437
540
  },
438
541
  // v3.3: Conditional presence — empty/absent derived data must not add
439
542
  // keys, or every state hash would change for rounds without it.
440
- ...(derived?.roundStats && derived.roundStats.length > 0
441
- ? { roundStats: derived.roundStats }
442
- : {}),
443
543
  ...(derived?.machineStatus ? { machineStatus: derived.machineStatus } : {}),
444
544
  // v3.3: Conditional presence — no contract, no key (hash stability).
445
545
  ...(roundContract ? { roundContract } : {}),
546
+ ...(derived?.roundFacts?.itemStatuses
547
+ ? { contractItemStatuses: derived.roundFacts.itemStatuses }
548
+ : {}),
549
+ ...(derived?.roundFacts?.verifiedSubGoals &&
550
+ derived.roundFacts.verifiedSubGoals.length > 0
551
+ ? { verifiedSubGoals: [...derived.roundFacts.verifiedSubGoals] }
552
+ : {}),
553
+ capability: deriveConfiguredCapability(getPolicy()),
446
554
  };
447
555
  }
448
556
  //# sourceMappingURL=canonical-state.js.map