loopforge 2.0.1 → 2.6.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 (92) hide show
  1. package/README.md +7 -5
  2. package/dist/canonical-state.d.ts +23 -2
  3. package/dist/canonical-state.d.ts.map +1 -1
  4. package/dist/canonical-state.js +90 -6
  5. package/dist/canonical-state.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +5 -1
  8. package/dist/cli.js.map +1 -1
  9. package/dist/enforcement-gate.d.ts +1 -1
  10. package/dist/enforcement-gate.d.ts.map +1 -1
  11. package/dist/enforcement-gate.js +86 -7
  12. package/dist/enforcement-gate.js.map +1 -1
  13. package/dist/engine.d.ts +2 -10
  14. package/dist/engine.d.ts.map +1 -1
  15. package/dist/engine.js +56 -106
  16. package/dist/engine.js.map +1 -1
  17. package/dist/evidence-provider.d.ts.map +1 -1
  18. package/dist/evidence-provider.js +1 -8
  19. package/dist/evidence-provider.js.map +1 -1
  20. package/dist/index.d.ts +7 -7
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +5 -5
  23. package/dist/index.js.map +1 -1
  24. package/dist/loop-compiler.d.ts.map +1 -1
  25. package/dist/loop-compiler.js +511 -48
  26. package/dist/loop-compiler.js.map +1 -1
  27. package/dist/loop-store.d.ts.map +1 -1
  28. package/dist/loop-store.js +23 -2
  29. package/dist/loop-store.js.map +1 -1
  30. package/dist/mcp/server.js +1 -1
  31. package/dist/mcp/session.d.ts +2 -4
  32. package/dist/mcp/session.d.ts.map +1 -1
  33. package/dist/mcp/session.js +27 -50
  34. package/dist/mcp/session.js.map +1 -1
  35. package/dist/mcp/tools.d.ts.map +1 -1
  36. package/dist/mcp/tools.js +32 -5
  37. package/dist/mcp/tools.js.map +1 -1
  38. package/dist/observability.d.ts +5 -36
  39. package/dist/observability.d.ts.map +1 -1
  40. package/dist/observability.js +9 -79
  41. package/dist/observability.js.map +1 -1
  42. package/dist/policy-metrics.d.ts +1 -0
  43. package/dist/policy-metrics.d.ts.map +1 -1
  44. package/dist/policy-metrics.js +2 -0
  45. package/dist/policy-metrics.js.map +1 -1
  46. package/dist/policy.d.ts +58 -0
  47. package/dist/policy.d.ts.map +1 -1
  48. package/dist/policy.js +24 -3
  49. package/dist/policy.js.map +1 -1
  50. package/dist/prompt-assembler.d.ts.map +1 -1
  51. package/dist/prompt-assembler.js +104 -0
  52. package/dist/prompt-assembler.js.map +1 -1
  53. package/dist/protocol.d.ts +115 -12
  54. package/dist/protocol.d.ts.map +1 -1
  55. package/dist/protocol.js +40 -4
  56. package/dist/protocol.js.map +1 -1
  57. package/dist/replay.d.ts +0 -1
  58. package/dist/replay.d.ts.map +1 -1
  59. package/dist/replay.js +1 -87
  60. package/dist/replay.js.map +1 -1
  61. package/dist/round-coordinator.d.ts +0 -2
  62. package/dist/round-coordinator.d.ts.map +1 -1
  63. package/dist/round-coordinator.js +71 -76
  64. package/dist/round-coordinator.js.map +1 -1
  65. package/dist/round-driver.d.ts +1 -0
  66. package/dist/round-driver.d.ts.map +1 -1
  67. package/dist/round-driver.js +2 -0
  68. package/dist/round-driver.js.map +1 -1
  69. package/dist/round-transaction.d.ts.map +1 -1
  70. package/dist/round-transaction.js +2 -22
  71. package/dist/round-transaction.js.map +1 -1
  72. package/dist/run.d.ts +51 -0
  73. package/dist/run.d.ts.map +1 -0
  74. package/dist/run.js +200 -0
  75. package/dist/run.js.map +1 -0
  76. package/dist/self-eval.d.ts +0 -3
  77. package/dist/self-eval.d.ts.map +1 -1
  78. package/dist/self-eval.js +15 -18
  79. package/dist/self-eval.js.map +1 -1
  80. package/dist/storage.d.ts.map +1 -1
  81. package/dist/storage.js +13 -3
  82. package/dist/storage.js.map +1 -1
  83. package/dist/token-utils.d.ts +22 -0
  84. package/dist/token-utils.d.ts.map +1 -0
  85. package/dist/token-utils.js +46 -0
  86. package/dist/token-utils.js.map +1 -0
  87. package/dist/verification-gate.d.ts +1 -5
  88. package/dist/verification-gate.d.ts.map +1 -1
  89. package/dist/verification-gate.js +109 -58
  90. package/dist/verification-gate.js.map +1 -1
  91. package/loop_policy.json +20 -3
  92. package/package.json +2 -2
@@ -5,16 +5,12 @@
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
7
  import { getPolicy } from "./policy.js";
8
- import { AgentStatus, makeCheckpointSummary, makeLoopCompileResponse, makeLoopHealth, makeLoopObjective, makeRollingSummary, makeTaskAlignment, } from "./protocol.js";
8
+ import { AgentStatus, makeLoopCompileResponse, makeLoopHealth, makeLoopObjective, makeConstraintMeta, makeMilestoneSummary, makeRollingSummary, makeSubGoal, makeTaskAlignment, } from "./protocol.js";
9
9
  import { createCanonicalLoopState, renderCanonicalStateMarkdown, } from "./canonical-state.js";
10
10
  import { assemblePromptArtifact } from "./prompt-assembler.js";
11
11
  import { decidePromptLevel, } from "./prompt-policy.js";
12
- function unique(values) {
13
- return [...new Set(values
14
- .filter((value) => typeof value === "string")
15
- .map((value) => value.trim())
16
- .filter(Boolean))];
17
- }
12
+ import { jaccardSimilarity, unique, entryRound } from "./token-utils.js";
13
+ // unique() imported from token-utils.ts
18
14
  function contextEntries(context) {
19
15
  if (!context || !Array.isArray(context.results))
20
16
  return [];
@@ -26,10 +22,7 @@ function lineage(entry) {
26
22
  ? value
27
23
  : {};
28
24
  }
29
- function entryRound(entry) {
30
- const value = lineage(entry).round;
31
- return typeof value === "number" ? value : 0;
32
- }
25
+ // entryRound() imported from token-utils.ts
33
26
  function loopEntries(loopId, context) {
34
27
  return contextEntries(context)
35
28
  .filter((entry) => entry.loop_id === loopId || lineage(entry).loop_id === loopId)
@@ -139,19 +132,111 @@ function evolveConstraints(request, objective, previous) {
139
132
  .slice(0, getPolicy().evolution.max_active_constraints);
140
133
  return { active, retired };
141
134
  }
135
+ // ═══════════════════════════════════════════════════════════════════════════
136
+ // Hierarchical Summary (v2.1)
137
+ // ═══════════════════════════════════════════════════════════════════════════
138
+ /** Extract execution_evidence from a vault entry, handling both direct-field
139
+ * and lineage-nested shapes. Returns null when absent. */
140
+ function entryExecutionEvidence(entry) {
141
+ const direct = entry.execution_evidence;
142
+ if (direct && typeof direct === "object" && !Array.isArray(direct)) {
143
+ return direct;
144
+ }
145
+ const lin = lineage(entry);
146
+ const nested = lin.execution_evidence;
147
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
148
+ return nested;
149
+ }
150
+ return null;
151
+ }
152
+ /** Read success_criteria_met from an entry's execution_evidence. */
153
+ function entryCriteriaMet(entry) {
154
+ const ev = entryExecutionEvidence(entry);
155
+ if (!ev)
156
+ return [];
157
+ const arr = ev.success_criteria_met;
158
+ return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
159
+ }
160
+ /** Detect newly met criteria by comparing the current entry's
161
+ * success_criteria_met against the previous entry's, using Jaccard
162
+ * similarity with threshold 0.7 to avoid Surface-form mismatches.
163
+ * Returns empty array when there is no previous entry — the first
164
+ * entry's criteria are the baseline, not a "new" event. */
165
+ function detectNewCriteria(current, previous) {
166
+ const currMet = entryCriteriaMet(current);
167
+ if (currMet.length === 0)
168
+ return [];
169
+ if (!previous) {
170
+ // First round with criteria is the baseline — not a milestone trigger.
171
+ return [];
172
+ }
173
+ const prevMet = entryCriteriaMet(previous);
174
+ if (prevMet.length === 0)
175
+ return currMet;
176
+ return currMet.filter((curr) => prevMet.every((prev) => jaccardSimilarity(curr, prev) < getPolicy().evolution.criteria_dedup_threshold));
177
+ }
178
+ /** Read constraints_active from an entry's lineage. */
179
+ function entryActiveConstraints(entry) {
180
+ const lin = lineage(entry);
181
+ const arr = lin.constraints_active;
182
+ return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
183
+ }
184
+ /** Read retracted_constraints from an entry. */
185
+ function entryRetractedConstraints(entry) {
186
+ const direct = entry.retracted_constraints;
187
+ if (Array.isArray(direct))
188
+ return direct.filter((v) => typeof v === "string");
189
+ const lin = lineage(entry);
190
+ const nested = lin.retracted_constraints;
191
+ if (Array.isArray(nested))
192
+ return nested.filter((v) => typeof v === "string");
193
+ return [];
194
+ }
195
+ /** Read progress_estimate from an entry's execution_evidence. */
196
+ function entryProgressEstimate(entry) {
197
+ const ev = entryExecutionEvidence(entry);
198
+ if (!ev)
199
+ return 0;
200
+ const pe = ev.progress_estimate;
201
+ return typeof pe === "number" ? pe : 0;
202
+ }
203
+ /** Build a single MilestoneSummary from a range of completed round entries. */
204
+ function buildMilestoneFromEntries(phaseEntries, startRound, endRound, label, kind) {
205
+ const last = phaseEntries.at(-1);
206
+ const outcomeRaw = last
207
+ ? (typeof last.output_summary === "string" ? last.output_summary : "")
208
+ : "";
209
+ const outcomeMax = getPolicy().checkpoint.outcome_max_chars;
210
+ const outcome = outcomeRaw.length > outcomeMax
211
+ ? outcomeRaw.slice(0, outcomeMax) + "…"
212
+ : outcomeRaw;
213
+ const carried = last ? entryActiveConstraints(last) : [];
214
+ const resolved = unique(phaseEntries.flatMap((e) => entryRetractedConstraints(e)));
215
+ const progress = last ? entryProgressEstimate(last) : 0;
216
+ return makeMilestoneSummary({
217
+ label,
218
+ round_range: { start: startRound, end: endRound },
219
+ outcome,
220
+ carried_constraints: carried,
221
+ resolved_constraints: resolved,
222
+ progress_at_boundary: progress,
223
+ kind,
224
+ generated_at_round: endRound,
225
+ });
226
+ }
142
227
  export function buildRollingSummary(loopId, currentRound, context, sinceRound = 0) {
143
- const window = getPolicy().summary.window;
144
- const entries = loopEntries(loopId, context)
228
+ const policy = getPolicy().summary;
229
+ const allEntries = loopEntries(loopId, context);
230
+ // ── Phase 1: Recent Window (preserves existing behavior) ──
231
+ const windowEntries = allEntries
145
232
  .filter((entry) => {
146
233
  const round = entryRound(entry);
147
234
  return round >= sinceRound && round < currentRound;
148
235
  })
149
- .slice(-window);
150
- if (entries.length === 0)
151
- return null;
236
+ .slice(-policy.window);
152
237
  const outcomes = [];
153
238
  const issues = [];
154
- for (const entry of entries) {
239
+ for (const entry of windowEntries) {
155
240
  const data = lineage(entry);
156
241
  const round = entryRound(entry);
157
242
  const success = entry.success ?? data.success;
@@ -165,33 +250,387 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
165
250
  : Array.isArray(data.constraint_violations) ? data.constraint_violations : [];
166
251
  issues.push(...violations.filter((item) => typeof item === "string"));
167
252
  }
253
+ // ── Phase 2: Milestone Accumulation ──
254
+ // Scan all completed entries in round order. For each entry, check three
255
+ // trigger signals in priority order: agent_declared > criteria_milestone > auto.
256
+ const completedEntries = allEntries.filter((entry) => entryRound(entry) >= 1 && entryRound(entry) < currentRound);
257
+ const milestones = [];
258
+ let lastMilestoneRound = 0;
259
+ for (let i = 0; i < completedEntries.length; i++) {
260
+ const entry = completedEntries[i];
261
+ const rnd = entryRound(entry);
262
+ const lin = lineage(entry);
263
+ // Signal 1: Agent-declared checkpoint
264
+ const isAgentCheckpoint = lin.compression_checkpoint === true;
265
+ // Signal 2: New criteria met (compare with previous vault entry)
266
+ const prev = i > 0 ? completedEntries[i - 1] : null;
267
+ const newCriteria = detectNewCriteria(entry, prev);
268
+ if (isAgentCheckpoint || newCriteria.length > 0) {
269
+ const startRound = lastMilestoneRound + 1;
270
+ const phaseEntries = completedEntries.filter((e) => {
271
+ const er = entryRound(e);
272
+ return er >= startRound && er <= rnd;
273
+ });
274
+ const kind = isAgentCheckpoint
275
+ ? "agent_declared"
276
+ : "criteria_milestone";
277
+ const label = isAgentCheckpoint
278
+ ? (typeof lin.checkpoint_label === "string" && lin.checkpoint_label.trim()
279
+ ? lin.checkpoint_label.trim()
280
+ : `Round ${rnd}`)
281
+ : `Completed: ${newCriteria.join(", ")}`;
282
+ milestones.push(buildMilestoneFromEntries(phaseEntries, startRound, rnd, label, kind));
283
+ lastMilestoneRound = rnd;
284
+ }
285
+ }
286
+ // Signal 3: Safety net — auto milestone when gap exceeds milestone_interval
287
+ const gapSinceLast = (currentRound - 1) - lastMilestoneRound;
288
+ if (lastMilestoneRound > 0 &&
289
+ gapSinceLast >= policy.milestone_interval &&
290
+ completedEntries.length > 0) {
291
+ const startRound = lastMilestoneRound + 1;
292
+ const endRound = currentRound - 1;
293
+ const phaseEntries = completedEntries.filter((e) => {
294
+ const er = entryRound(e);
295
+ return er >= startRound && er <= endRound;
296
+ });
297
+ if (phaseEntries.length > 0) {
298
+ milestones.push(buildMilestoneFromEntries(phaseEntries, startRound, endRound, `Rounds ${startRound}–${endRound}`, "auto"));
299
+ }
300
+ }
301
+ const cappedMilestones = milestones.slice(-policy.max_milestones);
302
+ // ── Phase 3: Loop Synthesis (formulaic) ──
303
+ let loopSynthesis = "";
304
+ if (policy.enable_loop_synthesis && currentRound > 1) {
305
+ const totalRounds = currentRound - 1;
306
+ const phaseCount = cappedMilestones.length;
307
+ const lastProgress = completedEntries.length > 0
308
+ ? entryProgressEstimate(completedEntries[completedEntries.length - 1])
309
+ : 0;
310
+ const activeCount = completedEntries.length > 0
311
+ ? entryActiveConstraints(completedEntries[completedEntries.length - 1]).length
312
+ : 0;
313
+ const totalResolved = cappedMilestones.reduce((sum, m) => sum + m.resolved_constraints.length, 0);
314
+ loopSynthesis =
315
+ `Loop spans ${totalRounds} rounds across ${phaseCount} phases. ` +
316
+ `Overall progress: ${(lastProgress * 100).toFixed(0)}%. ` +
317
+ `${activeCount} active constraints, ${totalResolved} resolved across all phases.`;
318
+ }
319
+ if (windowEntries.length === 0 && cappedMilestones.length === 0)
320
+ return null;
168
321
  return makeRollingSummary({
169
322
  key_outcomes: unique(outcomes),
170
323
  recurring_issues: unique(issues),
171
- rounds_sampled: entries.length,
324
+ rounds_sampled: windowEntries.length,
172
325
  generated_at_round: currentRound,
173
326
  failed_patterns: [],
327
+ milestones: cappedMilestones,
328
+ loop_synthesis: loopSynthesis || undefined,
329
+ });
330
+ }
331
+ // ═══════════════════════════════════════════════════════════════════════════
332
+ // Sub-Goal Structured Tracking (v2.2)
333
+ // ═══════════════════════════════════════════════════════════════════════════
334
+ /** Derive a stable sub-goal ID from its description hash. */
335
+ function deriveSubGoalId(description) {
336
+ return "sg-" + computeGoalTextHash(description).slice(0, 8);
337
+ }
338
+ /** Read emerged_subtasks from a vault entry (handles direct + lineage nesting). */
339
+ function entryEmergedSubtasks(entry) {
340
+ const direct = entry.emerged_subtasks;
341
+ if (Array.isArray(direct))
342
+ return direct.filter((v) => typeof v === "string");
343
+ const lin = lineage(entry);
344
+ const nested = lin.emerged_subtasks;
345
+ if (Array.isArray(nested))
346
+ return nested.filter((v) => typeof v === "string");
347
+ return [];
348
+ }
349
+ /** Read completed_subtasks from an entry. */
350
+ function entryCompletedSubtasks(entry) {
351
+ const direct = entry.completed_subtasks;
352
+ if (Array.isArray(direct))
353
+ return direct.filter((v) => typeof v === "string");
354
+ return [];
355
+ }
356
+ /** Read blocked_subtasks from an entry. */
357
+ function entryBlockedSubtasks(entry) {
358
+ const direct = entry.blocked_subtasks;
359
+ if (Array.isArray(direct))
360
+ return direct.filter((v) => typeof v === "string");
361
+ return [];
362
+ }
363
+ /** Read canceled_subtasks from an entry. */
364
+ function entryCanceledSubtasks(entry) {
365
+ const direct = entry.canceled_subtasks;
366
+ if (Array.isArray(direct))
367
+ return direct.filter((v) => typeof v === "string");
368
+ return [];
369
+ }
370
+ /**
371
+ * Match a user-provided sub-task description against existing SubGoals.
372
+ * Returns the index of the best-matching SubGoal with similarity >= threshold,
373
+ * or -1 if no match found.
374
+ */
375
+ function matchSubGoal(target, subGoals, threshold) {
376
+ let bestIdx = -1;
377
+ let bestScore = 0;
378
+ for (let i = 0; i < subGoals.length; i++) {
379
+ const score = jaccardSimilarity(target, subGoals[i].description);
380
+ if (score > bestScore) {
381
+ bestScore = score;
382
+ bestIdx = i;
383
+ }
384
+ }
385
+ return bestScore >= threshold ? bestIdx : -1;
386
+ }
387
+ /**
388
+ * Manage the full sub-goal lifecycle across rounds.
389
+ *
390
+ * Accumulates sub-goals from all prior vault entries, processes the current
391
+ * round's declarations, derives status changes, and returns the complete
392
+ * structured SubGoal list.
393
+ *
394
+ * Status derivation priority (compiler-driven):
395
+ * 1. Agent declared done/canceled/blocked → explicit status change
396
+ * 2. next_action match → auto in_progress
397
+ * 3. success_criteria_met match → auto done
398
+ */
399
+ function manageSubGoals(loopId, currentRound, lastRoundResult, vaultContext) {
400
+ const policy = getPolicy().evolution;
401
+ const allEntries = loopEntries(loopId, vaultContext);
402
+ const completedEntries = allEntries.filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound);
403
+ const subGoals = [];
404
+ // Phase 1 — Accumulate historical sub-goals from vault entries
405
+ for (const entry of completedEntries) {
406
+ const rnd = entryRound(entry);
407
+ const emerged = entryEmergedSubtasks(entry);
408
+ for (const desc of emerged) {
409
+ const id = deriveSubGoalId(desc);
410
+ // Dedup: skip if an existing sub-goal is too similar
411
+ if (matchSubGoal(desc, subGoals, policy.subgoal_dedup_threshold) >= 0)
412
+ continue;
413
+ subGoals.push(makeSubGoal({
414
+ id,
415
+ description: desc.trim(),
416
+ status: "pending",
417
+ declared_at_round: rnd,
418
+ status_changed_at_round: rnd,
419
+ priority: subGoals.length,
420
+ }));
421
+ }
422
+ }
423
+ // Phase 2 — Process current round's sub-task declarations
424
+ if (lastRoundResult) {
425
+ const rnd = lastRoundResult.round || (currentRound - 1);
426
+ // New sub-goals from this round
427
+ const emerged = lastRoundResult.emerged_subtasks ?? [];
428
+ for (const desc of emerged) {
429
+ if (matchSubGoal(desc, subGoals, policy.subgoal_dedup_threshold) >= 0)
430
+ continue;
431
+ subGoals.push(makeSubGoal({
432
+ id: deriveSubGoalId(desc),
433
+ description: desc.trim(),
434
+ status: "pending",
435
+ declared_at_round: rnd,
436
+ status_changed_at_round: rnd,
437
+ priority: subGoals.length,
438
+ }));
439
+ }
440
+ // Completed sub-goals
441
+ const completed = lastRoundResult.completed_subtasks ?? [];
442
+ for (const desc of completed) {
443
+ const idx = matchSubGoal(desc, subGoals, policy.subgoal_match_threshold);
444
+ if (idx >= 0 && subGoals[idx].status !== "done" && subGoals[idx].status !== "canceled") {
445
+ subGoals[idx].status = "done";
446
+ subGoals[idx].status_changed_at_round = rnd;
447
+ subGoals[idx].completed_at_round = rnd;
448
+ }
449
+ }
450
+ // Blocked sub-goals
451
+ const blocked = lastRoundResult.blocked_subtasks ?? [];
452
+ for (const desc of blocked) {
453
+ const idx = matchSubGoal(desc, subGoals, policy.subgoal_match_threshold);
454
+ if (idx >= 0 && subGoals[idx].status !== "done" && subGoals[idx].status !== "canceled") {
455
+ subGoals[idx].status = "blocked";
456
+ subGoals[idx].status_changed_at_round = rnd;
457
+ }
458
+ }
459
+ // Canceled sub-goals
460
+ const canceled = lastRoundResult.canceled_subtasks ?? [];
461
+ for (const desc of canceled) {
462
+ const idx = matchSubGoal(desc, subGoals, policy.subgoal_match_threshold);
463
+ if (idx >= 0 && subGoals[idx].status !== "done" && subGoals[idx].status !== "canceled") {
464
+ subGoals[idx].status = "canceled";
465
+ subGoals[idx].status_changed_at_round = rnd;
466
+ }
467
+ }
468
+ // Phase 3 — Auto-derive: next_action match → in_progress
469
+ const nextAction = lastRoundResult.next_action?.trim();
470
+ if (nextAction) {
471
+ for (const sg of subGoals) {
472
+ if (sg.status === "pending") {
473
+ if (jaccardSimilarity(nextAction, sg.description) >= policy.subgoal_auto_in_progress_threshold) {
474
+ sg.status = "in_progress";
475
+ sg.status_changed_at_round = rnd;
476
+ break; // Only one sub-goal can be in_progress at a time
477
+ }
478
+ }
479
+ }
480
+ }
481
+ // Phase 4 — Auto-complete: new criteria match → done
482
+ const newCriteria = lastRoundResult.execution_evidence?.success_criteria_met ?? [];
483
+ if (newCriteria.length > 0) {
484
+ for (const sg of subGoals) {
485
+ if (sg.status === "pending" || sg.status === "in_progress") {
486
+ for (const crit of newCriteria) {
487
+ if (jaccardSimilarity(crit, sg.description) >= policy.subgoal_auto_complete_threshold) {
488
+ sg.status = "done";
489
+ sg.status_changed_at_round = rnd;
490
+ sg.completed_at_round = rnd;
491
+ break;
492
+ }
493
+ }
494
+ }
495
+ }
496
+ }
497
+ }
498
+ // Sort for display: in_progress first, then pending by age desc, then blocked, then done, then canceled
499
+ subGoals.sort((a, b) => {
500
+ const rank = (s) => {
501
+ switch (s.status) {
502
+ case "in_progress": return 0;
503
+ case "pending": return 1;
504
+ case "blocked": return 2;
505
+ case "done": return 3;
506
+ case "canceled": return 4;
507
+ }
508
+ };
509
+ const ra = rank(a), rb = rank(b);
510
+ if (ra !== rb)
511
+ return ra - rb;
512
+ // Within same status: pending by oldest first, done by most recent first
513
+ if (a.status === "pending")
514
+ return (currentRound - a.declared_at_round) - (currentRound - b.declared_at_round);
515
+ if (a.status === "done")
516
+ return (b.completed_at_round ?? 0) - (a.completed_at_round ?? 0);
517
+ return a.declared_at_round - b.declared_at_round;
174
518
  });
519
+ return subGoals;
175
520
  }
176
- function tokens(text) {
177
- return new Set(text.toLowerCase().match(/[a-z0-9\u4e00-\u9fff]+/g) ?? []);
521
+ // ═══════════════════════════════════════════════════════════════════════════
522
+ // Time-Aware Constraint Management (v2.3)
523
+ // ═══════════════════════════════════════════════════════════════════════════
524
+ /** Determine the source of a constraint by matching against known sources. */
525
+ function constraintSource(text, objective, constraintsFromPlan) {
526
+ const norm = text.toLowerCase().trim();
527
+ if (objective.hard_constraints.some((c) => c.toLowerCase().trim() === norm))
528
+ return "hard";
529
+ if (constraintsFromPlan.some((c) => c.toLowerCase().trim() === norm))
530
+ return "plan";
531
+ if (objective.success_criteria.some((c) => c.toLowerCase().trim() === norm))
532
+ return "criteria";
533
+ return "discovered";
178
534
  }
179
- function similarity(left, right) {
180
- const a = tokens(left);
181
- const b = tokens(right);
182
- if (a.size === 0 || b.size === 0)
183
- return 1;
184
- let intersection = 0;
185
- for (const value of a)
186
- if (b.has(value))
187
- intersection++;
188
- return intersection / new Set([...a, ...b]).size;
535
+ /** Find the earliest round a constraint was discovered by scanning vault entries.
536
+ * Uses similarity ≥ 0.5 for fuzzy matching. */
537
+ function findDiscoveredRound(text, vaultEntries) {
538
+ let earliest = 0;
539
+ for (const entry of vaultEntries) {
540
+ const rnd = entryRound(entry);
541
+ if (rnd < 1)
542
+ continue;
543
+ const discovered = Array.isArray(entry.discovered_constraints)
544
+ ? entry.discovered_constraints.filter((v) => typeof v === "string")
545
+ : [];
546
+ for (const d of discovered) {
547
+ if (jaccardSimilarity(text, d) >= getPolicy().evolution.constraint_match_threshold) {
548
+ if (earliest === 0 || rnd < earliest)
549
+ earliest = rnd;
550
+ break;
551
+ }
552
+ }
553
+ }
554
+ return earliest;
189
555
  }
556
+ /** Find the latest round a constraint was violated by scanning vault entries.
557
+ * Uses similarity ≥ 0.5 for fuzzy matching. Returns 0 if never violated. */
558
+ function findLastViolatedRound(text, vaultEntries) {
559
+ let latest = 0;
560
+ for (const entry of vaultEntries) {
561
+ const rnd = entryRound(entry);
562
+ if (rnd < 1)
563
+ continue;
564
+ const violations = Array.isArray(entry.constraint_violations)
565
+ ? entry.constraint_violations.filter((v) => typeof v === "string")
566
+ : [];
567
+ for (const v of violations) {
568
+ if (jaccardSimilarity(text, v) >= getPolicy().evolution.constraint_match_threshold) {
569
+ if (rnd > latest)
570
+ latest = rnd;
571
+ break;
572
+ }
573
+ }
574
+ }
575
+ return latest;
576
+ }
577
+ /**
578
+ * Manage constraint lifecycle with time-aware decay.
579
+ *
580
+ * Discovered constraints that haven't been violated for N rounds are demoted
581
+ * to inactive — removed from prompts but kept in state. Hard/plan/criteria
582
+ * constraints never auto-decay. Inactive constraints are auto-reactivated
583
+ * if violated again in the current round.
584
+ */
585
+ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vaultContext, currentRound) {
586
+ const policy = getPolicy().evolution;
587
+ const allEntries = loopEntries(objective.loop_id || "", vaultContext);
588
+ const completedEntries = allEntries.filter((e) => entryRound(e) >= 1 && entryRound(e) < currentRound);
589
+ const metadata = [];
590
+ for (const text of active) {
591
+ const source = constraintSource(text, objective, constraintsFromPlan);
592
+ // Structural constraints never decay
593
+ if (source === "hard" || source === "plan" || source === "criteria") {
594
+ metadata.push(makeConstraintMeta({
595
+ text,
596
+ discovered_at_round: objective.created_at_round || 1,
597
+ last_violated_at_round: findLastViolatedRound(text, completedEntries),
598
+ source,
599
+ status: "active",
600
+ }));
601
+ continue;
602
+ }
603
+ // Discovered constraints: check for inactivity
604
+ const discoveredAt = findDiscoveredRound(text, completedEntries);
605
+ const lastViolated = findLastViolatedRound(text, completedEntries);
606
+ const roundsSinceRelevant = lastViolated > 0
607
+ ? currentRound - lastViolated
608
+ : currentRound - (discoveredAt || currentRound);
609
+ const status = roundsSinceRelevant > policy.constraint_inactive_rounds
610
+ ? "inactive"
611
+ : "active";
612
+ metadata.push(makeConstraintMeta({
613
+ text,
614
+ discovered_at_round: discoveredAt || 0,
615
+ last_violated_at_round: lastViolated,
616
+ source: "discovered",
617
+ status,
618
+ }));
619
+ }
620
+ const newActive = metadata
621
+ .filter((m) => m.status === "active")
622
+ .map((m) => m.text);
623
+ const inactive = metadata
624
+ .filter((m) => m.status === "inactive")
625
+ .map((m) => m.text);
626
+ return { active: newActive, inactive, metadata };
627
+ }
628
+ // jaccardSimilarity() imported from token-utils.ts
190
629
  export function alignTask(proposedTask, request, context) {
191
630
  const objective = request.loop_objective ?? latestObjective(request.loop_id, context);
192
631
  if (!objective)
193
632
  return makeTaskAlignment();
194
- const score = similarity(proposedTask, [objective.objective, ...objective.success_criteria, ...objective.hard_constraints].join(" "));
633
+ const score = jaccardSimilarity(proposedTask, [objective.objective, ...objective.success_criteria, ...objective.hard_constraints].join(" "));
195
634
  return makeTaskAlignment({
196
635
  is_aligned: score >= 0.3,
197
636
  alignment_score: Number(score.toFixed(2)),
@@ -204,7 +643,7 @@ export function checkLoopHealth(loopId, request, context) {
204
643
  const alignment = alignTask(request.task, request, context);
205
644
  const violations = request.last_round_result?.constraint_violations.length ?? 0;
206
645
  const integrity = Math.max(0, 1 - violations * 0.2);
207
- const continuity = previous ? similarity(previous.task, request.task) : 1;
646
+ const continuity = previous ? jaccardSimilarity(previous.task, request.task) : 1;
208
647
  const drift = alignment.alignment_score < 0.3 || continuity < 0.2;
209
648
  return makeLoopHealth({
210
649
  goal_alignment: alignment.alignment_score,
@@ -272,6 +711,9 @@ export function buildSelfEvalBlock(round) {
272
711
  },
273
712
  wrong_assumptions: [],
274
713
  next_action: "<next concrete action, or empty when complete>",
714
+ completed_subtasks: [],
715
+ blocked_subtasks: [],
716
+ canceled_subtasks: [],
275
717
  }, null, 2),
276
718
  "---end-loopforge-eval",
277
719
  "",
@@ -279,25 +721,17 @@ export function buildSelfEvalBlock(round) {
279
721
  "Set success=true only when the full goal and ALL hard constraints are verified.",
280
722
  ].join("\n");
281
723
  }
282
- function checkpoint(request, active, retired) {
283
- const last = request.last_round_result;
284
- if (!last?.compression_checkpoint)
285
- return null;
286
- const policy = getPolicy().checkpoint;
287
- return makeCheckpointSummary({
288
- label: last.checkpoint_label?.trim() || `Round ${last.round}`,
289
- declared_at_round: last.round,
290
- outcome: last.output_summary.slice(0, policy.outcome_max_chars),
291
- carried_constraints: active.slice(0, policy.max_carried_constraints),
292
- resolved_constraints: retired,
293
- });
294
- }
724
+ // v2.5: checkpoint() removed — agent_declared milestones from
725
+ // compression_checkpoint supersede the separate CheckpointSummary.
726
+ // See buildRollingSummary() signal 1.
295
727
  export function compileLoop(request, context) {
296
728
  const decision = levelDecision(request, context);
297
729
  const previous = getPreviousRound(request.loop_id, request.round - 1, context);
298
730
  const objective = evolveObjective(request, context);
299
731
  const constraints = evolveConstraints(request, objective, previous);
732
+ const constraintLifecycle = manageConstraintLifecycle(constraints.active, objective, request.constraints_from_plan, context, request.round);
300
733
  const rolling = buildRollingSummary(request.loop_id, request.round, context);
734
+ const subGoals = manageSubGoals(request.loop_id, request.round, request.last_round_result, context);
301
735
  const alignment = alignTask(request.task, request, context);
302
736
  const health = checkLoopHealth(request.loop_id, { ...request, loop_objective: objective }, context);
303
737
  const warnings = unique([
@@ -306,13 +740,33 @@ export function compileLoop(request, context) {
306
740
  ? `Loop health recommends ${health.escalation_recommended}.`
307
741
  : "",
308
742
  ]);
743
+ // ── v2.5: Agent trust score ──────────────────────────────────────────
744
+ const flags = request.verification_flags ?? [];
745
+ const errors = flags.filter((f) => f.severity === "error").length;
746
+ const warns = flags.filter((f) => f.severity === "warn").length;
747
+ const trustScore = Math.max(0, Math.min(1, 1.0 - (errors * 0.15) - (warns * 0.03)));
748
+ // Trend — derive from vault entries (violation counts per round as proxy).
749
+ // No new persistence; reconstructed same as milestones/sub-goals.
750
+ const completedEntries = loopEntries(request.loop_id, context)
751
+ .filter((e) => entryRound(e) >= 1 && entryRound(e) < request.round);
752
+ const trend = [];
753
+ for (const entry of completedEntries.slice(-10)) {
754
+ const viols = Array.isArray(entry.constraint_violations)
755
+ ? entry.constraint_violations.filter((v) => typeof v === "string")
756
+ : [];
757
+ // Simple proxy: no violations = perfect trust, each violation = -0.1
758
+ const score = Math.max(0, 1.0 - viols.length * 0.1);
759
+ trend.push(Number(score.toFixed(2)));
760
+ }
309
761
  const response = makeLoopCompileResponse({
310
762
  status: AgentStatus.OK,
311
763
  recompile_level: decision.level,
312
764
  diff_from_previous: decision.reasons.join(","),
313
765
  lineage: [`${request.loop_id}:r${request.round}`],
314
- constraints_active: constraints.active,
766
+ constraints_active: constraintLifecycle.active,
315
767
  constraints_retired: constraints.retired,
768
+ constraints_inactive: constraintLifecycle.inactive,
769
+ constraint_metadata: constraintLifecycle.metadata,
316
770
  loop_id: request.loop_id,
317
771
  round: request.round,
318
772
  goal_id: deriveGoalId(request.loop_id, request.task, request.goal_id),
@@ -321,7 +775,9 @@ export function compileLoop(request, context) {
321
775
  loop_health: health,
322
776
  task_alignment: alignment,
323
777
  rolling_summary: rolling,
324
- checkpoint_summary: checkpoint(request, constraints.active, constraints.retired),
778
+ sub_goals: subGoals,
779
+ agent_trust_score: request.round > 1 ? Number(trustScore.toFixed(2)) : undefined,
780
+ agent_trust_trend: request.round > 1 ? trend : [],
325
781
  suggested_next_task: request.last_round_result?.next_action?.trim() ||
326
782
  request.last_round_result?.emerged_subtasks?.[0] || "",
327
783
  plan_source: request.plan_source,
@@ -331,6 +787,13 @@ export function compileLoop(request, context) {
331
787
  const statePath = `${policy.state_file.directory}/${request.loop_id}-state.md`;
332
788
  const state = createCanonicalLoopState(request, response, statePath);
333
789
  const markdown = renderCanonicalStateMarkdown(state);
790
+ // v2.4–v2.5: Adaptive L2 budget — scales with loop complexity
791
+ const adaptiveL2 = policy.prompt.l2_adaptive_enabled
792
+ ? Math.min(policy.prompt.l2_max_chars
793
+ + (request.round * policy.prompt.l2_adaptive_round_factor)
794
+ + ((rolling?.milestones?.length ?? 0) * policy.prompt.l2_adaptive_milestone_factor)
795
+ + (subGoals.length * policy.prompt.l2_adaptive_subgoal_factor), policy.prompt.l2_adaptive_max_chars)
796
+ : policy.prompt.l2_max_chars;
334
797
  const artifact = assemblePromptArtifact({
335
798
  state,
336
799
  level: decision.level,
@@ -339,7 +802,7 @@ export function compileLoop(request, context) {
339
802
  budgets: {
340
803
  l0: policy.prompt.l0_max_chars,
341
804
  l1: policy.prompt.l1_max_chars,
342
- l2: policy.prompt.l2_max_chars,
805
+ l2: adaptiveL2,
343
806
  },
344
807
  attempt: request.attempt,
345
808
  selfEvaluationBlock: buildSelfEvalBlock(request.round),