loopforge 2.7.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +103 -166
  2. package/dist/canonical-state.d.ts.map +1 -1
  3. package/dist/canonical-state.js +44 -6
  4. package/dist/canonical-state.js.map +1 -1
  5. package/dist/enforcement-gate.d.ts +22 -1
  6. package/dist/enforcement-gate.d.ts.map +1 -1
  7. package/dist/enforcement-gate.js +281 -38
  8. package/dist/enforcement-gate.js.map +1 -1
  9. package/dist/engine.d.ts.map +1 -1
  10. package/dist/engine.js +21 -3
  11. package/dist/engine.js.map +1 -1
  12. package/dist/evidence-provider.d.ts +3 -5
  13. package/dist/evidence-provider.d.ts.map +1 -1
  14. package/dist/evidence-provider.js +24 -10
  15. package/dist/evidence-provider.js.map +1 -1
  16. package/dist/index.d.ts +2 -11
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -11
  19. package/dist/index.js.map +1 -1
  20. package/dist/loop-compiler.d.ts +2 -2
  21. package/dist/loop-compiler.d.ts.map +1 -1
  22. package/dist/loop-compiler.js +135 -42
  23. package/dist/loop-compiler.js.map +1 -1
  24. package/dist/loop-store.d.ts.map +1 -1
  25. package/dist/loop-store.js +3 -5
  26. package/dist/loop-store.js.map +1 -1
  27. package/dist/mcp/server.d.ts.map +1 -1
  28. package/dist/mcp/server.js +42 -8
  29. package/dist/mcp/server.js.map +1 -1
  30. package/dist/mcp/session.d.ts +27 -2
  31. package/dist/mcp/session.d.ts.map +1 -1
  32. package/dist/mcp/session.js +145 -1
  33. package/dist/mcp/session.js.map +1 -1
  34. package/dist/mcp/tools.d.ts.map +1 -1
  35. package/dist/mcp/tools.js +26 -4
  36. package/dist/mcp/tools.js.map +1 -1
  37. package/dist/policy-metrics.d.ts +8 -0
  38. package/dist/policy-metrics.d.ts.map +1 -1
  39. package/dist/policy-metrics.js +11 -0
  40. package/dist/policy-metrics.js.map +1 -1
  41. package/dist/policy.d.ts +46 -0
  42. package/dist/policy.d.ts.map +1 -1
  43. package/dist/policy.js +12 -2
  44. package/dist/policy.js.map +1 -1
  45. package/dist/prompt-assembler.d.ts +3 -1
  46. package/dist/prompt-assembler.d.ts.map +1 -1
  47. package/dist/prompt-assembler.js +427 -29
  48. package/dist/prompt-assembler.js.map +1 -1
  49. package/dist/protocol.d.ts +54 -8
  50. package/dist/protocol.d.ts.map +1 -1
  51. package/dist/protocol.js +7 -1
  52. package/dist/protocol.js.map +1 -1
  53. package/dist/round-coordinator.d.ts +28 -4
  54. package/dist/round-coordinator.d.ts.map +1 -1
  55. package/dist/round-coordinator.js +91 -7
  56. package/dist/round-coordinator.js.map +1 -1
  57. package/dist/round-driver.d.ts +4 -0
  58. package/dist/round-driver.d.ts.map +1 -1
  59. package/dist/round-driver.js +2 -0
  60. package/dist/round-driver.js.map +1 -1
  61. package/dist/round-transaction.d.ts +4 -0
  62. package/dist/round-transaction.d.ts.map +1 -1
  63. package/dist/round-transaction.js +4 -5
  64. package/dist/round-transaction.js.map +1 -1
  65. package/dist/self-eval.d.ts.map +1 -1
  66. package/dist/self-eval.js +30 -0
  67. package/dist/self-eval.js.map +1 -1
  68. package/dist/token-utils.d.ts +3 -0
  69. package/dist/token-utils.d.ts.map +1 -1
  70. package/dist/token-utils.js +6 -0
  71. package/dist/token-utils.js.map +1 -1
  72. package/dist/verification-gate.d.ts +6 -2
  73. package/dist/verification-gate.d.ts.map +1 -1
  74. package/dist/verification-gate.js +41 -5
  75. package/dist/verification-gate.js.map +1 -1
  76. package/package.json +2 -2
  77. package/skills/perception/SKILL.md +105 -30
  78. package/dist/interop.d.ts +0 -35
  79. package/dist/interop.d.ts.map +0 -1
  80. package/dist/interop.js +0 -34
  81. package/dist/interop.js.map +0 -1
  82. package/dist/run.d.ts +0 -51
  83. package/dist/run.d.ts.map +0 -1
  84. package/dist/run.js +0 -200
  85. package/dist/run.js.map +0 -1
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
8
  import { hashCanonicalState } from "./canonical-state.js";
9
+ import { jaccardSimilarity } from "./token-utils.js";
10
+ import { getPolicy } from "./policy.js";
9
11
  export const PROMPT_ARTIFACT_SCHEMA_VERSION = 1;
10
12
  export const BASE_PROMPT_VERSION = "2.0.0";
11
13
  export const DEFAULT_PROMPT_BUDGETS = {
@@ -19,6 +21,29 @@ function section(title, body) {
19
21
  function bullets(values, prefix = "- ") {
20
22
  return values.map((value) => `${prefix}${value}`).join("\n");
21
23
  }
24
+ /** v2.11: Derive a stable item ID from text content.
25
+ * Same hash strategy as computeGoalTextHash in loop-compiler.ts. */
26
+ function deriveItemId(text) {
27
+ const normalized = text.trim().replace(/\s+/g, " ").toLowerCase();
28
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 8);
29
+ }
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. When constraint_id_enabled is false, renders plain bullets. */
33
+ function bulletsWithIds(values, prefix) {
34
+ const idEnabled = getPolicy().evolution.constraint_id_enabled;
35
+ if (!idEnabled)
36
+ return bullets(values);
37
+ const idRegex = /^(c|cr|sg)-[a-f0-9]{8}$/;
38
+ return values.map((v) => {
39
+ const trimmed = v.trim();
40
+ if (idRegex.test(trimmed)) {
41
+ return `- [\`${trimmed}\`] ${trimmed}`;
42
+ }
43
+ const id = prefix + "-" + deriveItemId(trimmed);
44
+ return `- [\`${id}\`] ${trimmed}`;
45
+ }).join("\n");
46
+ }
22
47
  function verificationText(state) {
23
48
  const lines = state.verificationFlags.map((flag) => {
24
49
  const icon = flag.severity === "error"
@@ -35,7 +60,9 @@ function activeNonHardConstraints(state) {
35
60
  const hard = new Set(state.hardConstraints);
36
61
  return state.activeConstraints.filter((value) => !hard.has(value));
37
62
  }
38
- function commonMandatorySections(state) {
63
+ function commonMandatorySections(state,
64
+ /** v2.11: L0 skips ID rendering to stay within tight budget. */
65
+ level) {
39
66
  const sections = [
40
67
  {
41
68
  id: "objective",
@@ -49,9 +76,13 @@ function commonMandatorySections(state) {
49
76
  },
50
77
  ];
51
78
  if (state.hardConstraints.length > 0) {
79
+ // v2.11: L0 uses plain bullets (tight budget); L1/L2 show constraint IDs
80
+ const body = level === "l0"
81
+ ? bullets(state.hardConstraints)
82
+ : bulletsWithIds(state.hardConstraints, "c");
52
83
  sections.push({
53
84
  id: "hard_constraints",
54
- text: section("Active Hard Constraints", bullets(state.hardConstraints)),
85
+ text: section("Active Hard Constraints", body),
55
86
  mandatory: true,
56
87
  });
57
88
  }
@@ -69,7 +100,7 @@ function l0Sections(state) {
69
100
  ...state.blockers,
70
101
  ...state.verificationFlags.map((flag) => flag.detail),
71
102
  ];
72
- const sections = commonMandatorySections(state);
103
+ const sections = commonMandatorySections(state, "l0");
73
104
  if (retryRequirements.length > 0) {
74
105
  sections.push({
75
106
  id: "retry_requirements",
@@ -87,7 +118,7 @@ function l0Sections(state) {
87
118
  return sections;
88
119
  }
89
120
  function l1Sections(state) {
90
- const sections = commonMandatorySections(state);
121
+ const sections = commonMandatorySections(state, "l1");
91
122
  const active = activeNonHardConstraints(state);
92
123
  if (state.changesSinceLastRound.length > 0) {
93
124
  sections.push({
@@ -99,14 +130,14 @@ function l1Sections(state) {
99
130
  if (active.length > 0) {
100
131
  sections.push({
101
132
  id: "active_constraints",
102
- text: section("Active Constraints / Success Criteria", bullets(active)),
133
+ text: section("Active Constraints / Success Criteria", bulletsWithIds(active, "c")),
103
134
  mandatory: false,
104
135
  });
105
136
  }
106
137
  if (state.remainingCriteria.length > 0) {
107
138
  sections.push({
108
139
  id: "remaining",
109
- text: section("Remaining", bullets(state.remainingCriteria)),
140
+ text: section("Remaining", bulletsWithIds(state.remainingCriteria, "cr")),
110
141
  mandatory: false,
111
142
  });
112
143
  }
@@ -123,7 +154,7 @@ function l1Sections(state) {
123
154
  const maxShow = 5;
124
155
  const items = activeSubs.slice(0, maxShow).map((sg) => {
125
156
  const icon = sg.status === "in_progress" ? "🔄" : "⏳";
126
- return `${icon} ${sg.description}`;
157
+ return `${icon} [\`${sg.id}\`] ${sg.description}`;
127
158
  });
128
159
  if (activeSubs.length > maxShow) {
129
160
  items.push(`... and ${activeSubs.length - maxShow} more`);
@@ -165,7 +196,7 @@ function l1Sections(state) {
165
196
  return sections;
166
197
  }
167
198
  function l2Sections(state, fullStateMarkdown) {
168
- const sections = commonMandatorySections(state);
199
+ const sections = commonMandatorySections(state, "l2");
169
200
  const fullState = fullStateMarkdown?.trim();
170
201
  if (fullState) {
171
202
  sections.push({
@@ -175,20 +206,24 @@ function l2Sections(state, fullStateMarkdown) {
175
206
  });
176
207
  return sections;
177
208
  }
209
+ // v2.11: Groups with an idPrefix use ID-aware rendering; others use plain bullets.
178
210
  const groups = [
179
- ["success_criteria", "Success Criteria", state.successCriteria],
180
- ["active_constraints", "Active Constraints", activeNonHardConstraints(state)],
181
- ["changes", "Changes Since Last Round", state.changesSinceLastRound],
182
- ["remaining", "Remaining", state.remainingCriteria],
183
- ["blockers", "Blockers", state.blockers],
184
- ["discoveries", "Discoveries", state.discoveries],
185
- ["rolling_outcomes", "Cross-Round Outcomes", state.rollingOutcomes],
186
- ["recurring_issues", "Recurring Issues", state.recurringIssues],
187
- ["failed_patterns", "Failed Patterns", state.failedPatterns],
211
+ ["success_criteria", "Success Criteria", state.successCriteria, "cr"],
212
+ ["active_constraints", "Active Constraints", activeNonHardConstraints(state), "c"],
213
+ ["changes", "Changes Since Last Round", state.changesSinceLastRound, null],
214
+ ["remaining", "Remaining", state.remainingCriteria, "cr"],
215
+ ["blockers", "Blockers", state.blockers, null],
216
+ ["discoveries", "Discoveries", state.discoveries, null],
217
+ ["rolling_outcomes", "Cross-Round Outcomes", state.rollingOutcomes, null],
218
+ ["recurring_issues", "Recurring Issues", state.recurringIssues, null],
219
+ ["failed_patterns", "Failed Patterns", state.failedPatterns, null],
188
220
  ];
189
- for (const [id, title, values] of groups) {
221
+ for (const [id, title, values, idPrefix] of groups) {
190
222
  if (values.length > 0) {
191
- sections.push({ id, text: section(title, bullets(values)), mandatory: false });
223
+ const body = idPrefix
224
+ ? bulletsWithIds(values, idPrefix)
225
+ : bullets(values);
226
+ sections.push({ id, text: section(title, body), mandatory: false });
192
227
  }
193
228
  }
194
229
  if (state.milestones.length > 0) {
@@ -218,7 +253,7 @@ function l2Sections(state, fullStateMarkdown) {
218
253
  // In progress
219
254
  const inProgress = state.subGoals.filter((sg) => sg.status === "in_progress");
220
255
  for (const sg of inProgress) {
221
- lines.push(`🔄 ${sg.description} (since R${sg.status_changed_at_round})`);
256
+ lines.push(`🔄 [\`${sg.id}\`] ${sg.description} (since R${sg.status_changed_at_round})`);
222
257
  }
223
258
  // Pending (by age)
224
259
  const pending = state.subGoals.filter((sg) => sg.status === "pending");
@@ -226,7 +261,7 @@ function l2Sections(state, fullStateMarkdown) {
226
261
  for (const sg of shownPending) {
227
262
  const age = state.round - sg.declared_at_round;
228
263
  const stale = age >= 10 ? " ⚠️ stale" : "";
229
- lines.push(`⏳ ${sg.description} (pending ${age} rounds)${stale}`);
264
+ lines.push(`⏳ [\`${sg.id}\`] ${sg.description} (pending ${age} rounds)${stale}`);
230
265
  }
231
266
  if (pending.length > 10) {
232
267
  lines.push(`... and ${pending.length - 10} more pending`);
@@ -234,13 +269,13 @@ function l2Sections(state, fullStateMarkdown) {
234
269
  // Blocked
235
270
  const blocked = state.subGoals.filter((sg) => sg.status === "blocked");
236
271
  for (const sg of blocked) {
237
- lines.push(`🚫 ${sg.description} (blocked since R${sg.status_changed_at_round})`);
272
+ lines.push(`🚫 [\`${sg.id}\`] ${sg.description} (blocked since R${sg.status_changed_at_round})`);
238
273
  }
239
274
  // Recently done
240
275
  const done = state.subGoals.filter((sg) => sg.status === "done");
241
276
  const recentDone = done.slice(0, 5);
242
277
  for (const sg of recentDone) {
243
- lines.push(`✅ ${sg.description} (done R${sg.completed_at_round ?? sg.status_changed_at_round})`);
278
+ lines.push(`✅ [\`${sg.id}\`] ${sg.description} (done R${sg.completed_at_round ?? sg.status_changed_at_round})`);
244
279
  }
245
280
  if (done.length > 5) {
246
281
  lines.push(`... and ${done.length - 5} more completed`);
@@ -278,6 +313,90 @@ function l2Sections(state, fullStateMarkdown) {
278
313
  mandatory: false,
279
314
  });
280
315
  }
316
+ // ── v2.8: Path B gap fills (previously only in full state markdown) ──
317
+ // Progress Dashboard
318
+ if (state.progress.estimate !== null ||
319
+ state.progress.criteriaMet.length > 0 ||
320
+ state.progress.criteriaRemaining.length > 0 ||
321
+ state.progress.filesChanged.length > 0 ||
322
+ state.progress.tests !== null) {
323
+ const lines = [];
324
+ const total = state.progress.criteriaMet.length + state.progress.criteriaRemaining.length;
325
+ if (total > 0) {
326
+ lines.push(`**Criteria**: ${state.progress.criteriaMet.length}/${total} met`);
327
+ }
328
+ if (state.progress.estimate !== null) {
329
+ lines.push(`**Estimated Completion**: ${(state.progress.estimate * 100).toFixed(0)}%`);
330
+ }
331
+ if (state.progress.tests) {
332
+ lines.push(`**Tests**: ${state.progress.tests.passed} passed, ` +
333
+ `${state.progress.tests.failed} failed, ${state.progress.tests.skipped} skipped`);
334
+ }
335
+ if (state.progress.filesChanged.length > 0) {
336
+ lines.push("**Files Changed**:");
337
+ for (const f of state.progress.filesChanged.slice(0, 10))
338
+ lines.push(`- ${f}`);
339
+ if (state.progress.filesChanged.length > 10) {
340
+ lines.push(`- ... and ${state.progress.filesChanged.length - 10} more`);
341
+ }
342
+ }
343
+ sections.push({
344
+ id: "progress",
345
+ text: section("Progress Dashboard", lines.join("\n")),
346
+ mandatory: false,
347
+ });
348
+ }
349
+ // Retired Constraints
350
+ if (state.retiredConstraints.length > 0) {
351
+ const lines = state.retiredConstraints.map((c) => `- ~${c}~ (retired)`);
352
+ sections.push({
353
+ id: "retired_constraints",
354
+ text: section("Retired Constraints", lines.join("\n")),
355
+ mandatory: false,
356
+ });
357
+ }
358
+ // Inactive Constraints (with decay metadata)
359
+ if (state.inactiveConstraints.length > 0) {
360
+ const lines = [
361
+ "> Demoted after prolonged inactivity. Auto-reactivated if violated again.",
362
+ "",
363
+ ];
364
+ for (const c of state.inactiveConstraints) {
365
+ const meta = state.constraintMetadata.find((m) => m.text === c);
366
+ const age = meta
367
+ ? state.round - (meta.last_violated_at_round || meta.discovered_at_round || 0)
368
+ : "?";
369
+ const lastV = meta?.last_violated_at_round
370
+ ? `last violated R${meta.last_violated_at_round}`
371
+ : "never violated";
372
+ lines.push(`- ${c} (${lastV}, ${age} rounds inactive)`);
373
+ }
374
+ sections.push({
375
+ id: "inactive_constraints",
376
+ text: section("Inactive Constraints", lines.join("\n")),
377
+ mandatory: false,
378
+ });
379
+ }
380
+ // Agent Trust Score + Trend
381
+ if (state.agentTrustScore !== undefined) {
382
+ const barLen = 10;
383
+ const filled = Math.round(state.agentTrustScore * barLen);
384
+ const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
385
+ const lines = [
386
+ `${bar} ${(state.agentTrustScore * 100).toFixed(0)}%`,
387
+ ];
388
+ if (state.agentTrustTrend.length > 0) {
389
+ const avg = (state.agentTrustTrend.reduce((a, b) => a + b, 0)
390
+ / state.agentTrustTrend.length).toFixed(2);
391
+ lines.push(`Trend (last ${state.agentTrustTrend.length}): ${state.agentTrustTrend.join(" → ")}`);
392
+ lines.push(`Average: ${avg}`);
393
+ }
394
+ sections.push({
395
+ id: "agent_trust",
396
+ text: section("Agent Trust", lines.join("\n")),
397
+ mandatory: false,
398
+ });
399
+ }
281
400
  return sections;
282
401
  }
283
402
  function selectSections(input) {
@@ -305,20 +424,257 @@ function renderWithinBudget(sections, fixedText, budget) {
305
424
  }
306
425
  return { rendered, included };
307
426
  }
427
+ // ═══════════════════════════════════════════════════════════════════════════
428
+ // v2.9: Prompt Requests — model-expressed information needs
429
+ // ═══════════════════════════════════════════════════════════════════════════
430
+ /** Match a free-text emphasis target against state items.
431
+ * v2.11: ID-first matching. When a target is an ID (c-/cr-/sg-XXXXXXXX),
432
+ * candidates that derive to the same ID get score 1.0. Falls back to
433
+ * Jaccard similarity otherwise. */
434
+ function matchEmphasize(targets, candidates, cap) {
435
+ if (targets.length === 0 || candidates.length === 0)
436
+ return [];
437
+ const idRegex = /^(c|cr|sg)-[a-f0-9]{8}$/;
438
+ const scored = candidates.map((c) => {
439
+ let best = 0;
440
+ for (const t of targets) {
441
+ let s = 0;
442
+ // Phase 1: exact ID match (v2.11)
443
+ const idMatch = t.match(idRegex);
444
+ if (idMatch) {
445
+ const candidateId = idMatch[1] + "-" + deriveItemId(c);
446
+ if (candidateId === t)
447
+ s = 1.0;
448
+ }
449
+ // Phase 2: Jaccard fallback
450
+ if (s === 0)
451
+ s = jaccardSimilarity(t, c);
452
+ if (s > best)
453
+ best = s;
454
+ }
455
+ return { text: c, score: best };
456
+ });
457
+ return scored
458
+ .filter((e) => e.score >= getPolicy().evolution.constraint_match_threshold)
459
+ .sort((a, b) => b.score - a.score)
460
+ .slice(0, cap)
461
+ .map((e) => e.text);
462
+ }
463
+ /** Render the "Confusion Alerts" section that appears at the prompt top.
464
+ * Compiler auto-matches each confusion point against state sections. */
465
+ function renderConfusionAlerts(points, state, expandAll) {
466
+ if (points.length === 0)
467
+ return "";
468
+ const lines = [
469
+ "## ⚠️ Confusion Alerts",
470
+ "",
471
+ "You flagged these as confusing in the previous round.",
472
+ "Address each before proceeding — the state file has full context.",
473
+ "",
474
+ ];
475
+ const sections = [
476
+ { keyword: "milestone", label: "Phase History", ref: "## Phase History" },
477
+ { keyword: "sub-goal", label: "Sub-Goal Dashboard", ref: "## Sub-Goal Dashboard" },
478
+ { keyword: "constraint", label: "Active Constraints / Constraint Lifecycle", ref: "## Active Constraints" },
479
+ { keyword: "trust", label: "Agent Trust", ref: "## Agent Trust" },
480
+ { keyword: "progress", label: "Progress Dashboard", ref: "## Progress Dashboard" },
481
+ { keyword: "criteria", label: "Success Criteria", ref: "## Success Criteria" },
482
+ { keyword: "phase", label: "Phase History", ref: "## Phase History" },
483
+ { keyword: "objective", label: "Loop Objective", ref: "## Loop Objective" },
484
+ { keyword: "loop", label: "Loop Summary", ref: "## Loop Summary" },
485
+ ];
486
+ const shown = expandAll ? points : points.slice(0, 1);
487
+ for (const point of shown) {
488
+ const truncated = point.length > 200 ? point.slice(0, 197) + "…" : point;
489
+ // Find best-matching state section
490
+ let bestSection = null;
491
+ let bestScore = 0;
492
+ for (const sec of sections) {
493
+ const score = jaccardSimilarity(truncated.toLowerCase(), sec.keyword);
494
+ if (score > bestScore) {
495
+ bestScore = score;
496
+ bestSection = sec;
497
+ }
498
+ }
499
+ const pointer = bestSection && bestScore >= 0.15
500
+ ? `→ Check **${bestSection.label}** (\`${bestSection.ref}\`) in the state file.`
501
+ : "→ Read the full state file for relevant context.";
502
+ lines.push(`- **"${truncated}"**`);
503
+ lines.push(` ${pointer}`);
504
+ lines.push("");
505
+ }
506
+ if (!expandAll && points.length > 1) {
507
+ lines.push(`*… and ${points.length - 1} more confusion point(s) — see state file.*`);
508
+ lines.push("");
509
+ }
510
+ return lines.join("\n");
511
+ }
512
+ /** Render the "Critical Context" section — items the model emphasized.
513
+ * Pulled from active constraints, discoveries, and blocking issues. */
514
+ function renderCriticalContext(emphasized, state) {
515
+ if (emphasized.length === 0)
516
+ return "";
517
+ const lines = [
518
+ "## 🔴 Critical Context",
519
+ "",
520
+ "You flagged these as important for this round. Prioritize them.",
521
+ "",
522
+ ];
523
+ for (const item of emphasized) {
524
+ lines.push(`- ${item}`);
525
+ }
526
+ lines.push("");
527
+ return lines.join("\n");
528
+ }
529
+ /** Expand a specific section to L2-level detail within a L1 prompt.
530
+ * Returns the expanded section text, or empty string if the section
531
+ * has no content to expand. */
532
+ function renderExpandedSection(sectionName, state) {
533
+ switch (sectionName) {
534
+ case "milestones": {
535
+ if (state.milestones.length === 0)
536
+ return "";
537
+ const text = state.milestones
538
+ .map((m) => {
539
+ const icon = m.kind === "agent_declared" ? "🏁" : m.kind === "criteria_milestone" ? "✅" : "📍";
540
+ const lines = [
541
+ `**${icon} ${m.label}** (R${m.round_range.start}–R${m.round_range.end}, ${(m.progress_at_boundary * 100).toFixed(0)}%)`,
542
+ ` Outcome: ${m.outcome}`,
543
+ ];
544
+ if (m.carried_constraints.length > 0)
545
+ lines.push(` Carried: ${m.carried_constraints.slice(0, 3).join("; ")}`);
546
+ if (m.resolved_constraints.length > 0)
547
+ lines.push(` Resolved: ${m.resolved_constraints.slice(0, 3).join("; ")}`);
548
+ return lines.join("\n");
549
+ })
550
+ .join("\n\n");
551
+ return section("Expanded Phase History", text);
552
+ }
553
+ case "sub_goals": {
554
+ if (state.subGoals.length === 0)
555
+ return "";
556
+ const lines = [];
557
+ for (const sg of state.subGoals) {
558
+ const icon = sg.status === "in_progress" ? "🔄" :
559
+ sg.status === "done" ? "✅" :
560
+ sg.status === "blocked" ? "🚫" :
561
+ sg.status === "canceled" ? "❌" : "⏳";
562
+ const stale = sg.status === "pending" && state.round - sg.declared_at_round >= 10 ? " ⚠️ stale" : "";
563
+ const detail = sg.status === "done" && sg.completed_at_round
564
+ ? ` (done, R${sg.completed_at_round})`
565
+ : sg.status === "in_progress" ? ` (since R${sg.status_changed_at_round})`
566
+ : sg.status === "pending" ? ` (since R${sg.declared_at_round})`
567
+ : sg.status === "blocked" ? ` (blocked R${sg.status_changed_at_round})`
568
+ : ` (canceled R${sg.status_changed_at_round})`;
569
+ lines.push(`- ${icon} [\`${sg.id}\`] ${sg.description}${detail}${stale}`);
570
+ }
571
+ return section("Expanded Sub-Goal Dashboard", lines.join("\n"));
572
+ }
573
+ case "constraint_lifecycle": {
574
+ if (state.inactiveConstraints.length === 0 && state.retiredConstraints.length === 0)
575
+ return "";
576
+ const lines = [];
577
+ if (state.inactiveConstraints.length > 0) {
578
+ lines.push("**Inactive (auto-decayed):**");
579
+ for (const c of state.inactiveConstraints) {
580
+ const meta = state.constraintMetadata.find((m) => m.text === c);
581
+ const age = meta ? state.round - (meta.last_violated_at_round || meta.discovered_at_round || 0) : "?";
582
+ lines.push(`- ${c} (${age} rounds inactive)`);
583
+ }
584
+ }
585
+ if (state.retiredConstraints.length > 0) {
586
+ lines.push("**Retired:**");
587
+ for (const c of state.retiredConstraints)
588
+ lines.push(`- ~${c}~`);
589
+ }
590
+ return section("Constraint Lifecycle", lines.join("\n"));
591
+ }
592
+ case "agent_trust": {
593
+ if (state.agentTrustScore === undefined)
594
+ return "";
595
+ const barLen = 10;
596
+ const filled = Math.round(state.agentTrustScore * barLen);
597
+ const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
598
+ const lines = [
599
+ `${bar} ${(state.agentTrustScore * 100).toFixed(0)}%`,
600
+ ];
601
+ if (state.agentTrustTrend.length > 0) {
602
+ lines.push(`Trend: ${state.agentTrustTrend.join(" → ")}`);
603
+ }
604
+ return section("Agent Trust (Expanded)", lines.join("\n"));
605
+ }
606
+ case "progress": {
607
+ const p = state.progress;
608
+ if (p.estimate === null && p.criteriaMet.length === 0 && p.criteriaRemaining.length === 0)
609
+ return "";
610
+ const lines = [];
611
+ const total = p.criteriaMet.length + p.criteriaRemaining.length;
612
+ if (total > 0)
613
+ lines.push(`**Criteria**: ${p.criteriaMet.length}/${total} met`);
614
+ if (p.estimate !== null)
615
+ lines.push(`**Estimate**: ${(p.estimate * 100).toFixed(0)}%`);
616
+ if (p.tests)
617
+ lines.push(`**Tests**: ${p.tests.passed} passed, ${p.tests.failed} failed, ${p.tests.skipped} skipped`);
618
+ if (p.filesChanged.length > 0) {
619
+ lines.push("**Files Changed**:");
620
+ for (const f of p.filesChanged.slice(0, 10))
621
+ lines.push(`- ${f}`);
622
+ }
623
+ return section("Progress Dashboard (Expanded)", lines.join("\n"));
624
+ }
625
+ case "loop_synthesis": {
626
+ if (!state.loopSynthesis)
627
+ return "";
628
+ return section("Loop Summary", state.loopSynthesis);
629
+ }
630
+ default:
631
+ return "";
632
+ }
633
+ }
308
634
  export function assemblePromptArtifact(input) {
309
635
  const mode = input.mode ?? "adaptive";
310
636
  const budgets = { ...DEFAULT_PROMPT_BUDGETS, ...input.budgets };
311
637
  const budget = budgets[input.level];
312
638
  const stateHash = hashCanonicalState(input.state);
313
639
  const attempt = Math.max(1, input.attempt ?? 1);
640
+ // ── State file reference + conditional read instruction ────────────
641
+ // L2 rounds (full rehydration), recovery, rejection, drift, and pointer
642
+ // mode all benefit from the model reading the durable state file rather
643
+ // than relying on potentially-compacted conversation history.
644
+ const fullContextReasons = new Set([
645
+ "first_round", "plan_boundary", "checkpoint_boundary", "goal_changed",
646
+ "missing_previous_state", "verification_contradicted",
647
+ "rejection_rehydrate", "recovery_boundary", "state_drift",
648
+ "periodic_refresh",
649
+ ]);
650
+ const needsFullContext = input.level === "l2"
651
+ || input.mode === "pointer"
652
+ || input.reasons.some((r) => fullContextReasons.has(r));
653
+ const stateFilePath = input.state.stateFilePath;
654
+ const stateFileRef = stateFilePath
655
+ ? `📄 Full state: \`${stateFilePath}\`\n`
656
+ : "";
657
+ const readInstruction = needsFullContext && stateFilePath
658
+ ? [
659
+ "",
660
+ "> ⚠️ **Read the full state file before acting.** The summary below is a",
661
+ "> capsule. The durable state file contains complete phase history,",
662
+ "> sub-goal dashboard, constraint lifecycle, and agent trust data.",
663
+ "> Conversation history may be incomplete after compaction or session",
664
+ "> resume — the state file is the source of truth.",
665
+ "",
666
+ ].join("\n")
667
+ : "";
314
668
  const header = [
315
669
  `## LoopForge Round ${input.state.round}`,
316
670
  `State: ${stateHash.slice(0, 12)} | Level: ${input.level.toUpperCase()} | Attempt: ${attempt}`,
671
+ stateFileRef,
672
+ readInstruction,
317
673
  "",
318
674
  ].join("\n");
319
675
  const sections = selectSections(input);
320
- const pointer = input.state.stateFilePath
321
- ? section("Full State", input.state.stateFilePath)
676
+ const pointer = stateFilePath
677
+ ? section("Full State", stateFilePath)
322
678
  : "";
323
679
  const evaluation = input.selfEvaluationBlock.trim()
324
680
  ? `${input.selfEvaluationBlock.trim()}\n`
@@ -351,13 +707,55 @@ export function assemblePromptArtifact(input) {
351
707
  evaluation,
352
708
  ].join("\n");
353
709
  const footer = [pointer, howToRespond].join("");
354
- const fixedText = header;
710
+ // ── v2.9: Prompt Requests — model-expressed information needs ──────────
711
+ const pr = input.promptRequests;
712
+ const levelIsL0 = input.level === "l0";
713
+ const levelIsL1 = input.level === "l1";
714
+ const levelIsL2 = input.level === "l2";
715
+ // Confusion alerts: L1/L2 only, rendered at the top (before mandatory sections)
716
+ const confusionText = (!levelIsL0 && pr?.confusion_points?.length)
717
+ ? renderConfusionAlerts(pr.confusion_points, input.state, levelIsL2)
718
+ : "";
719
+ // Emphasize: L1/L2 only, matched against active state items
720
+ const policy = getPolicy().prompt;
721
+ const emphasizeCap = levelIsL2 ? policy.max_emphasize_l2 : policy.max_emphasize_l1;
722
+ const emphasized = (!levelIsL0 && pr?.emphasize?.length)
723
+ ? matchEmphasize(pr.emphasize, [
724
+ ...input.state.activeConstraints,
725
+ ...input.state.discoveries,
726
+ ...input.state.blockers,
727
+ ...input.state.changesSinceLastRound,
728
+ ], emphasizeCap)
729
+ : [];
730
+ const criticalContextText = renderCriticalContext(emphasized, input.state);
731
+ // Expand: L1 only, max 1 section (L2 already expanded, L0 ignored)
732
+ const expandCap = levelIsL1 ? policy.max_expand_l1 : 0;
733
+ const expandedSection = (pr?.expand?.length && expandCap > 0)
734
+ ? renderExpandedSection(pr.expand[0], input.state)
735
+ : "";
736
+ // Inject confusion alerts into the header area (before mandatory sections)
737
+ const fixedText = header + confusionText;
738
+ // Inject critical context as a mandatory section
739
+ let augmentedSections = sections;
740
+ if (criticalContextText) {
741
+ augmentedSections = [
742
+ {
743
+ id: "critical_context",
744
+ text: criticalContextText,
745
+ mandatory: true,
746
+ },
747
+ ...sections,
748
+ ];
749
+ }
355
750
  const selected = mode === "pointer"
356
- ? renderWithinBudget(sections.filter((item) => item.mandatory), fixedText, budget)
357
- : renderWithinBudget(sections, fixedText, budget - footer.length);
358
- const renderedPrompt = selected.rendered + footer;
751
+ ? renderWithinBudget(augmentedSections.filter((item) => item.mandatory), fixedText, budget)
752
+ : renderWithinBudget(augmentedSections, fixedText, budget - footer.length - expandedSection.length);
753
+ const renderedPrompt = selected.rendered + expandedSection + footer;
359
754
  const includedSections = [
360
755
  ...selected.included,
756
+ ...(confusionText ? ["confusion_alerts"] : []),
757
+ ...(criticalContextText ? ["critical_context"] : []),
758
+ ...(expandedSection ? [`expand:${pr?.expand?.[0] ?? ""}`] : []),
361
759
  ...(pointer ? ["state_pointer"] : []),
362
760
  ...(evaluation ? ["self_evaluation"] : []),
363
761
  ];