loopforge 3.2.1 → 3.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.
- package/README.md +67 -67
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +7 -34
- package/dist/audit.js.map +1 -1
- package/dist/canonical-state.d.ts +71 -4
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +176 -9
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +16 -18
- package/dist/cli.js.map +1 -1
- package/dist/cognitive-facts.d.ts +17 -0
- package/dist/cognitive-facts.d.ts.map +1 -0
- package/dist/cognitive-facts.js +99 -0
- package/dist/cognitive-facts.js.map +1 -0
- package/dist/committed-round.d.ts +59 -0
- package/dist/committed-round.d.ts.map +1 -0
- package/dist/committed-round.js +222 -0
- package/dist/committed-round.js.map +1 -0
- package/dist/enforcement-gate.d.ts +4 -2
- package/dist/enforcement-gate.d.ts.map +1 -1
- package/dist/enforcement-gate.js +339 -301
- package/dist/enforcement-gate.js.map +1 -1
- package/dist/engine.d.ts +16 -4
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +63 -15
- package/dist/engine.js.map +1 -1
- package/dist/evidence-claims.d.ts.map +1 -1
- package/dist/evidence-claims.js +21 -39
- package/dist/evidence-claims.js.map +1 -1
- package/dist/evidence-provider.d.ts +29 -0
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +93 -16
- package/dist/evidence-provider.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +21 -4
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +244 -66
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-projection.d.ts +5 -25
- package/dist/loop-projection.d.ts.map +1 -1
- package/dist/loop-projection.js +18 -134
- package/dist/loop-projection.js.map +1 -1
- package/dist/loop-store.d.ts +5 -2
- package/dist/loop-store.d.ts.map +1 -1
- package/dist/loop-store.js.map +1 -1
- package/dist/mcp/round-lifecycle.d.ts +6 -8
- package/dist/mcp/round-lifecycle.d.ts.map +1 -1
- package/dist/mcp/round-lifecycle.js +61 -52
- package/dist/mcp/round-lifecycle.js.map +1 -1
- package/dist/mcp/server.js +3 -3
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +14 -3
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +174 -52
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/tools.d.ts +12 -0
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +160 -52
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +0 -7
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +15 -55
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +22 -20
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +24 -38
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +120 -140
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/protocol.d.ts +57 -23
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +10 -4
- package/dist/protocol.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +21 -22
- package/dist/replay.js.map +1 -1
- package/dist/round-contract.d.ts +64 -0
- package/dist/round-contract.d.ts.map +1 -0
- package/dist/round-contract.js +80 -0
- package/dist/round-contract.js.map +1 -0
- package/dist/round-coordinator.d.ts +5 -3
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +33 -29
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +0 -1
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +0 -1
- package/dist/round-driver.js.map +1 -1
- package/dist/round-transaction.d.ts +0 -1
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +0 -1
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +29 -34
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +112 -148
- package/dist/self-eval.js.map +1 -1
- package/dist/verification-gate.d.ts +53 -12
- package/dist/verification-gate.d.ts.map +1 -1
- package/dist/verification-gate.js +462 -90
- package/dist/verification-gate.js.map +1 -1
- package/dist/workspace.d.ts +29 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +61 -0
- package/dist/workspace.js.map +1 -0
- package/loop_policy.json +5 -13
- package/package.json +1 -1
- package/skills/perception/SKILL.md +135 -136
package/dist/loop-compiler.js
CHANGED
|
@@ -7,6 +7,8 @@ import { createHash } from "node:crypto";
|
|
|
7
7
|
import { getPolicy } from "./policy.js";
|
|
8
8
|
import { AgentStatus, makeLoopCompileResponse, makeLoopHealth, makeLoopObjective, makeConstraintMeta, makeMilestoneSummary, makeRollingSummary, makeSubGoal, makeTaskAlignment, } from "./protocol.js";
|
|
9
9
|
import { createCanonicalLoopState, renderCanonicalStateMarkdown, } from "./canonical-state.js";
|
|
10
|
+
import { decodeCommittedRound, decodeMergedRound, machineGitMotionSeries, mergedRoundsFromEntries, } from "./committed-round.js";
|
|
11
|
+
import { contractRoundEvaluations, deriveActiveRoundContract, } from "./round-contract.js";
|
|
10
12
|
import { assemblePromptArtifact } from "./prompt-assembler.js";
|
|
11
13
|
import { decidePromptLevel, } from "./prompt-policy.js";
|
|
12
14
|
import { deriveItemId, jaccardSimilarity, unique, entryRound } from "./token-utils.js";
|
|
@@ -26,6 +28,35 @@ function loopEntries(loopId, context) {
|
|
|
26
28
|
.filter((entry) => entry.loop_id === loopId || lineage(entry).loop_id === loopId)
|
|
27
29
|
.sort((a, b) => entryRound(a) - entryRound(b));
|
|
28
30
|
}
|
|
31
|
+
/** v3.3.1: The canonical entry for a round — its compile-time lineage entry
|
|
32
|
+
* (task_type "loop_lineage"; engine hydration merges the committed
|
|
33
|
+
* decision into it, so one round = one merged lineage entry in the
|
|
34
|
+
* compiler's view). Event/journal entries (delegation journals, gate
|
|
35
|
+
* records) share lineage.round with the round but carry none of its
|
|
36
|
+
* compile-time fields; round-level consumers that matched on the round
|
|
37
|
+
* number alone could hit them first (they sort after the lineage entry of
|
|
38
|
+
* the same round), producing an empty goal_id / task / constraints_active
|
|
39
|
+
* and forcing spurious L2 recovery. A direct :feedback entry is accepted
|
|
40
|
+
* when the caller supplies a raw committed view instead of hydration. */
|
|
41
|
+
function roundCanonicalEntry(entries, round) {
|
|
42
|
+
const candidates = entries.filter((entry) => entryRound(entry) === round);
|
|
43
|
+
return (candidates.find((entry) => entry.task_type === "loop_lineage") ??
|
|
44
|
+
candidates.find((entry) => String(entry.task_id ?? "").endsWith(":feedback")) ??
|
|
45
|
+
// Untyped compiler inputs are treated as round views, not events. Known event
|
|
46
|
+
// types (delegation journals, gate records) are excluded explicitly so
|
|
47
|
+
// they can never shadow a round again.
|
|
48
|
+
candidates.find((entry) => {
|
|
49
|
+
const type = String(entry.task_type ?? "");
|
|
50
|
+
return type === "" || (!type.startsWith("delegation") && !type.endsWith("journal"));
|
|
51
|
+
}) ??
|
|
52
|
+
null);
|
|
53
|
+
}
|
|
54
|
+
/** Return the canonical read model when an entry is a committed round.
|
|
55
|
+
* Uncommitted compiler lineage has no durable decision yet and deliberately
|
|
56
|
+
* remains on the local compile path; it cannot be treated as history. */
|
|
57
|
+
function committedView(entry) {
|
|
58
|
+
return decodeMergedRound(entry) ?? decodeCommittedRound(entry);
|
|
59
|
+
}
|
|
29
60
|
export function computeGoalTextHash(text) {
|
|
30
61
|
const normalized = text.trim().replace(/\s+/g, " ").toLowerCase();
|
|
31
62
|
return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
|
|
@@ -38,12 +69,10 @@ export function deriveGoalId(loopId, task, explicit = "") {
|
|
|
38
69
|
}
|
|
39
70
|
/** v3.2: Read the previous round's persisted L1 presentation snapshot — the
|
|
40
71
|
* diff baseline for L1 collapse. Returns null when the previous round's
|
|
41
|
-
* lineage entry
|
|
42
|
-
*
|
|
43
|
-
* existing fixtures and pre-v3.2 loops on the full-render path. */
|
|
72
|
+
* lineage entry lacks the three presented_* fields (as on L0/L2 compiles),
|
|
73
|
+
* so callers render the full L1 state. */
|
|
44
74
|
export function readPresentedBaseline(loopId, round, context) {
|
|
45
|
-
const entry =
|
|
46
|
-
.find((candidate) => entryRound(candidate) === round - 1);
|
|
75
|
+
const entry = roundCanonicalEntry(loopEntries(loopId, context), round - 1);
|
|
47
76
|
if (!entry)
|
|
48
77
|
return null;
|
|
49
78
|
const data = lineage(entry);
|
|
@@ -65,8 +94,7 @@ export function readPresentedBaseline(loopId, round, context) {
|
|
|
65
94
|
};
|
|
66
95
|
}
|
|
67
96
|
export function getPreviousRound(loopId, round, context) {
|
|
68
|
-
const entry =
|
|
69
|
-
.find((candidate) => entryRound(candidate) === round);
|
|
97
|
+
const entry = roundCanonicalEntry(loopEntries(loopId, context), round);
|
|
70
98
|
if (!entry)
|
|
71
99
|
return null;
|
|
72
100
|
const data = lineage(entry);
|
|
@@ -196,6 +224,9 @@ function evolveConstraints(request, objective, previous, context) {
|
|
|
196
224
|
/** Extract execution_evidence from a vault entry, handling both direct-field
|
|
197
225
|
* and lineage-nested shapes. Returns null when absent. */
|
|
198
226
|
function entryExecutionEvidence(entry) {
|
|
227
|
+
const view = committedView(entry);
|
|
228
|
+
if (view)
|
|
229
|
+
return view.executionEvidence;
|
|
199
230
|
const direct = entry.execution_evidence;
|
|
200
231
|
if (direct && typeof direct === "object" && !Array.isArray(direct)) {
|
|
201
232
|
return direct;
|
|
@@ -209,6 +240,10 @@ function entryExecutionEvidence(entry) {
|
|
|
209
240
|
}
|
|
210
241
|
/** Read success_criteria_met from an entry's execution_evidence. */
|
|
211
242
|
function entryCriteriaMet(entry) {
|
|
243
|
+
const view = committedView(entry);
|
|
244
|
+
if (view)
|
|
245
|
+
return view.executionEvidence?.success_criteria_met
|
|
246
|
+
?.filter((value) => typeof value === "string") ?? [];
|
|
212
247
|
const ev = entryExecutionEvidence(entry);
|
|
213
248
|
if (!ev)
|
|
214
249
|
return [];
|
|
@@ -217,6 +252,10 @@ function entryCriteriaMet(entry) {
|
|
|
217
252
|
}
|
|
218
253
|
/** Read success_criteria_remaining from an entry's execution_evidence. */
|
|
219
254
|
function entryCriteriaRemaining(entry) {
|
|
255
|
+
const view = committedView(entry);
|
|
256
|
+
if (view)
|
|
257
|
+
return view.executionEvidence?.success_criteria_remaining
|
|
258
|
+
?.filter((value) => typeof value === "string") ?? [];
|
|
220
259
|
const ev = entryExecutionEvidence(entry);
|
|
221
260
|
if (!ev)
|
|
222
261
|
return [];
|
|
@@ -234,17 +273,20 @@ export function deriveLessons(loopId, context, currentRound) {
|
|
|
234
273
|
const warns = new Map();
|
|
235
274
|
for (const entry of entries) {
|
|
236
275
|
const rnd = entryRound(entry);
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
276
|
+
const view = committedView(entry);
|
|
277
|
+
const viols = view
|
|
278
|
+
? view.constraintViolations ?? []
|
|
279
|
+
: Array.isArray(entry.constraint_violations)
|
|
280
|
+
? entry.constraint_violations.filter((v) => typeof v === "string")
|
|
281
|
+
: [];
|
|
240
282
|
for (const text of viols) {
|
|
241
283
|
const list = violations.get(text) ?? [];
|
|
242
284
|
list.push(rnd);
|
|
243
285
|
violations.set(text, list);
|
|
244
286
|
}
|
|
245
|
-
const flags =
|
|
246
|
-
?
|
|
247
|
-
: [];
|
|
287
|
+
const flags = view
|
|
288
|
+
? view.verificationFlags
|
|
289
|
+
: Array.isArray(entry.verification_flags) ? entry.verification_flags : [];
|
|
248
290
|
for (const flag of flags) {
|
|
249
291
|
const target = flag.severity === "error"
|
|
250
292
|
? errors
|
|
@@ -274,6 +316,59 @@ export function deriveLessons(loopId, context, currentRound) {
|
|
|
274
316
|
}
|
|
275
317
|
return lessons.sort((a, b) => b.count - a.count);
|
|
276
318
|
}
|
|
319
|
+
/** v3.3: Derive display-only round statistics over the last `window`
|
|
320
|
+
* committed rounds — files changed, rejected attempts (snapshot.attempt
|
|
321
|
+
* minus one: the successful commit's attempt counts the redo), and the
|
|
322
|
+
* self-reported progress delta vs the previous round. One row per round,
|
|
323
|
+
* preferring the :feedback entry when both lineage and feedback entries
|
|
324
|
+
* exist. Zero persistence — re-derived from committed entries. */
|
|
325
|
+
function deriveRoundStats(rounds, window = 5) {
|
|
326
|
+
const stats = [];
|
|
327
|
+
let prevProgress = null;
|
|
328
|
+
for (const round of rounds.slice(-window)) {
|
|
329
|
+
const ev = round.executionEvidence;
|
|
330
|
+
const files = ev?.files_changed;
|
|
331
|
+
const rawProgress = ev?.progress_estimate;
|
|
332
|
+
const progress = typeof rawProgress === "number" ? rawProgress : null;
|
|
333
|
+
stats.push({
|
|
334
|
+
round: round.round,
|
|
335
|
+
filesChangedCount: Array.isArray(files) ? files.length : null,
|
|
336
|
+
rejectedAttempts: round.attempt >= 1 ? round.attempt - 1 : null,
|
|
337
|
+
progressDelta: progress !== null && prevProgress !== null
|
|
338
|
+
? Number((progress - prevProgress).toFixed(3))
|
|
339
|
+
: null,
|
|
340
|
+
});
|
|
341
|
+
if (progress !== null)
|
|
342
|
+
prevProgress = progress;
|
|
343
|
+
}
|
|
344
|
+
return stats;
|
|
345
|
+
}
|
|
346
|
+
/** v3.3: Machine git-motion cross-check over the last 3 committed rounds
|
|
347
|
+
* (the R4 window). Undefined when no committed rounds carry git snapshots
|
|
348
|
+
* — the dashboard then shows no machine row. Display-only companion to the
|
|
349
|
+
* R4/R5 exculpatory signal. */
|
|
350
|
+
function deriveMachineStatus(rounds, currentRound) {
|
|
351
|
+
const series = machineGitMotionSeries(rounds, currentRound, 3);
|
|
352
|
+
if (series === null)
|
|
353
|
+
return undefined;
|
|
354
|
+
const motionRounds = series.filter(Boolean).length;
|
|
355
|
+
return { windowRounds: 3, gitMotion: motionRounds > 0, motionRounds };
|
|
356
|
+
}
|
|
357
|
+
/** v3.4: ACTIVE Round Contract for the compile round — derived from the
|
|
358
|
+
* committed evals of earlier rounds only, never from request data. Reads
|
|
359
|
+
* the hydrated merged lineage entries (one per round; a committed decision
|
|
360
|
+
* is merged onto the round's lineage entry with `committed_action` set),
|
|
361
|
+
* skipping rounds whose committed action was "backtrack" (a roll-back
|
|
362
|
+
* directive, not an executed round). Because it runs identically on every
|
|
363
|
+
* compile path — including rejection retries and resume / unpause /
|
|
364
|
+
* backtrack compiles that carry no last_round_result — a contract round's
|
|
365
|
+
* re-compiles keep showing its contract as the Current Task. Null when no
|
|
366
|
+
* active contract: the Current Task falls back to the original task text. */
|
|
367
|
+
function deriveActiveContract(loopId, context, currentRound) {
|
|
368
|
+
const entries = loopEntries(loopId, context);
|
|
369
|
+
const canonical = Array.from({ length: Math.max(0, currentRound - 1) }, (_, index) => roundCanonicalEntry(entries, index + 1)).filter((entry) => entry !== null);
|
|
370
|
+
return deriveActiveRoundContract(contractRoundEvaluations(mergedRoundsFromEntries(canonical, currentRound)));
|
|
371
|
+
}
|
|
277
372
|
/** v3.2: Derive per-criterion status — the "goal → criteria → evidence"
|
|
278
373
|
* vertical view. Each objective criterion gets: met/remaining/unknown
|
|
279
374
|
* (from per-round success_criteria_met/remaining reports, ID-first
|
|
@@ -323,8 +418,10 @@ export function deriveCriterionStatuses(loopId, context, objective, currentRound
|
|
|
323
418
|
}
|
|
324
419
|
/** v2.11: Match two criterion references for deduplication.
|
|
325
420
|
* If either is a criterion ID (cr-XXXXXXXX), uses exact ID comparison.
|
|
326
|
-
* Otherwise falls back to Jaccard similarity.
|
|
327
|
-
|
|
421
|
+
* Otherwise falls back to Jaccard similarity.
|
|
422
|
+
* v3.3: exported for the verification gate's windowed criteria-completion
|
|
423
|
+
* scan (R4/R5 exculpatory cross-check). */
|
|
424
|
+
export function criteriaMatch(a, b) {
|
|
328
425
|
const aIsId = isCriterionId(a);
|
|
329
426
|
const bIsId = isCriterionId(b);
|
|
330
427
|
// Both are IDs — exact string comparison
|
|
@@ -341,7 +438,7 @@ function criteriaMatch(a, b) {
|
|
|
341
438
|
/** Detect newly met criteria by comparing the current entry's
|
|
342
439
|
* success_criteria_met against the previous entry's.
|
|
343
440
|
* v2.11: ID-first matching (cr-XXXXXXXX) with Jaccard similarity
|
|
344
|
-
* fallback for
|
|
441
|
+
* fallback for natural-language references.
|
|
345
442
|
* Returns empty array when there is no previous entry — the first
|
|
346
443
|
* entry's criteria are the baseline, not a "new" event. */
|
|
347
444
|
function detectNewCriteria(current, previous) {
|
|
@@ -359,12 +456,18 @@ function detectNewCriteria(current, previous) {
|
|
|
359
456
|
}
|
|
360
457
|
/** Read constraints_active from an entry's lineage. */
|
|
361
458
|
function entryActiveConstraints(entry) {
|
|
459
|
+
const view = committedView(entry);
|
|
460
|
+
if (view)
|
|
461
|
+
return view.activeConstraints ?? [];
|
|
362
462
|
const lin = lineage(entry);
|
|
363
463
|
const arr = lin.constraints_active;
|
|
364
464
|
return Array.isArray(arr) ? arr.filter((v) => typeof v === "string") : [];
|
|
365
465
|
}
|
|
366
466
|
/** Read retracted_constraints from an entry. */
|
|
367
467
|
function entryRetractedConstraints(entry) {
|
|
468
|
+
const view = committedView(entry);
|
|
469
|
+
if (view)
|
|
470
|
+
return view.retractedConstraints ?? [];
|
|
368
471
|
const direct = entry.retracted_constraints;
|
|
369
472
|
if (Array.isArray(direct))
|
|
370
473
|
return direct.filter((v) => typeof v === "string");
|
|
@@ -385,9 +488,10 @@ function entryProgressEstimate(entry) {
|
|
|
385
488
|
/** Build a single MilestoneSummary from a range of completed round entries. */
|
|
386
489
|
function buildMilestoneFromEntries(phaseEntries, startRound, endRound, label, kind) {
|
|
387
490
|
const last = phaseEntries.at(-1);
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
491
|
+
const lastView = last ? committedView(last) : null;
|
|
492
|
+
const outcomeRaw = lastView
|
|
493
|
+
? (lastView.outputSummary ?? "")
|
|
494
|
+
: last && typeof last.output_summary === "string" ? last.output_summary : "";
|
|
391
495
|
const outcomeMax = getPolicy().checkpoint.outcome_max_chars;
|
|
392
496
|
const outcome = outcomeRaw.length > outcomeMax
|
|
393
497
|
? outcomeRaw.slice(0, outcomeMax) + "…"
|
|
@@ -468,24 +572,29 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
|
|
|
468
572
|
const outcomes = [];
|
|
469
573
|
const issues = [];
|
|
470
574
|
for (const entry of windowEntries) {
|
|
575
|
+
const view = committedView(entry);
|
|
471
576
|
const data = lineage(entry);
|
|
472
577
|
const round = entryRound(entry);
|
|
473
|
-
const success = entry.success ?? data.success;
|
|
474
|
-
const summary =
|
|
475
|
-
?
|
|
476
|
-
: typeof
|
|
578
|
+
const success = view ? view.success : entry.success ?? data.success;
|
|
579
|
+
const summary = view
|
|
580
|
+
? view.outputSummary ?? ""
|
|
581
|
+
: typeof entry.output_summary === "string"
|
|
582
|
+
? entry.output_summary
|
|
583
|
+
: typeof data.output_summary === "string" ? data.output_summary : "";
|
|
477
584
|
// v2.12: Declared outcome wins the label; the default wording stays
|
|
478
585
|
// "accepted" for zero regression on existing loop output.
|
|
479
|
-
const declared = entry.outcome;
|
|
586
|
+
const declared = view ? view.outcome : entry.outcome;
|
|
480
587
|
const outcomeLabel = declared === "success" || declared === "partial" ||
|
|
481
588
|
declared === "failed" || declared === "blocked"
|
|
482
589
|
? declared
|
|
483
590
|
: success === false ? "failed" : "accepted";
|
|
484
591
|
if (summary)
|
|
485
592
|
outcomes.push(`[R${round}] ${outcomeLabel}: ${summary}`);
|
|
486
|
-
const violations =
|
|
487
|
-
?
|
|
488
|
-
: Array.isArray(
|
|
593
|
+
const violations = view
|
|
594
|
+
? view.constraintViolations ?? []
|
|
595
|
+
: Array.isArray(entry.constraint_violations)
|
|
596
|
+
? entry.constraint_violations
|
|
597
|
+
: Array.isArray(data.constraint_violations) ? data.constraint_violations : [];
|
|
489
598
|
issues.push(...violations.filter((item) => typeof item === "string"));
|
|
490
599
|
}
|
|
491
600
|
// ── Phase 2: Milestone Accumulation ──
|
|
@@ -521,10 +630,17 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
|
|
|
521
630
|
lastMilestoneRound = rnd;
|
|
522
631
|
}
|
|
523
632
|
}
|
|
524
|
-
// Signal 3: Safety net — auto milestone when gap exceeds milestone_interval
|
|
633
|
+
// Signal 3: Safety net — auto milestone when gap exceeds milestone_interval.
|
|
634
|
+
// v3.3.1: the policy contract (milestone_interval: "only fires when no
|
|
635
|
+
// agent_declared or criteria_milestone has been created for this many
|
|
636
|
+
// rounds") includes loops that NEVER created a milestone — but the guard
|
|
637
|
+
// `lastMilestoneRound > 0` made the gap start from the (nonexistent) first
|
|
638
|
+
// milestone, so a checkpoint-less loop could run forever with an empty
|
|
639
|
+
// Phase History and no memory skeleton. With lastMilestoneRound == 0 the
|
|
640
|
+
// gap is measured from round 1 and the safety net fires from the very
|
|
641
|
+
// first interval.
|
|
525
642
|
const gapSinceLast = (currentRound - 1) - lastMilestoneRound;
|
|
526
|
-
if (
|
|
527
|
-
gapSinceLast >= policy.milestone_interval &&
|
|
643
|
+
if (gapSinceLast >= policy.milestone_interval &&
|
|
528
644
|
completedEntries.length > 0) {
|
|
529
645
|
const startRound = lastMilestoneRound + 1;
|
|
530
646
|
const endRound = currentRound - 1;
|
|
@@ -540,23 +656,6 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
|
|
|
540
656
|
// L2 (full rehydration) keeps every milestone — the recovery view is the
|
|
541
657
|
// loop's memory skeleton and must not be truncated.
|
|
542
658
|
const cappedMilestones = sampleMilestones(milestones, policy, level);
|
|
543
|
-
// ── Phase 3: Loop Synthesis (formulaic) ──
|
|
544
|
-
let loopSynthesis = "";
|
|
545
|
-
if (policy.enable_loop_synthesis && currentRound > 1) {
|
|
546
|
-
const totalRounds = currentRound - 1;
|
|
547
|
-
const phaseCount = cappedMilestones.length;
|
|
548
|
-
const lastProgress = completedEntries.length > 0
|
|
549
|
-
? entryProgressEstimate(completedEntries[completedEntries.length - 1])
|
|
550
|
-
: 0;
|
|
551
|
-
const activeCount = completedEntries.length > 0
|
|
552
|
-
? entryActiveConstraints(completedEntries[completedEntries.length - 1]).length
|
|
553
|
-
: 0;
|
|
554
|
-
const totalResolved = cappedMilestones.reduce((sum, m) => sum + m.resolved_constraints.length, 0);
|
|
555
|
-
loopSynthesis =
|
|
556
|
-
`Loop spans ${totalRounds} rounds across ${phaseCount} phases. ` +
|
|
557
|
-
`Overall progress: ${(lastProgress * 100).toFixed(0)}%. ` +
|
|
558
|
-
`${activeCount} active constraints, ${totalResolved} resolved across all phases.`;
|
|
559
|
-
}
|
|
560
659
|
if (windowEntries.length === 0 && cappedMilestones.length === 0)
|
|
561
660
|
return null;
|
|
562
661
|
return makeRollingSummary({
|
|
@@ -566,7 +665,6 @@ export function buildRollingSummary(loopId, currentRound, context, sinceRound =
|
|
|
566
665
|
generated_at_round: currentRound,
|
|
567
666
|
failed_patterns: [],
|
|
568
667
|
milestones: cappedMilestones,
|
|
569
|
-
loop_synthesis: loopSynthesis || undefined,
|
|
570
668
|
});
|
|
571
669
|
}
|
|
572
670
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -599,6 +697,9 @@ export function isCriterionId(ref) {
|
|
|
599
697
|
}
|
|
600
698
|
/** Read emerged_subtasks from a vault entry (handles direct + lineage nesting). */
|
|
601
699
|
function entryEmergedSubtasks(entry) {
|
|
700
|
+
const view = committedView(entry);
|
|
701
|
+
if (view)
|
|
702
|
+
return view.emergedSubtasks ?? [];
|
|
602
703
|
const direct = entry.emerged_subtasks;
|
|
603
704
|
if (Array.isArray(direct))
|
|
604
705
|
return direct.filter((v) => typeof v === "string");
|
|
@@ -610,6 +711,9 @@ function entryEmergedSubtasks(entry) {
|
|
|
610
711
|
}
|
|
611
712
|
/** Read completed_subtasks from an entry. */
|
|
612
713
|
function entryCompletedSubtasks(entry) {
|
|
714
|
+
const view = committedView(entry);
|
|
715
|
+
if (view)
|
|
716
|
+
return view.completedSubtasks ?? [];
|
|
613
717
|
const direct = entry.completed_subtasks;
|
|
614
718
|
if (Array.isArray(direct))
|
|
615
719
|
return direct.filter((v) => typeof v === "string");
|
|
@@ -617,6 +721,9 @@ function entryCompletedSubtasks(entry) {
|
|
|
617
721
|
}
|
|
618
722
|
/** Read blocked_subtasks from an entry. */
|
|
619
723
|
function entryBlockedSubtasks(entry) {
|
|
724
|
+
const view = committedView(entry);
|
|
725
|
+
if (view)
|
|
726
|
+
return view.blockedSubtasks ?? [];
|
|
620
727
|
const direct = entry.blocked_subtasks;
|
|
621
728
|
if (Array.isArray(direct))
|
|
622
729
|
return direct.filter((v) => typeof v === "string");
|
|
@@ -624,6 +731,9 @@ function entryBlockedSubtasks(entry) {
|
|
|
624
731
|
}
|
|
625
732
|
/** Read canceled_subtasks from an entry. */
|
|
626
733
|
function entryCanceledSubtasks(entry) {
|
|
734
|
+
const view = committedView(entry);
|
|
735
|
+
if (view)
|
|
736
|
+
return view.canceledSubtasks ?? [];
|
|
627
737
|
const direct = entry.canceled_subtasks;
|
|
628
738
|
if (Array.isArray(direct))
|
|
629
739
|
return direct.filter((v) => typeof v === "string");
|
|
@@ -816,9 +926,12 @@ function findDiscoveredRound(text, vaultEntries) {
|
|
|
816
926
|
const rnd = entryRound(entry);
|
|
817
927
|
if (rnd < 1)
|
|
818
928
|
continue;
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
929
|
+
const view = committedView(entry);
|
|
930
|
+
const discovered = view
|
|
931
|
+
? view.discoveredConstraints ?? []
|
|
932
|
+
: Array.isArray(entry.discovered_constraints)
|
|
933
|
+
? entry.discovered_constraints.filter((v) => typeof v === "string")
|
|
934
|
+
: [];
|
|
822
935
|
for (const d of discovered) {
|
|
823
936
|
// Phase 1: exact ID match (v2.11)
|
|
824
937
|
if (d === targetId || deriveConstraintId(d) === targetId) {
|
|
@@ -826,7 +939,7 @@ function findDiscoveredRound(text, vaultEntries) {
|
|
|
826
939
|
earliest = rnd;
|
|
827
940
|
break;
|
|
828
941
|
}
|
|
829
|
-
// Phase 2: Jaccard fallback
|
|
942
|
+
// Phase 2: Jaccard fallback for natural-language references.
|
|
830
943
|
if (jaccardSimilarity(text, d) >= getPolicy().evolution.constraint_match_threshold) {
|
|
831
944
|
if (earliest === 0 || rnd < earliest)
|
|
832
945
|
earliest = rnd;
|
|
@@ -856,9 +969,12 @@ function findLastViolatedRound(text, vaultEntries) {
|
|
|
856
969
|
const rnd = entryRound(entry);
|
|
857
970
|
if (rnd < 1)
|
|
858
971
|
continue;
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
972
|
+
const view = committedView(entry);
|
|
973
|
+
const violations = view
|
|
974
|
+
? view.constraintViolations ?? []
|
|
975
|
+
: Array.isArray(entry.constraint_violations)
|
|
976
|
+
? entry.constraint_violations.filter((v) => typeof v === "string")
|
|
977
|
+
: [];
|
|
862
978
|
for (const v of violations) {
|
|
863
979
|
if (matchesConstraintText(text, v)) {
|
|
864
980
|
if (rnd > latest)
|
|
@@ -893,8 +1009,15 @@ function manageConstraintLifecycle(active, objective, constraintsFromPlan, vault
|
|
|
893
1009
|
const violatedThisRound = lastRoundViolations.some((v) => matchesConstraintText(text, v));
|
|
894
1010
|
// Structural constraints never decay
|
|
895
1011
|
if (source === "hard" || source === "plan" || source === "criteria") {
|
|
1012
|
+
// v3.3.1: one text, one identity — a success-criterion text that also
|
|
1013
|
+
// lives in the merged active set belongs to the cr-XXXXXXXX namespace
|
|
1014
|
+
// (its own Success Criteria section and the verification gate's
|
|
1015
|
+
// criteriaMatch are ID-first on cr-). Rendering it under c-XXXXXXXX in
|
|
1016
|
+
// the active list meant the same text carried two IDs in one prompt,
|
|
1017
|
+
// and an agent echoing the active-list ID into success_criteria_met
|
|
1018
|
+
// could never match.
|
|
896
1019
|
metadata.push(makeConstraintMeta({
|
|
897
|
-
id: deriveConstraintId(text),
|
|
1020
|
+
id: source === "criteria" ? deriveCriterionId(text) : deriveConstraintId(text),
|
|
898
1021
|
text,
|
|
899
1022
|
discovered_at_round: objective.created_at_round || 1,
|
|
900
1023
|
last_violated_at_round: violatedThisRound
|
|
@@ -1011,11 +1134,24 @@ export function buildSelfEvalBlock(round, prevDriftFlags,
|
|
|
1011
1134
|
/** v2.12: L0 is the minimal retry template — the v2.12 declarative fields
|
|
1012
1135
|
* (outcome/blocker/retroactiveClaims) are L1/L2 additions so the retry
|
|
1013
1136
|
* prompt stays within its tight budget. */
|
|
1014
|
-
level
|
|
1137
|
+
level,
|
|
1138
|
+
/** v3.3/v3.4: Whether this round's Current Task IS the ACTIVE Round
|
|
1139
|
+
* Contract (derived from committed rounds — compileLoop passes
|
|
1140
|
+
* `activeContract != null`). Only then does the template ask the agent
|
|
1141
|
+
* to restate/propose it — a generic empty contract template would invite
|
|
1142
|
+
* placeholder submissions that trigger round_underspecified noise. */
|
|
1143
|
+
hasContract = false,
|
|
1144
|
+
/** v3.5: L2-only prose suggesting a Round Contract declaration when the
|
|
1145
|
+
* Current Task is NOT one (contract_nudge_on_l2 policy, computed at the
|
|
1146
|
+
* compileLoop call site). Mutually exclusive with hasContract. The prose
|
|
1147
|
+
* must never contain the JSON key name `round_contract` — contract-less
|
|
1148
|
+
* L2 tests assert its lowercase absence. */
|
|
1149
|
+
proposalNudge = false) {
|
|
1015
1150
|
const hasIntentDrift = prevDriftFlags?.some(f => f.check === "intent_drift") ?? false;
|
|
1016
1151
|
const hasSubGoalDrift = prevDriftFlags?.some(f => f.check === "subgoal_drift") ?? false;
|
|
1017
1152
|
const needsClarification = hasIntentDrift || hasSubGoalDrift;
|
|
1018
1153
|
const declareOutcome = level !== "l0";
|
|
1154
|
+
const restateContract = hasContract && declareOutcome;
|
|
1019
1155
|
const evalObj = {
|
|
1020
1156
|
success: false,
|
|
1021
1157
|
output_summary: `<verified result of round ${round}>`,
|
|
@@ -1042,6 +1178,20 @@ level) {
|
|
|
1042
1178
|
evalObj.blocker = "<required only when outcome=blocked>";
|
|
1043
1179
|
evalObj.retroactiveClaims = [];
|
|
1044
1180
|
}
|
|
1181
|
+
// v3.3/v3.4: Round Contract proposal template (L1/L2, active-contract
|
|
1182
|
+
// rounds only — L0 retry stays lean, and rounds without an active
|
|
1183
|
+
// contract never see the template). When the prompt's Current Task IS the
|
|
1184
|
+
// ACTIVE contract, the agent restates it unchanged as its proposal; when
|
|
1185
|
+
// the active contract's done_when are all satisfied (or the round is
|
|
1186
|
+
// blocked), a NEW contract is declared for the next round instead.
|
|
1187
|
+
if (restateContract) {
|
|
1188
|
+
evalObj.round_contract = {
|
|
1189
|
+
work_item: "<this round's focus, or empty>",
|
|
1190
|
+
done_when: [],
|
|
1191
|
+
verification_plan: [],
|
|
1192
|
+
scope: [],
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1045
1195
|
// v2.8: Inject drift clarification field when previous round had drift flags
|
|
1046
1196
|
if (needsClarification) {
|
|
1047
1197
|
const flagTypes = [
|
|
@@ -1057,19 +1207,27 @@ level) {
|
|
|
1057
1207
|
"### LoopForge Evaluation (Required)",
|
|
1058
1208
|
"",
|
|
1059
1209
|
`After completing Round ${round}, replace the placeholder values below`,
|
|
1060
|
-
"with your actual results and
|
|
1210
|
+
"with your actual results and pass this object as `loopforge_next.evaluation`.",
|
|
1061
1211
|
];
|
|
1062
1212
|
if (needsClarification) {
|
|
1063
1213
|
lines.push("", "> ⚠️ **Drift Detected:** The verification gate detected that your previous", "> round's declared intent (next_action) did not match your actual output.", "> Please explain the pivot in the `drift_clarification` field below.", "> An honest explanation helps the enforcement gate distinguish intentional", "> course corrections from unacknowledged drift.");
|
|
1064
1214
|
}
|
|
1065
|
-
lines.push("
|
|
1215
|
+
lines.push("```json");
|
|
1066
1216
|
lines.push(JSON.stringify(evalObj, null, 2));
|
|
1067
|
-
lines.push("
|
|
1217
|
+
lines.push("```");
|
|
1068
1218
|
lines.push("");
|
|
1069
1219
|
lines.push("IMPORTANT: Replace every <placeholder> with your actual data.");
|
|
1070
1220
|
lines.push("Set success=true only when the full goal and ALL hard constraints are verified.");
|
|
1071
1221
|
lines.push("Use IDs for exact matching: c-XXXXXXXX, cr-XXXXXXXX, sg-XXXXXXXX.");
|
|
1072
1222
|
lines.push("For subtasks, use sub-goal IDs from the Sub-Goal Dashboard above.");
|
|
1223
|
+
// v3.5: L2 contract-less nudge (prose only — never the JSON key name, so
|
|
1224
|
+
// contract-less rounds keep their template-lean assertions).
|
|
1225
|
+
if (proposalNudge) {
|
|
1226
|
+
lines.push("", "If the remaining work will span several rounds, consider declaring a", "Round Contract for the next round — list its done_when items, the", "verification_plan commands that will back them, and the scope. This", "keeps each round's boundary machine-checkable.");
|
|
1227
|
+
}
|
|
1228
|
+
if (restateContract) {
|
|
1229
|
+
lines.push("Round Contract: restate the Current Task's contract UNCHANGED in", "`round_contract` (work_item/done_when/verification_plan/scope) and run", "its Verify commands; list satisfied done_when items in", "`success_criteria_met`. When the current contract's done_when items are", "all satisfied, or the round is blocked (outcome=blocked + blocker),", "declare the NEXT round's contract instead (or omit `round_contract`", "when the whole task is done — the Current Task reverts to the", "original task).");
|
|
1230
|
+
}
|
|
1073
1231
|
return lines.join("\n");
|
|
1074
1232
|
}
|
|
1075
1233
|
// v2.5: checkpoint() removed — agent_declared milestones from
|
|
@@ -1104,15 +1262,22 @@ export function compileLoop(request, context) {
|
|
|
1104
1262
|
// No new persistence; reconstructed same as milestones/sub-goals.
|
|
1105
1263
|
const completedEntries = loopEntries(request.loop_id, context)
|
|
1106
1264
|
.filter((e) => entryRound(e) >= 1 && entryRound(e) < request.round);
|
|
1265
|
+
const committedRounds = mergedRoundsFromEntries(completedEntries, request.round);
|
|
1107
1266
|
const trend = [];
|
|
1108
|
-
for (const
|
|
1109
|
-
const viols =
|
|
1110
|
-
? entry.constraint_violations.filter((v) => typeof v === "string")
|
|
1111
|
-
: [];
|
|
1267
|
+
for (const round of committedRounds.slice(-10)) {
|
|
1268
|
+
const viols = round.evaluation?.constraint_violations ?? [];
|
|
1112
1269
|
// Simple proxy: no violations = perfect trust, each violation = -0.1
|
|
1113
1270
|
const score = Math.max(0, 1.0 - viols.length * 0.1);
|
|
1114
1271
|
trend.push(Number(score.toFixed(2)));
|
|
1115
1272
|
}
|
|
1273
|
+
// v3.3: Round stats + machine git-motion status (display-only, derived).
|
|
1274
|
+
const roundStats = deriveRoundStats(committedRounds);
|
|
1275
|
+
const machineStatus = deriveMachineStatus(committedRounds, request.round);
|
|
1276
|
+
// v3.4: ACTIVE Round Contract for this compile — derived from committed
|
|
1277
|
+
// rounds, so rejection retries / resume / unpause / backtrack compiles
|
|
1278
|
+
// (no last_round_result) keep showing the contract as Current Task, and a
|
|
1279
|
+
// satisfied or blocked contract stops being shown.
|
|
1280
|
+
const activeContract = deriveActiveContract(request.loop_id, context, request.round);
|
|
1116
1281
|
const response = makeLoopCompileResponse({
|
|
1117
1282
|
status: AgentStatus.OK,
|
|
1118
1283
|
recompile_level: decision.level,
|
|
@@ -1142,7 +1307,12 @@ export function compileLoop(request, context) {
|
|
|
1142
1307
|
});
|
|
1143
1308
|
const policy = getPolicy();
|
|
1144
1309
|
const statePath = `${policy.state_file.directory}/${request.loop_id}-state.md`;
|
|
1145
|
-
const state = createCanonicalLoopState(request, response, statePath
|
|
1310
|
+
const state = createCanonicalLoopState(request, response, statePath, {
|
|
1311
|
+
roundStats,
|
|
1312
|
+
machineStatus,
|
|
1313
|
+
// v3.4: derived active contract — the Current Task's source of truth.
|
|
1314
|
+
roundContract: activeContract,
|
|
1315
|
+
});
|
|
1146
1316
|
const markdown = renderCanonicalStateMarkdown(state);
|
|
1147
1317
|
// v2.4–v2.5: Adaptive L2 budget — scales with loop complexity
|
|
1148
1318
|
const adaptiveL2 = policy.prompt.l2_adaptive_enabled
|
|
@@ -1166,7 +1336,15 @@ export function compileLoop(request, context) {
|
|
|
1166
1336
|
l2: adaptiveL2,
|
|
1167
1337
|
},
|
|
1168
1338
|
attempt: request.attempt,
|
|
1169
|
-
selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags, decision.level
|
|
1339
|
+
selfEvaluationBlock: buildSelfEvalBlock(request.round, request.verification_flags, decision.level,
|
|
1340
|
+
// v3.4: restate the contract only when this prompt's Current Task IS
|
|
1341
|
+
// the ACTIVE contract (derived — not the previous submission's field,
|
|
1342
|
+
// which is a proposal and may differ from what this round executes).
|
|
1343
|
+
activeContract != null,
|
|
1344
|
+
// v3.5: L2-only declaration nudge when nothing is active (policy-gated;
|
|
1345
|
+
// mutually exclusive with the restate template above).
|
|
1346
|
+
decision.level === "l2" && activeContract === null &&
|
|
1347
|
+
getPolicy().prompt.contract_nudge_on_l2),
|
|
1170
1348
|
fullStateMarkdown: l2Pointer ? undefined : markdown,
|
|
1171
1349
|
// v2.9: Model's information needs from the previous round's SelfEvaluation
|
|
1172
1350
|
promptRequests: request.last_round_result?.prompt_requests,
|