loopforge 3.9.2 → 3.9.4

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 (104) hide show
  1. package/dist/canonical-state.d.ts +39 -1
  2. package/dist/canonical-state.d.ts.map +1 -1
  3. package/dist/canonical-state.js +114 -32
  4. package/dist/canonical-state.js.map +1 -1
  5. package/dist/committed-round.d.ts +27 -4
  6. package/dist/committed-round.d.ts.map +1 -1
  7. package/dist/committed-round.js +87 -14
  8. package/dist/committed-round.js.map +1 -1
  9. package/dist/enforcement-gate.d.ts +9 -32
  10. package/dist/enforcement-gate.d.ts.map +1 -1
  11. package/dist/enforcement-gate.js +9 -122
  12. package/dist/enforcement-gate.js.map +1 -1
  13. package/dist/engine.d.ts.map +1 -1
  14. package/dist/engine.js +13 -2
  15. package/dist/engine.js.map +1 -1
  16. package/dist/explain.d.ts +1 -0
  17. package/dist/explain.d.ts.map +1 -1
  18. package/dist/explain.js +1 -0
  19. package/dist/explain.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/loop-compiler.d.ts +17 -1
  24. package/dist/loop-compiler.d.ts.map +1 -1
  25. package/dist/loop-compiler.js +217 -104
  26. package/dist/loop-compiler.js.map +1 -1
  27. package/dist/mcp/round-lifecycle.d.ts +31 -1
  28. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  29. package/dist/mcp/round-lifecycle.js +102 -60
  30. package/dist/mcp/round-lifecycle.js.map +1 -1
  31. package/dist/mcp/server.d.ts +5 -0
  32. package/dist/mcp/server.d.ts.map +1 -1
  33. package/dist/mcp/server.js +19 -1
  34. package/dist/mcp/server.js.map +1 -1
  35. package/dist/mcp/session.d.ts.map +1 -1
  36. package/dist/mcp/session.js +23 -5
  37. package/dist/mcp/session.js.map +1 -1
  38. package/dist/mcp/submission-boundary.js +14 -4
  39. package/dist/mcp/submission-boundary.js.map +1 -1
  40. package/dist/mcp/tools.d.ts.map +1 -1
  41. package/dist/mcp/tools.js +62 -6
  42. package/dist/mcp/tools.js.map +1 -1
  43. package/dist/policy.d.ts +5 -2
  44. package/dist/policy.d.ts.map +1 -1
  45. package/dist/policy.js +2 -2
  46. package/dist/policy.js.map +1 -1
  47. package/dist/prompt-assembler.d.ts +8 -3
  48. package/dist/prompt-assembler.d.ts.map +1 -1
  49. package/dist/prompt-assembler.js +233 -240
  50. package/dist/prompt-assembler.js.map +1 -1
  51. package/dist/prompt-coverage.d.ts +45 -0
  52. package/dist/prompt-coverage.d.ts.map +1 -0
  53. package/dist/prompt-coverage.js +84 -0
  54. package/dist/prompt-coverage.js.map +1 -0
  55. package/dist/prompt-freshness.d.ts +36 -0
  56. package/dist/prompt-freshness.d.ts.map +1 -0
  57. package/dist/prompt-freshness.js +83 -0
  58. package/dist/prompt-freshness.js.map +1 -0
  59. package/dist/prompt-policy.d.ts +5 -2
  60. package/dist/prompt-policy.d.ts.map +1 -1
  61. package/dist/prompt-policy.js +10 -7
  62. package/dist/prompt-policy.js.map +1 -1
  63. package/dist/protocol.d.ts +45 -8
  64. package/dist/protocol.d.ts.map +1 -1
  65. package/dist/protocol.js +4 -1
  66. package/dist/protocol.js.map +1 -1
  67. package/dist/round-coordinator.d.ts +4 -2
  68. package/dist/round-coordinator.d.ts.map +1 -1
  69. package/dist/round-coordinator.js +7 -7
  70. package/dist/round-coordinator.js.map +1 -1
  71. package/dist/round-facts.d.ts.map +1 -1
  72. package/dist/round-facts.js +9 -3
  73. package/dist/round-facts.js.map +1 -1
  74. package/dist/round-transaction.d.ts.map +1 -1
  75. package/dist/round-transaction.js +19 -1
  76. package/dist/round-transaction.js.map +1 -1
  77. package/dist/self-eval.d.ts +8 -4
  78. package/dist/self-eval.d.ts.map +1 -1
  79. package/dist/self-eval.js +55 -5
  80. package/dist/self-eval.js.map +1 -1
  81. package/dist/submission-guidance.d.ts +25 -11
  82. package/dist/submission-guidance.d.ts.map +1 -1
  83. package/dist/submission-guidance.js +102 -44
  84. package/dist/submission-guidance.js.map +1 -1
  85. package/dist/submission-receipt.d.ts.map +1 -1
  86. package/dist/submission-receipt.js +1 -0
  87. package/dist/submission-receipt.js.map +1 -1
  88. package/dist/token-utils.d.ts +19 -0
  89. package/dist/token-utils.d.ts.map +1 -1
  90. package/dist/token-utils.js +41 -6
  91. package/dist/token-utils.js.map +1 -1
  92. package/dist/verification-actions.d.ts +13 -0
  93. package/dist/verification-actions.d.ts.map +1 -0
  94. package/dist/verification-actions.js +45 -0
  95. package/dist/verification-actions.js.map +1 -0
  96. package/dist/verification-gate.d.ts +5 -1
  97. package/dist/verification-gate.d.ts.map +1 -1
  98. package/dist/verification-gate.js +19 -7
  99. package/dist/verification-gate.js.map +1 -1
  100. package/dist/version.d.ts +1 -1
  101. package/dist/version.js +1 -1
  102. package/loop_policy.json +2 -2
  103. package/package.json +68 -68
  104. package/skills/perception/SKILL.md +25 -6
@@ -10,10 +10,12 @@
10
10
  * alone are over it — truncation of mandatory content never happens.
11
11
  */
12
12
  import { createHash } from "node:crypto";
13
- import { activeSubGoalView, buildPhaseLine, hashCanonicalState, milestoneHeading, } from "./canonical-state.js";
13
+ import { buildPhaseLine, formatActiveSubGoals, formatCriterionRows, hashCanonicalState, milestoneHeading, partitionActiveConstraints, } from "./canonical-state.js";
14
14
  import { PROMPT_ARTIFACT_SCHEMA_VERSION } from "./protocol.js";
15
- import { deriveItemId, STABLE_ID_RE, normalizeText } from "./token-utils.js";
15
+ import { deriveCriterionId, deriveItemId, findingKey, STABLE_ID_RE, normalizeText, } from "./token-utils.js";
16
16
  import { getPolicy } from "./policy.js";
17
+ import { verificationActionFor } from "./verification-actions.js";
18
+ import { assertPromptCoverage, derivePromptCoverage, } from "./prompt-coverage.js";
17
19
  export const DEFAULT_PROMPT_BUDGETS = {
18
20
  l0: 5000,
19
21
  l1: 10000,
@@ -25,88 +27,19 @@ function section(title, body) {
25
27
  function bullets(values, prefix = "- ") {
26
28
  return values.map((value) => `${prefix}${value}`).join("\n");
27
29
  }
28
- /** v2.11: Derive a stable item ID from text content.
29
- * Same hash strategy as computeGoalTextHash in loop-compiler.ts. */
30
- /** v2.11: Render a bullet list with ID prefixes for constraints/criteria.
31
- * Each item gets a [prefix-XXXXXXXX] tag. Items that already look like IDs
32
- * are rendered as-is. v3.8.1: IDs are always rendered — the
33
- * `constraint_id_enabled` escape hatch is gone. */
34
- function bulletsWithIds(values, prefix) {
35
- return values.map((v) => {
36
- const trimmed = v.trim();
37
- if (STABLE_ID_RE.test(trimmed)) {
38
- return `- [\`${trimmed}\`] ${trimmed}`;
39
- }
40
- const id = prefix + "-" + deriveItemId(trimmed);
41
- return `- [\`${id}\`] ${trimmed}`;
42
- }).join("\n");
43
- }
44
- /** v3.3.1: Render an identity-HETEROGENEOUS list — the merged active
45
- * constraint set holds hard/plan/criteria/discovered texts together, so no
46
- * single prefix fits. Each item's authoritative ID comes from constraint
47
- * metadata (criteria texts carry cr-XXXXXXXX, everything else
48
- * c-XXXXXXXX), keeping the active list in the same identity namespace as
49
- * the item's own section — one text never renders under two prefixes in
50
- * one prompt. Falls back to a c- derivation only when metadata is missing
51
- * (defensive: metadata covers every active text by construction). */
52
- function bulletsWithIdentities(values, metadata) {
53
- const idByText = new Map(metadata.map((meta) => [meta.text, meta.id]));
54
- return values.map((v) => {
55
- const trimmed = v.trim();
56
- if (STABLE_ID_RE.test(trimmed)) {
57
- return `- [\`${trimmed}\`] ${trimmed}`;
58
- }
59
- const id = idByText.get(trimmed) ?? `c-${deriveItemId(trimmed)}`;
60
- return `- [\`${id}\`] ${trimmed}`;
61
- }).join("\n");
62
- }
63
- /** v3.2: Short imperative fix/action per verification check, appended to the
64
- * L1/L2 Verification Gate lines. Deliberately shorter than the rejection-
65
- * prompt wording in enforcement-gate.ts; keys are the plain check-name
66
- * strings (NOT imported from verification-gate.ts — it already imports
67
- * loop-compiler, which imports this module, so importing back would cycle).
68
- * Full coverage of every CHECK_* constant is enforced by a test that
69
- * iterates the verification-gate module's CHECK_ exports (no hardcoded
70
- * list — a new constant can never silently lack an action entry). */
71
- const VERIFICATION_ACTIONS = {
72
- success_with_remaining_criteria: "Complete the remaining criteria before claiming success, or set success=false and report what remains.",
73
- success_without_verified_evidence: "Back the success claim with machine-verifiable evidence: run tests or a required verification command and report the actual output. If you genuinely performed no work, provide execution_report and declare no_change_reason.",
74
- outcome_success_contradiction: "Reconcile the declared outcome with the success flag and resubmit an honest evaluation.",
75
- blocked_without_blocker: "State the concrete blocker, or drop the blocked outcome.",
76
- retroactive_claim_bad_round: "Correct the referenced round number, or withdraw the retroactive claim.",
77
- retroactive_claim_unverified: "Provide evidence for the retroactive claim, or withdraw it.",
78
- duplicate_constraint_discovery: "Update the existing constraint entry instead of duplicating it.",
79
- recurring_violation: "Explain why this violation keeps recurring and try a different approach — do not repeat the failed strategy.",
80
- retract_fresh_constraint: "Justify the retraction explicitly, or restore the constraint.",
81
- evidence_integrity: "Correct the mismatch between your claims and the recorded evidence.",
82
- required_command_failed: "Re-run the required verification command and report its actual output before claiming success.",
83
- command_evidence_mismatch: "Report the true command output and reconcile the mismatch.",
84
- backtrack_workspace_not_restored: "Restore the skipped-round files and the expected git HEAD, then resubmit.",
85
- criteria_claims_unverified: "Provide evidence for each claimed success criterion.",
86
- // v3.3 — Round Contract checks (and the three v3.3 checks that predated
87
- // this table entry and fell back to the generic action).
88
- verification_entrypoint_modified: "Re-run the verification command on the changed entrypoint, or revert the entrypoint change and re-run.",
89
- test_files_modified: "Re-run the verification command after the test-file changes and report the fresh output.",
90
- round_scope_drift: "Revert the out-of-scope file changes, or close the active contract and declare the extended scope in a new proposal. Scope drift is a machine fact and is not waivable by explanation.",
91
- // v3.8 — item model: claimed-but-unverified debt
92
- contract_items_unverified: "Run the bound command(s) for each claimed item so they are observed passing, or report the item as remaining (or declare outcome=\"blocked\").",
93
- contract_premature: "The ACTIVE contract is still open — restate it unchanged to continue it; a different contract is ignored until every item is verified or the round reports blocked.",
94
- // v3.7.1 — opt-in gate layer: cited gates need an approved human decision
95
- user_gate_unresolved: "The round cites a high-risk action without an approved human decision. Run loopforge_gate_check, present the approval question, call loopforge_gate_resolve after the human decides, and resubmit with the approved gate id in evaluation.gate_ids.",
96
- };
97
- /** Fallback for unmapped check names (forward-compat with future checks). */
98
- const VERIFICATION_ACTION_FALLBACK = "Re-examine the flagged claim and correct your evaluation before resubmitting.";
99
- /** v3.2: The actionable instruction for a verification check. Exported for
100
- * the coverage test (which imports the CHECK_* constants). */
101
- export function verificationActionFor(check) {
102
- return VERIFICATION_ACTIONS[check] ?? VERIFICATION_ACTION_FALLBACK;
103
- }
30
+ /** 3.9.3: The action table lives in a neutral module so the evaluation-field
31
+ * guidance can quote the same wording without depending on a renderer.
32
+ * Re-exported here for the existing CHECK_* coverage test. */
33
+ export { verificationActionFor } from "./verification-actions.js";
104
34
  function verificationText(state, level) {
105
35
  const lines = state.verificationFlags.map((flag) => {
106
36
  const icon = flag.severity === "error"
107
37
  ? "🚫"
108
38
  : flag.severity === "warn" ? "⚠️" : "ℹ️";
109
- const base = `- ${icon} [${flag.check}] ${flag.detail}`;
39
+ const ref = flag.ref ? ` [ref: \`${flag.ref}\`]` : "";
40
+ // v3.9.4: the finding names the affected field AND the concrete subject;
41
+ // the repair surface is a fact, not a next-action instruction.
42
+ const base = `- ${icon} [${flag.check}] \`${flag.field}\` — ${flag.detail}${ref}`;
110
43
  // v3.2: error/warn flags get an actionable continuation line in L1/L2 —
111
44
  // the verdict is translated into what to DO. L0 keeps the compact retry
112
45
  // prompt byte-identical; info flags stay informational.
@@ -120,43 +53,132 @@ function verificationText(state, level) {
120
53
  }
121
54
  return lines.join("\n");
122
55
  }
123
- function activeNonHardConstraints(state) {
124
- const hard = new Set(state.hardConstraints);
125
- return state.activeConstraints.filter((value) => !hard.has(value));
56
+ /** v3.9.4: the ONE identity of a text in the prompt's normative namespaces.
57
+ * A criterion keeps its cr-* even when it also appears in an active list;
58
+ * metadata supplies the id for everything else. */
59
+ function identityForText(state, text) {
60
+ // Criteria identity wins even if a stale/hand-built metadata row disagrees.
61
+ const status = state.criterionStatuses.find((criterion) => normalizeText(criterion.text) === normalizeText(text));
62
+ if (status)
63
+ return status.id;
64
+ if (state.successCriteria.some((criterion) => normalizeText(criterion) === normalizeText(text))) {
65
+ return deriveCriterionId(text);
66
+ }
67
+ const meta = state.constraintMetadata.find((entry) => entry.text === text);
68
+ if (meta)
69
+ return meta.id;
70
+ return `c-${deriveItemId(text)}`;
126
71
  }
127
- // ═══════════════════════════════════════════════════════════════════════════
128
- // v3.2: L1 diff-collapse helpers
129
- // ═══════════════════════════════════════════════════════════════════════════
130
- /** v3.2: Stable ID of a constraint text as rendered by bulletsWithIds —
131
- * metadata's own id when present, else the derived hash. */
132
- function constraintIdOf(text, metadata) {
133
- const meta = metadata.find((m) => m.text === text);
134
- return meta?.id ?? `c-${deriveItemId(text)}`;
72
+ /** v3.9.4: active constraint rows with their real ids and the machine fact
73
+ * that this round violated them. Shared by the hard and other partitions. */
74
+ function constraintLines(state, texts) {
75
+ return texts.map((text) => {
76
+ const violated = state.constraintMetadata.find((meta) => meta.text === text)
77
+ ?.last_violated_at_round === state.round;
78
+ return `- [\`${identityForText(state, text)}\`] ${text}` +
79
+ (violated ? " (violated this round)" : "");
80
+ }).join("\n");
81
+ }
82
+ /** v3.9.4: every active normative object the current prompt MUST render. */
83
+ function activeNormativeIds(state, recoveryBrief) {
84
+ const out = [];
85
+ const partition = partitionActiveConstraints(state);
86
+ for (const text of partition.hard) {
87
+ out.push({ id: identityForText(state, text), kind: "constraint" });
88
+ }
89
+ for (const text of partition.other) {
90
+ out.push({ id: identityForText(state, text), kind: "constraint" });
91
+ }
92
+ for (const text of partition.criteria) {
93
+ out.push({ id: identityForText(state, text), kind: "criterion" });
94
+ }
95
+ if (state.roundContract) {
96
+ out.push({ id: state.roundContract.id, kind: "contract" });
97
+ for (const item of state.roundContract.items) {
98
+ out.push({ id: item.id, kind: "contract_item" });
99
+ }
100
+ }
101
+ for (const subGoal of state.subGoals) {
102
+ if (subGoal.status !== "done" && subGoal.status !== "canceled") {
103
+ out.push({ id: subGoal.id, kind: "subgoal" });
104
+ }
105
+ }
106
+ for (const flag of state.verificationFlags) {
107
+ out.push({ id: findingKey(flag), kind: "finding" });
108
+ }
109
+ if (recoveryBrief && recoveryBrief.length > 0) {
110
+ out.push({ id: "recovery", kind: "recovery" });
111
+ }
112
+ return out;
135
113
  }
136
- function commonMandatorySections(state,
137
- /** v2.11: L0 skips ID rendering to stay within tight budget. */
138
- level) {
114
+ /** v3.9.4: the normative sections shared by L0/L1/L2. Levels change ONLY the
115
+ * density of narrative sections; they never change which active normative
116
+ * ids are rendered. Every section here is mandatory (protected). */
117
+ function commonMandatorySections(state, level, recoveryBrief) {
118
+ const partition = partitionActiveConstraints(state);
139
119
  const sections = [
140
120
  {
141
121
  id: "objective",
142
122
  text: section("Objective", state.objective),
143
123
  mandatory: true,
124
+ coverageIds: [],
144
125
  },
145
126
  {
146
127
  id: "current_task",
147
128
  text: section("Current Task", state.currentTask),
148
129
  mandatory: true,
130
+ // Declare what this text actually carries. `createCanonicalLoopState`
131
+ // builds currentTask with `formatRoundContract`, so this is the
132
+ // structured source, not a text guess; an inconsistent hand-built state
133
+ // therefore fails closed instead of claiming an id it did not render.
134
+ coverageIds: state.roundContract
135
+ ? [
136
+ state.roundContract.id,
137
+ ...state.roundContract.items.map((item) => item.id),
138
+ ].filter((id) => state.currentTask.includes(id))
139
+ : [],
149
140
  },
150
141
  ];
151
- if (state.hardConstraints.length > 0) {
152
- // v2.11: L0 uses plain bullets (tight budget); L1/L2 show constraint IDs
153
- const body = level === "l0"
154
- ? bullets(state.hardConstraints)
155
- : bulletsWithIds(state.hardConstraints, "c");
142
+ const criteria = formatCriterionRows(state.criterionStatuses, state.successCriteria);
143
+ if (criteria.lines.length > 0) {
144
+ sections.push({
145
+ id: "criteria",
146
+ text: section("Success Criteria", criteria.lines.join("\n")),
147
+ mandatory: true,
148
+ coverageIds: criteria.ids,
149
+ });
150
+ }
151
+ if (partition.hard.length > 0) {
156
152
  sections.push({
157
153
  id: "hard_constraints",
158
- text: section("Active Hard Constraints", body),
154
+ text: section("Active Hard Constraints", constraintLines(state, partition.hard)),
155
+ mandatory: true,
156
+ coverageIds: partition.hard.map((text) => identityForText(state, text)),
157
+ });
158
+ }
159
+ if (partition.other.length > 0) {
160
+ sections.push({
161
+ id: "active_constraints",
162
+ text: section("Active Constraints", constraintLines(state, partition.other)),
159
163
  mandatory: true,
164
+ coverageIds: partition.other.map((text) => identityForText(state, text)),
165
+ });
166
+ }
167
+ if (state.subGoals.length > 0) {
168
+ const subGoals = formatActiveSubGoals(state.subGoals, state.round);
169
+ sections.push({
170
+ id: "sub_goals",
171
+ text: section("Active Sub-Goals", subGoals.lines.join("\n")),
172
+ mandatory: true,
173
+ coverageIds: subGoals.ids,
174
+ });
175
+ }
176
+ if (recoveryBrief && recoveryBrief.length > 0) {
177
+ sections.push({
178
+ id: "recovery",
179
+ text: section("Recovery Brief", recoveryBrief.join("\n")),
180
+ mandatory: true,
181
+ coverageIds: ["recovery"],
160
182
  });
161
183
  }
162
184
  if (state.verificationFlags.length > 0) {
@@ -164,21 +186,25 @@ level) {
164
186
  id: "verification",
165
187
  text: section("Verification Gate", verificationText(state, level)),
166
188
  mandatory: true,
189
+ coverageIds: state.verificationFlags.map((flag) => findingKey(flag)),
167
190
  });
168
191
  }
169
192
  return sections;
170
193
  }
171
- function l0Sections(state) {
194
+ function l0Sections(state, recoveryBrief) {
172
195
  const retryRequirements = [
173
196
  ...state.blockers,
174
197
  ...state.verificationFlags.map((flag) => flag.detail),
175
198
  ];
176
- const sections = commonMandatorySections(state, "l0");
199
+ const sections = commonMandatorySections(state, "l0", recoveryBrief);
177
200
  if (retryRequirements.length > 0) {
178
201
  sections.push({
179
202
  id: "retry_requirements",
180
203
  text: section("Retry Requirements", bullets(retryRequirements)),
181
204
  mandatory: true,
205
+ // The same finding keys as the Verification Gate: either repair surface
206
+ // proves the finding was delivered, and both derive from the one list.
207
+ coverageIds: state.verificationFlags.map((flag) => findingKey(flag)),
182
208
  });
183
209
  }
184
210
  if (state.changesSinceLastRound.length > 0) {
@@ -186,6 +212,7 @@ function l0Sections(state) {
186
212
  id: "changes",
187
213
  text: section("New Evidence / Changes", bullets(state.changesSinceLastRound)),
188
214
  mandatory: false,
215
+ coverageIds: [],
189
216
  });
190
217
  }
191
218
  return sections;
@@ -219,43 +246,14 @@ function activeWarnings(flags, currentRound) {
219
246
  return `- ${icon} ${subject} — ${flag.count}×, latest R${Math.max(...flag.rounds)}`;
220
247
  }).join("\n");
221
248
  }
222
- function l1Sections(state) {
223
- const sections = commonMandatorySections(state, "l1");
224
- const active = activeNonHardConstraints(state);
249
+ function l1Sections(state, recoveryBrief) {
250
+ const sections = commonMandatorySections(state, "l1", recoveryBrief);
225
251
  if (state.changesSinceLastRound.length > 0) {
226
252
  sections.push({
227
253
  id: "changes",
228
254
  text: section("Changes Since Last Round", bullets(state.changesSinceLastRound)),
229
255
  mandatory: false,
230
- });
231
- }
232
- // v3.8.1: Active Constraints render IN FULL. v3.2 folded unchanged items
233
- // into "… N unchanged constraints (see state file)" pointer lines, which
234
- // made the prompt's content depend on what the PREVIOUS prompt happened to
235
- // show rather than on committed facts — and those pointer lines fed the
236
- // prompt hash. The fold needed the previous round's persisted presentation
237
- // snapshot, which is deleted.
238
- if (active.length > 0) {
239
- // The one thing the folded branch carried that a plain bullet list does
240
- // not: the machine fact that this round broke the constraint. Kept so the
241
- // annotation is not lost along with the fold.
242
- const lines = active.map((text) => {
243
- const violated = state.constraintMetadata.find((m) => m.text === text)
244
- ?.last_violated_at_round === state.round;
245
- return `- [\`${constraintIdOf(text, state.constraintMetadata)}\`] ${text}` +
246
- (violated ? " (violated this round)" : "");
247
- });
248
- sections.push({
249
- id: "active_constraints",
250
- text: section("Active Constraints / Success Criteria", lines.join("\n")),
251
- mandatory: false,
252
- });
253
- }
254
- if (state.remainingCriteria.length > 0) {
255
- sections.push({
256
- id: "remaining",
257
- text: section("Remaining", bulletsWithIds(state.remainingCriteria, "cr")),
258
- mandatory: false,
256
+ coverageIds: [],
259
257
  });
260
258
  }
261
259
  if (state.blockers.length > 0) {
@@ -263,24 +261,7 @@ function l1Sections(state) {
263
261
  id: "blockers",
264
262
  text: section("Blockers", bullets(state.blockers)),
265
263
  mandatory: false,
266
- });
267
- }
268
- // v2.2: Sub-Goal Dashboard (compact — L1). v3.7.1: only ACTIVE items
269
- // (pending/in_progress/blocked) enter the view; done/canceled never render
270
- // as rows. v3.8.1: rendered in full — the unchanged-count fold is gone.
271
- const view = activeSubGoalView(state.subGoals, Number.POSITIVE_INFINITY);
272
- const activeSubs = view.active;
273
- if (activeSubs.length > 0) {
274
- const iconOf = (sg) => sg.status === "in_progress" ? "🔄" : sg.status === "blocked" ? "🚫" : "⏳";
275
- const maxShow = 5;
276
- const items = activeSubs.slice(0, maxShow).map((sg) => `${iconOf(sg)} [\`${sg.id}\`] ${sg.description}`);
277
- if (activeSubs.length > maxShow) {
278
- items.push(`... and ${activeSubs.length - maxShow} more`);
279
- }
280
- sections.push({
281
- id: "sub_goals",
282
- text: section("Active Sub-Goals", items.join("\n")),
283
- mandatory: false,
264
+ coverageIds: [],
284
265
  });
285
266
  }
286
267
  if (state.discoveries.length > 0) {
@@ -288,6 +269,7 @@ function l1Sections(state) {
288
269
  id: "discoveries",
289
270
  text: section("New Discoveries", bullets(state.discoveries)),
290
271
  mandatory: false,
272
+ coverageIds: [],
291
273
  });
292
274
  }
293
275
  // v3.2: Recent Rounds. v3.8.1: rendered in full — the keep-newest-N fold and
@@ -297,6 +279,7 @@ function l1Sections(state) {
297
279
  id: "rolling_outcomes",
298
280
  text: section("Recent Rounds", bullets(state.rollingOutcomes)),
299
281
  mandatory: false,
282
+ coverageIds: [],
300
283
  });
301
284
  }
302
285
  // v3.2: L1 also renders the explicit external context (previously L2-only).
@@ -306,6 +289,7 @@ function l1Sections(state) {
306
289
  id: "external_context",
307
290
  text: section("External Context", state.externalContext),
308
291
  mandatory: false,
292
+ coverageIds: [],
309
293
  });
310
294
  }
311
295
  // v3.8.1: one compact phase line, not the Roadmap prose.
@@ -315,6 +299,7 @@ function l1Sections(state) {
315
299
  id: "phase",
316
300
  text: section("Phase", l1Phase),
317
301
  mandatory: false,
302
+ coverageIds: [],
318
303
  });
319
304
  }
320
305
  // v3.8.1: Active Warnings — the recent tail of the ONE recurring-fact
@@ -329,46 +314,42 @@ function l1Sections(state) {
329
314
  id: "active_warnings",
330
315
  text: section("Active Warnings", warnings),
331
316
  mandatory: false,
317
+ coverageIds: [],
332
318
  });
333
319
  }
334
320
  return sections;
335
321
  }
336
- function l2Sections(state, fullStateMarkdown) {
337
- const sections = commonMandatorySections(state, "l2");
322
+ function l2Sections(state, fullStateMarkdown, recoveryBrief) {
323
+ const sections = commonMandatorySections(state, "l2", recoveryBrief);
338
324
  const fullState = fullStateMarkdown?.trim();
339
325
  if (fullState) {
340
326
  sections.push({
341
327
  id: "full_state",
342
328
  text: section("Full Rehydrated State", fullState),
343
329
  mandatory: false,
330
+ coverageIds: [],
344
331
  });
345
332
  return sections;
346
333
  }
347
- // v2.11: Groups with an idPrefix use ID-aware rendering; others use plain bullets.
334
+ // v2.11: narrative groups. v3.9.4: criteria, active constraints and active
335
+ // sub-goals moved to the shared mandatory sections, so they are not repeated
336
+ // here; these groups carry no normative ids.
348
337
  const groups = [
349
- ["success_criteria", "Success Criteria", state.successCriteria, "cr"],
350
- ["active_constraints", "Active Constraints", activeNonHardConstraints(state), "c"],
351
- ["changes", "Changes Since Last Round", state.changesSinceLastRound, null],
352
- ["remaining", "Remaining", state.remainingCriteria, "cr"],
353
- ["blockers", "Blockers", state.blockers, null],
354
- ["discoveries", "Discoveries", state.discoveries, null],
355
- ["rolling_outcomes", "Cross-Round Outcomes", state.rollingOutcomes, null],
356
- ["failed_patterns", "Failed Patterns", state.failedPatterns, null],
338
+ ["changes", "Changes Since Last Round", state.changesSinceLastRound],
339
+ ["blockers", "Blockers", state.blockers],
340
+ ["discoveries", "Discoveries", state.discoveries],
341
+ ["rolling_outcomes", "Cross-Round Outcomes", state.rollingOutcomes],
342
+ ["failed_patterns", "Failed Patterns", state.failedPatterns],
357
343
  ];
358
- for (const [id, title, values, idPrefix] of groups) {
344
+ for (const [id, title, values] of groups) {
359
345
  if (values.length === 0)
360
346
  continue;
361
- // v3.3.1: the active set is identity-heterogeneous (hard/plan/criteria/
362
- // discovered merged) — render through constraint metadata so criteria
363
- // texts keep their cr- identity here too (same namespace as their own
364
- // Success Criteria section). Every other group is homogeneous and keeps
365
- // its single-prefix renderer.
366
- const body = id === "active_constraints"
367
- ? bulletsWithIdentities(values, state.constraintMetadata)
368
- : idPrefix
369
- ? bulletsWithIds(values, idPrefix)
370
- : bullets(values);
371
- sections.push({ id, text: section(title, body), mandatory: false });
347
+ sections.push({
348
+ id,
349
+ text: section(title, bullets(values)),
350
+ mandatory: false,
351
+ coverageIds: [],
352
+ });
372
353
  }
373
354
  // v3.8.1: Active Warnings — the recent tail of the ONE recurring-fact
374
355
  // derivation (see l1Sections). The whole-history recurring set is the state
@@ -380,6 +361,7 @@ function l2Sections(state, fullStateMarkdown) {
380
361
  id: "active_warnings",
381
362
  text: section("Active Warnings", warnings),
382
363
  mandatory: false,
364
+ coverageIds: [],
383
365
  });
384
366
  }
385
367
  // v3.8.1: one compact phase line, not the Roadmap prose — rendered right
@@ -391,6 +373,7 @@ function l2Sections(state, fullStateMarkdown) {
391
373
  id: "phase",
392
374
  text: section("Phase", l2Phase),
393
375
  mandatory: false,
376
+ coverageIds: [],
394
377
  });
395
378
  }
396
379
  if (state.milestones.length > 0) {
@@ -410,34 +393,7 @@ function l2Sections(state, fullStateMarkdown) {
410
393
  id: "milestones",
411
394
  text: section("Phase History", milestoneText),
412
395
  mandatory: false,
413
- });
414
- }
415
- if (state.subGoals.length > 0) {
416
- // v3.7.1: active-only rows ordered blocked → in_progress → pending
417
- // (priority asc, recently changed first), capped by policy; done and
418
- // canceled appear only in the stats line.
419
- const cap = getPolicy().evolution.max_active_subgoals;
420
- const view = activeSubGoalView(state.subGoals, cap);
421
- const lines = [];
422
- for (const sg of view.active) {
423
- if (sg.status === "in_progress") {
424
- lines.push(`🔄 [\`${sg.id}\`] ${sg.description} (since R${sg.status_changed_at_round})`);
425
- }
426
- else if (sg.status === "blocked") {
427
- lines.push(`🚫 [\`${sg.id}\`] ${sg.description} (blocked since R${sg.status_changed_at_round})`);
428
- }
429
- else {
430
- const age = state.round - sg.declared_at_round;
431
- const stale = age >= 10 ? " ⚠️ stale" : "";
432
- lines.push(`⏳ [\`${sg.id}\`] ${sg.description} (pending ${age} rounds)${stale}`);
433
- }
434
- }
435
- // Stats line (full counts — rows may be capped)
436
- lines.push(`─── ${view.total} total: ${view.activeTotal} active, ${view.done} done, ${view.canceled} canceled`);
437
- sections.push({
438
- id: "sub_goals_full",
439
- text: section("Sub-Goal Dashboard", lines.join("\n")),
440
- mandatory: false,
396
+ coverageIds: [],
441
397
  });
442
398
  }
443
399
  if (state.externalContext) {
@@ -445,33 +401,28 @@ function l2Sections(state, fullStateMarkdown) {
445
401
  id: "external_context",
446
402
  text: section("External Context", state.externalContext),
447
403
  mandatory: false,
404
+ coverageIds: [],
448
405
  });
449
406
  }
450
- // ── v2.8: Path B gap fills (previously only in full state markdown) ──
451
- // v3.8.1: the L2 Progress Dashboard and Round Stats sections are gone.
452
- // The dashboard was `criterionStatuses` re-composed into a table and mixed
453
- // with reported test counts; Round Stats
454
- // re-listed per-round files and progress deltas, both self-reported. The
455
- // criterion list and the machine git/criteria rows still render in the
456
- // state file, where a human or external tool reads them — the prompt keeps
457
- // only the current round’s own facts.
458
- // Retired Constraints
459
407
  if (state.retiredConstraints.length > 0) {
460
408
  const lines = state.retiredConstraints.map((c) => `- ~${c}~ (retired)`);
461
409
  sections.push({
462
410
  id: "retired_constraints",
463
411
  text: section("Retired Constraints", lines.join("\n")),
464
412
  mandatory: false,
413
+ coverageIds: [],
465
414
  });
466
415
  }
467
416
  return sections;
468
417
  }
469
418
  function selectSections(input) {
470
- if (input.level === "l0")
471
- return l0Sections(input.state);
472
- if (input.level === "l1")
473
- return l1Sections(input.state);
474
- return l2Sections(input.state, input.fullStateMarkdown);
419
+ if (input.level === "l0") {
420
+ return l0Sections(input.state, input.recoveryBrief);
421
+ }
422
+ if (input.level === "l1") {
423
+ return l1Sections(input.state, input.recoveryBrief);
424
+ }
425
+ return l2Sections(input.state, input.fullStateMarkdown, input.recoveryBrief);
475
426
  }
476
427
  /** v3.8.1: render order, in ONE place.
477
428
  *
@@ -486,33 +437,32 @@ function selectSections(input) {
486
437
  * single large early section silently pushed out every later one.)
487
438
  */
488
439
  const SECTION_PRIORITY = {
489
- // ── protected ───────────────────────────────────────────────────────────
440
+ // ── protected (every active normative id lives here) ────────────────────
490
441
  objective: 0,
491
442
  current_task: 1,
492
- hard_constraints: 2,
493
- verification: 3,
494
- retry_requirements: 4,
443
+ criteria: 2,
444
+ hard_constraints: 3,
445
+ active_constraints: 4,
446
+ sub_goals: 5,
447
+ recovery: 6,
448
+ verification: 7,
449
+ retry_requirements: 8,
495
450
  // Zero-token pure reorder, but it is the ONLY place emphasized items render
496
451
  // (they are MOVED out of their source sections), so dropping it would delete
497
452
  // them rather than just deprioritize them.
498
- critical_context: 5,
499
- // ── optional, most to least important ───────────────────────────────────
500
- success_criteria: 10,
501
- active_constraints: 11,
502
- remaining: 12,
503
- sub_goals: 13,
504
- blockers: 14,
505
- changes: 15,
506
- discoveries: 16,
507
- rolling_outcomes: 17,
508
- phase: 18,
509
- active_warnings: 19,
510
- external_context: 20,
511
- failed_patterns: 21,
512
- retired_constraints: 22,
513
- milestones: 23,
514
- sub_goals_full: 24,
515
- full_state: 25,
453
+ critical_context: 9,
454
+ // ── optional narrative, most to least important ─────────────────────────
455
+ blockers: 20,
456
+ changes: 21,
457
+ discoveries: 22,
458
+ rolling_outcomes: 23,
459
+ phase: 24,
460
+ active_warnings: 25,
461
+ external_context: 26,
462
+ failed_patterns: 27,
463
+ retired_constraints: 28,
464
+ milestones: 29,
465
+ full_state: 30,
516
466
  };
517
467
  const DEFAULT_SECTION_PRIORITY = 50;
518
468
  /** v3.8.1: the smallest remaining room worth spending on a truncated
@@ -672,6 +622,16 @@ function renderConfusionAlerts(points, fullDensity) {
672
622
  }
673
623
  /** Render the "Critical Context" section — items the model emphasized.
674
624
  * Pulled from active constraints, discoveries, and blocking issues. */
625
+ /** v3.9.4: an emphasized item that is also an active normative constraint
626
+ * reports its real id here, because it is MOVED out of its source section
627
+ * and this is the only place it renders. Narrative items carry no id. */
628
+ function normativeIdForText(state, text) {
629
+ const key = normalizeText(text);
630
+ const normative = state.activeConstraints.some((item) => normalizeText(item) === key) ||
631
+ state.successCriteria.some((item) => normalizeText(item) === key) ||
632
+ state.hardConstraints.some((item) => normalizeText(item) === key);
633
+ return normative ? identityForText(state, text) : null;
634
+ }
675
635
  function renderCriticalContext(emphasized) {
676
636
  if (emphasized.length === 0)
677
637
  return "";
@@ -682,7 +642,9 @@ function renderCriticalContext(emphasized) {
682
642
  "",
683
643
  ];
684
644
  for (const item of emphasized) {
685
- lines.push(`- ${item}`);
645
+ lines.push(item.id
646
+ ? `- [\`${item.id}\`] ${item.text}`
647
+ : `- ${item.text}`);
686
648
  }
687
649
  lines.push("");
688
650
  return lines.join("\n");
@@ -820,7 +782,13 @@ export function assemblePromptArtifact(input) {
820
782
  }
821
783
  : input.state;
822
784
  const sections = selectSections({ ...input, state: stateForSections });
823
- const criticalContextText = renderCriticalContext(emphasized);
785
+ // The ID attached here comes from the ORIGINAL state: emphasize moved the
786
+ // item out of its source section, so this is the only renderer for it.
787
+ const emphasizedItems = emphasized.map((text) => ({
788
+ text,
789
+ id: normativeIdForText(input.state, text),
790
+ }));
791
+ const criticalContextText = renderCriticalContext(emphasizedItems);
824
792
  // v3.8.1: no presentation snapshot. The artifact records what THIS prompt
825
793
  // rendered (level, hashes, round identity, the sections it emitted) and
826
794
  // nothing about what a LATER prompt should do differently — a prompt's
@@ -836,6 +804,7 @@ export function assemblePromptArtifact(input) {
836
804
  id: "critical_context",
837
805
  text: criticalContextText,
838
806
  mandatory: true,
807
+ coverageIds: emphasizedItems.flatMap((item) => item.id ? [item.id] : []),
839
808
  },
840
809
  ...sections,
841
810
  ];
@@ -844,6 +813,29 @@ export function assemblePromptArtifact(input) {
844
813
  const selected = renderWithinBudget(augmentedSections, fixedText, sectionBudget);
845
814
  const renderedPrompt = selected.rendered + footer;
846
815
  const promptHash = createHash("sha256").update(renderedPrompt).digest("hex");
816
+ // v3.9.4: coverage is derived from the ORIGINAL state and the sections that
817
+ // actually rendered. It is not text-scanned and not a second fact source.
818
+ // An incomplete projection fails closed — no prompt or artifact escapes.
819
+ const active = activeNormativeIds(input.state, input.recoveryBrief);
820
+ const declaredIds = new Set();
821
+ for (const section of augmentedSections) {
822
+ for (const id of section.coverageIds)
823
+ declaredIds.add(id);
824
+ }
825
+ const renderedIds = new Set();
826
+ for (const sectionId of selected.included) {
827
+ const section = augmentedSections.find((entry) => entry.id === sectionId);
828
+ if (!section)
829
+ continue;
830
+ for (const id of section.coverageIds)
831
+ renderedIds.add(id);
832
+ }
833
+ const coverage = derivePromptCoverage({
834
+ active,
835
+ rendered: renderedIds,
836
+ declared: declaredIds,
837
+ });
838
+ assertPromptCoverage(coverage);
847
839
  return {
848
840
  schemaVersion: PROMPT_ARTIFACT_SCHEMA_VERSION,
849
841
  roundId: `loop:${input.state.loopId}:round:${input.state.round}`,
@@ -862,6 +854,7 @@ export function assemblePromptArtifact(input) {
862
854
  budget: sectionBudget,
863
855
  renderedChars: selected.rendered.length,
864
856
  promptRequestEffects,
857
+ coverage,
865
858
  };
866
859
  }
867
860
  //# sourceMappingURL=prompt-assembler.js.map