@llm4ts/flow 2.49.0 → 2.51.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/dist/FlowEvents.d.ts +2 -0
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +3 -1
- package/dist/FlowEvents.js.map +1 -1
- package/dist/PlanExecution.d.ts +7 -0
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/PlanExecution.js +14 -0
- package/dist/PlanExecution.js.map +1 -1
- package/dist/Recheck.d.ts +69 -0
- package/dist/Recheck.d.ts.map +1 -0
- package/dist/Recheck.js +111 -0
- package/dist/Recheck.js.map +1 -0
- package/dist/Review.d.ts +32 -33
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +104 -45
- package/dist/Review.js.map +1 -1
- package/dist/ReviewCache.d.ts +1 -1
- package/dist/ReviewCache.d.ts.map +1 -1
- package/dist/ReviewCache.js +1 -1
- package/dist/ReviewCache.js.map +1 -1
- package/dist/ReviewModel.d.ts +36 -0
- package/dist/ReviewModel.d.ts.map +1 -0
- package/dist/ReviewModel.js +50 -0
- package/dist/ReviewModel.js.map +1 -0
- package/dist/Stories.d.ts +13 -2
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +239 -89
- package/dist/Stories.js.map +1 -1
- package/package.json +4 -2
- package/src/FlowEvents.ts +3 -1
- package/src/PlanExecution.ts +21 -0
- package/src/Recheck.ts +160 -0
- package/src/Review.ts +178 -58
- package/src/ReviewCache.ts +1 -1
- package/src/ReviewModel.ts +63 -0
- package/src/Stories.ts +233 -17
package/dist/Stories.js
CHANGED
|
@@ -16,7 +16,7 @@ import * as Stream from "effect/Stream";
|
|
|
16
16
|
import { cap } from "./Context.js";
|
|
17
17
|
import { BoardItem, eventedBoard } from "./BoardSync.js";
|
|
18
18
|
import { makeChat } from "./Chat.js";
|
|
19
|
-
import { implementPlanFlow } from "./Flow.js";
|
|
19
|
+
import { flowReviewer, implementPlanFlow } from "./Flow.js";
|
|
20
20
|
import { GateBaseline, baselineKey, ensureBaseline as ensureStoredBaseline, failingLinesOf, storyGateLogDir, mergeGateLogDir, writeBaseline } from "./Gates.js";
|
|
21
21
|
import { describeFlowError, rosterExhaustedPrefix, EpicCheckoutDirty, MissingDependency, PerimeterViolation, StoryFailed } from "./FlowError.js";
|
|
22
22
|
import { Began, EvidenceChecked, Info, JudgedDimension, StoryJudged, Timed, withLane } from "./FlowEvents.js";
|
|
@@ -27,8 +27,9 @@ import { loadVersioned, makePlanStore, saveVersioned } from "./Persistence.js";
|
|
|
27
27
|
import { Plan, Task } from "./Plan.js";
|
|
28
28
|
import { stage } from "./PlanExecution.js";
|
|
29
29
|
import { planFrom } from "./Planner.js";
|
|
30
|
-
import { ReviewIssue, ReviewResult, applyTriage, isBlocking, loadRepoReviewRules, nonBlockingIssues } from "./Review.js";
|
|
30
|
+
import { ReviewIssue, ReviewResult, applyTriage, isBlocking, loadRepoReviewRules, minimalReviewers, reviewAndFixLoop, nonBlockingIssues } from "./Review.js";
|
|
31
31
|
import { checkOracle, defaultOracleRules, parseUnifiedDiff } from "./OracleGuard.js";
|
|
32
|
+
import { ReviewMemory } from "./Recheck.js";
|
|
32
33
|
import { cachedValue, fingerprintOf } from "./ReviewCache.js";
|
|
33
34
|
import { withContract } from "./AutonomyContract.js";
|
|
34
35
|
import { fabricatedStatusIssues, unverifiedClaims } from "./Evidence.js";
|
|
@@ -451,6 +452,8 @@ const combined = (first, second) => first.isClean && second.isClean
|
|
|
451
452
|
* nothing was deferred.
|
|
452
453
|
*/
|
|
453
454
|
export const deferredPath = (stateDir, storyId) => join(stateDir, `stories/${storyId}.deferred.md`);
|
|
455
|
+
/** Where a story review keeps its reviewers' memory between rounds and runs (ADR 0044). */
|
|
456
|
+
export const reviewStatePath = (stateDir, storyId) => join(stateDir, `stories/${storyId}.review-state.json`);
|
|
454
457
|
/** A deferred finding as one Markdown bullet, prefixed with where it was found. */
|
|
455
458
|
export const deferredLine = (source, issue) => {
|
|
456
459
|
const where = issue.file === undefined
|
|
@@ -513,8 +516,11 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
513
516
|
const concurrency = Math.max(1, options.concurrency ?? 3);
|
|
514
517
|
const judgeRounds = Math.max(1, options.judgeRounds ?? 2);
|
|
515
518
|
const mergeRevisions = Math.max(0, options.mergeRevisions ?? 1);
|
|
516
|
-
|
|
519
|
+
// Parallel tasks are opt-in (ADR 0034 amendment): one coder per story unless asked.
|
|
520
|
+
const taskConcurrency = Math.max(1, options.taskConcurrency ?? 1);
|
|
517
521
|
const deferring = options.deferNonBlocking === true;
|
|
522
|
+
const reviewAtStory = options.reviewAt === "story";
|
|
523
|
+
const reviewRounds = Math.max(1, options.reviewRounds ?? 3);
|
|
518
524
|
const statePath = (story) => join(options.stateDir, `stories/${story.id}.json`);
|
|
519
525
|
const planPath = (story) => join(options.stateDir, `stories/${story.id}.plan.md`);
|
|
520
526
|
/** The judge's last verdict beside a fingerprint of what it judged. */
|
|
@@ -1154,7 +1160,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1154
1160
|
// A lens's answer is kept beside the task and diff it answered, so a
|
|
1155
1161
|
// rerun over the same change asks nothing; every round is written to
|
|
1156
1162
|
// the story's findings log for people to read.
|
|
1157
|
-
|
|
1163
|
+
// Reviewing per story (ADR 0044), a task runs on its gates alone.
|
|
1164
|
+
...(reviewAtStory
|
|
1165
|
+
? { reviewers: [] }
|
|
1166
|
+
: { reviewCache: { files, dir: reviewCacheDir(story) } }),
|
|
1158
1167
|
onReview: (task, round, result, settled) => Effect.gen(function* () {
|
|
1159
1168
|
// A settled round's leftovers are deferred, not dropped: when the
|
|
1160
1169
|
// rounds ran out, blocking ones too, since the task commits anyway.
|
|
@@ -1167,15 +1176,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1167
1176
|
onTaskReply: (task, _reply, trailer, startedAt) => checkEvidence(task, trailer, startedAt),
|
|
1168
1177
|
// The repository's own review rules ride along as one extra lens
|
|
1169
1178
|
// (ADR 0027 decision 9), read from the epic checkout.
|
|
1170
|
-
repoRules: loadRepoReviewRules(files, context.workDir),
|
|
1179
|
+
...(reviewAtStory ? {} : { repoRules: loadRepoReviewRules(files, context.workDir) }),
|
|
1171
1180
|
...(options.votes === undefined ? {} : { votes: options.votes }),
|
|
1172
1181
|
...(options.fixer === undefined ? {} : { fixer: options.fixer }),
|
|
1173
1182
|
...(options.stall === undefined ? {} : { stall: options.stall }),
|
|
1174
|
-
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
1183
|
+
...(options.reviewers === undefined || reviewAtStory ? {} : { reviewers: options.reviewers }),
|
|
1175
1184
|
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds }),
|
|
1176
1185
|
...(deferring ? { settle: "blocking" } : {})
|
|
1177
1186
|
});
|
|
1178
1187
|
if (mergeFailure !== undefined) {
|
|
1188
|
+
// The tasks run again: what the story review remembered no longer holds.
|
|
1189
|
+
yield* files.remove(reviewStatePath(options.stateDir, story.id));
|
|
1179
1190
|
// The branch already caught up with the epic above: the gates in the
|
|
1180
1191
|
// worktree now see the combination that was red after the merge.
|
|
1181
1192
|
yield* appendRevision("make the epic gates green after merging", [
|
|
@@ -1210,94 +1221,230 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1210
1221
|
const addRevision = (verdict, empty) => appendRevision("close the judge's findings", empty
|
|
1211
1222
|
? `The story "${story.title}" has no changes yet, and what it must provide is not all in place on the epic branch.`
|
|
1212
1223
|
: `The story "${story.title}" was judged short of done.`, verdict);
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1224
|
+
/**
|
|
1225
|
+
* One judge round over the story's branch: the verdict, published on the
|
|
1226
|
+
* lane, logged in the findings and kept beside a fingerprint of what was
|
|
1227
|
+
* judged. `empty` says the branch had no changes, so its owned code was
|
|
1228
|
+
* judged instead.
|
|
1229
|
+
*/
|
|
1230
|
+
const judgeOnce = (judge, round) => Effect.gen(function* () {
|
|
1231
|
+
const diff = yield* git.diffVsBase(epicBranch);
|
|
1232
|
+
// No changes is not a failure by itself: the story's code may already
|
|
1233
|
+
// be on the epic (an earlier story or run put it there). Judge that
|
|
1234
|
+
// code instead: the owned paths against the empty tree.
|
|
1235
|
+
const empty = diff.trim().length === 0;
|
|
1236
|
+
const judgedCode = empty ? yield* git.diffVsBaseScoped(emptyTree, story.owned, false) : diff;
|
|
1237
|
+
// What the coder claimed and could not show travels with the subject
|
|
1238
|
+
// (ADR 0027 decision 6): the judge weighs the code knowing the
|
|
1239
|
+
// story's own account of its testing is not evidence.
|
|
1240
|
+
const subject = evidenceNotes.length === 0
|
|
1241
|
+
? judgedCode
|
|
1242
|
+
: [
|
|
1243
|
+
"Evidence notes from the run (not part of the diff):",
|
|
1244
|
+
...evidenceNotes.map((note) => `- ${note}`),
|
|
1245
|
+
"",
|
|
1246
|
+
judgedCode
|
|
1247
|
+
].join("\n");
|
|
1248
|
+
// The verdict is kept beside a fingerprint of what was judged: a
|
|
1249
|
+
// rerun that finds the same diff gets the same answer without a
|
|
1250
|
+
// model call, and the last verdict is on disk to read. Only the
|
|
1251
|
+
// first round reuses it; a later round follows a revision and asks.
|
|
1252
|
+
// The round is an EVALUATOR span (ADR 0026): the verdict's scores and
|
|
1253
|
+
// findings land on it, so a trace shows why a story was sent back.
|
|
1254
|
+
const judged = yield* withKindSpan(`story ${story.id}: judge ${round}`, { kind: "EVALUATOR" }, Effect.gen(function* () {
|
|
1255
|
+
const outcome = empty && subject.trim().length === 0
|
|
1256
|
+
? // Nothing to judge is decided here, not asked of a model.
|
|
1257
|
+
{
|
|
1258
|
+
value: ReviewResult.make({
|
|
1259
|
+
issues: [
|
|
1260
|
+
ReviewIssue.make({
|
|
1261
|
+
severity: "Critical",
|
|
1262
|
+
title: `nothing exists yet under ${story.owned.join(", ")}`,
|
|
1263
|
+
description: "The story's owned paths are empty on the epic branch."
|
|
1264
|
+
})
|
|
1265
|
+
],
|
|
1266
|
+
summary: `judge:${story.id}`
|
|
1267
|
+
}),
|
|
1268
|
+
reused: false
|
|
1269
|
+
}
|
|
1270
|
+
: yield* cachedValue(files, judgePath(story), Verdict, fingerprintOf([
|
|
1271
|
+
empty ? "code" : "diff",
|
|
1272
|
+
subject,
|
|
1273
|
+
story.id,
|
|
1274
|
+
story.title,
|
|
1275
|
+
story.description,
|
|
1276
|
+
story.provides.join("\n")
|
|
1277
|
+
]), withTimedRole("judge", judge(story, subject, watchedSeats, empty ? "code" : "diff")), { reuse: round === 1 });
|
|
1278
|
+
const scored = outcome.value instanceof StoryVerdict ? outcome.value.dimensions : [];
|
|
1279
|
+
yield* Effect.annotateCurrentSpan({
|
|
1280
|
+
"llm4ts.judge.cleared": outcome.value.isClean,
|
|
1281
|
+
"llm4ts.judge.reused": outcome.reused,
|
|
1282
|
+
...Object.fromEntries(scored.map((d) => [`llm4ts.judge.${d.id}`, d.score]))
|
|
1283
|
+
});
|
|
1284
|
+
const now = yield* Clock.currentTimeNanos;
|
|
1285
|
+
yield* Effect.option(Effect.currentSpan).pipe(Effect.map((span) => {
|
|
1286
|
+
if (span._tag === "Some") {
|
|
1287
|
+
for (const issue of outcome.value.issues) {
|
|
1288
|
+
span.value.event("judge finding", now, {
|
|
1289
|
+
title: issue.title,
|
|
1290
|
+
severity: issue.severity
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}));
|
|
1295
|
+
return outcome;
|
|
1296
|
+
}));
|
|
1297
|
+
const verdict = judged.value;
|
|
1298
|
+
if (judged.reused) {
|
|
1299
|
+
yield* laneOf(story).publish(Info.make({
|
|
1300
|
+
lane: story.id,
|
|
1301
|
+
message: `judge round ${round}: reused the verdict for an unchanged ${empty ? "code" : "diff"}`
|
|
1302
|
+
}));
|
|
1303
|
+
}
|
|
1304
|
+
yield* appendFindings(story, `judge round ${round} — ${verdict.isClean ? "cleared" : "not cleared"}${verdict instanceof StoryVerdict
|
|
1305
|
+
? ` (${verdict.dimensions.map((d) => `${d.id} ${d.score}/${d.max}`).join(", ")})`
|
|
1306
|
+
: ""}${judged.reused ? ", reused" : ""}`, verdict);
|
|
1307
|
+
yield* laneOf(story).publish(StoryJudged.make({
|
|
1308
|
+
lane: story.id,
|
|
1309
|
+
round,
|
|
1310
|
+
cleared: verdict.isClean,
|
|
1311
|
+
issues: verdict.issues.length,
|
|
1312
|
+
dimensions: verdict instanceof StoryVerdict ? verdict.dimensions : []
|
|
1313
|
+
}));
|
|
1314
|
+
return { verdict, empty };
|
|
1315
|
+
});
|
|
1316
|
+
/**
|
|
1317
|
+
* The story review (ADR 0044): every lens and the judge on the story's
|
|
1318
|
+
* diff, one fix turn per round, re-checks after the first. Returns the
|
|
1319
|
+
* judge's note; fails the story when the judge is still not cleared.
|
|
1320
|
+
*/
|
|
1321
|
+
const reviewStory = () => Effect.gen(function* () {
|
|
1322
|
+
const statePath = reviewStatePath(options.stateDir, story.id);
|
|
1323
|
+
const repoRules = yield* loadRepoReviewRules(files, context.workDir);
|
|
1324
|
+
const lenses = [
|
|
1325
|
+
...(options.reviewers ?? minimalReviewers),
|
|
1326
|
+
...(repoRules === undefined ? [] : [repoRules])
|
|
1327
|
+
];
|
|
1216
1328
|
const judge = options.judge;
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
const judgedCode = empty ? yield* git.diffVsBaseScoped(emptyTree, story.owned, false) : diff;
|
|
1224
|
-
// What the coder claimed and could not show travels with the subject
|
|
1225
|
-
// (ADR 0027 decision 6): the judge weighs the code knowing the
|
|
1226
|
-
// story's own account of its testing is not evidence.
|
|
1227
|
-
const subject = evidenceNotes.length === 0
|
|
1228
|
-
? judgedCode
|
|
1329
|
+
const latest = yield* Ref.make(undefined);
|
|
1330
|
+
const lastStanding = yield* Ref.make(undefined);
|
|
1331
|
+
// The fix turn is a fresh chat: it gets the story and where each finding points.
|
|
1332
|
+
const storyFixPrompt = (prompt, standing) => {
|
|
1333
|
+
const located = (standing?.issues ?? []).flatMap((issue) => issue.file === undefined
|
|
1334
|
+
? []
|
|
1229
1335
|
: [
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1336
|
+
`- ${issue.title} (${issue.lens ?? "gates"}): ${issue.file}${issue.line === undefined ? "" : `:${issue.line}`}`
|
|
1337
|
+
]);
|
|
1338
|
+
return [
|
|
1339
|
+
`Story ${story.id}: ${story.title}`,
|
|
1340
|
+
story.description,
|
|
1341
|
+
...(story.acceptance.length === 0
|
|
1342
|
+
? []
|
|
1343
|
+
: ["Done when:", ...story.acceptance.map((line) => `- ${line}`)]),
|
|
1344
|
+
"",
|
|
1345
|
+
prompt,
|
|
1346
|
+
...(located.length === 0 ? [] : ["", "Where the findings point:", ...located])
|
|
1347
|
+
].join("\n");
|
|
1348
|
+
};
|
|
1349
|
+
const judgeRuns = yield* Ref.make(0);
|
|
1350
|
+
const lastRound = yield* Ref.make(0);
|
|
1351
|
+
// A judge that scores `provides` at 0 is owed a piece no fix turn writes.
|
|
1352
|
+
const missingPiece = (verdict) => verdict instanceof StoryVerdict &&
|
|
1353
|
+
verdict.dimensions.some((d) => d.id === "provides" && d.score <= 0);
|
|
1354
|
+
const result = yield* reviewAndFixLoop({
|
|
1355
|
+
reviewers: lenses,
|
|
1356
|
+
reviewerService: flowReviewer(storyContext),
|
|
1357
|
+
coder: yield* makeChat(storyContext.coder, { system, events, agent: "coder" }),
|
|
1358
|
+
taskTitle: `story ${story.id}: ${story.title}`,
|
|
1359
|
+
currentDiff: git.diffVsBase(epicBranch),
|
|
1360
|
+
events: storyContext.events,
|
|
1361
|
+
lint: gates,
|
|
1362
|
+
triage,
|
|
1363
|
+
maxRounds: reviewRounds,
|
|
1364
|
+
...(deferring ? { settle: "blocking" } : {}),
|
|
1365
|
+
...(options.fix === undefined ? {} : { fix: options.fix }),
|
|
1366
|
+
cache: { files, dir: reviewCacheDir(story) },
|
|
1367
|
+
fixWith: (prompt) => Effect.gen(function* () {
|
|
1368
|
+
const verdict = yield* Ref.get(latest);
|
|
1369
|
+
if (missingPiece(verdict)) {
|
|
1370
|
+
// Used once: a later round's fix answers that round, not this verdict.
|
|
1371
|
+
yield* Ref.set(latest, undefined);
|
|
1372
|
+
yield* addRevision(verdict, false);
|
|
1373
|
+
yield* guarded(implementTasks, implementTasks);
|
|
1374
|
+
yield* catchUp(story, git);
|
|
1375
|
+
yield* repairPerimeter;
|
|
1376
|
+
return "";
|
|
1377
|
+
}
|
|
1378
|
+
// A BLOCKED_ON from the fix turn stops the story like a task's would.
|
|
1379
|
+
yield* guarded(coderTurn(storyFixPrompt(prompt, yield* Ref.get(lastStanding))), Effect.void);
|
|
1380
|
+
yield* git.commitAll(`${story.id}: story review fixes`);
|
|
1381
|
+
return "";
|
|
1382
|
+
}),
|
|
1383
|
+
onRound: (round, standing, settled) => Effect.andThen(Effect.andThen(Ref.set(lastRound, round), Ref.set(lastStanding, standing)), appendFindings(story, `story review round ${round} — ${standing.isClean ? "clean" : `${standing.issues.length} issue(s)`}${settled ? "" : ", fixing"}`, standing)),
|
|
1384
|
+
recheck: {
|
|
1385
|
+
extra: judge === undefined
|
|
1386
|
+
? []
|
|
1387
|
+
: [
|
|
1244
1388
|
{
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
],
|
|
1253
|
-
summary: `judge:${story.id}`
|
|
1254
|
-
}),
|
|
1255
|
-
reused: false
|
|
1389
|
+
name: "judge",
|
|
1390
|
+
review: () => Effect.gen(function* () {
|
|
1391
|
+
const round = yield* Ref.updateAndGet(judgeRuns, (n) => n + 1);
|
|
1392
|
+
const { verdict } = yield* judgeOnce(judge, round);
|
|
1393
|
+
yield* Ref.set(latest, verdict);
|
|
1394
|
+
return verdict;
|
|
1395
|
+
})
|
|
1256
1396
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
]), withTimedRole("judge", judge(story, subject, watchedSeats, empty ? "code" : "diff")), { reuse: round === 1 });
|
|
1265
|
-
const scored = outcome.value instanceof StoryVerdict ? outcome.value.dimensions : [];
|
|
1266
|
-
yield* Effect.annotateCurrentSpan({
|
|
1267
|
-
"llm4ts.judge.cleared": outcome.value.isClean,
|
|
1268
|
-
"llm4ts.judge.reused": outcome.reused,
|
|
1269
|
-
...Object.fromEntries(scored.map((d) => [`llm4ts.judge.${d.id}`, d.score]))
|
|
1270
|
-
});
|
|
1271
|
-
const now = yield* Clock.currentTimeNanos;
|
|
1272
|
-
yield* Effect.option(Effect.currentSpan).pipe(Effect.map((span) => {
|
|
1273
|
-
if (span._tag === "Some") {
|
|
1274
|
-
for (const issue of outcome.value.issues) {
|
|
1275
|
-
span.value.event("judge finding", now, {
|
|
1276
|
-
title: issue.title,
|
|
1277
|
-
severity: issue.severity
|
|
1278
|
-
});
|
|
1397
|
+
],
|
|
1398
|
+
memory: {
|
|
1399
|
+
load: Effect.map(files.read(statePath), (text) => {
|
|
1400
|
+
if (text === undefined)
|
|
1401
|
+
return undefined;
|
|
1402
|
+
try {
|
|
1403
|
+
return Schema.decodeUnknownSync(ReviewMemory)(JSON.parse(text));
|
|
1279
1404
|
}
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
lane: story.id,
|
|
1288
|
-
message: `judge round ${round}: reused the verdict for an unchanged ${empty ? "code" : "diff"}`
|
|
1289
|
-
}));
|
|
1405
|
+
catch {
|
|
1406
|
+
// An unreadable state starts the review over.
|
|
1407
|
+
return undefined;
|
|
1408
|
+
}
|
|
1409
|
+
}),
|
|
1410
|
+
save: (memory) => files.writeAtomic(statePath, JSON.stringify(Schema.encodeSync(ReviewMemory)(memory), null, 2))
|
|
1411
|
+
}
|
|
1290
1412
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1413
|
+
});
|
|
1414
|
+
const rounds = yield* Ref.get(lastRound);
|
|
1415
|
+
const left = result.issues;
|
|
1416
|
+
// Lenses and the judge always name themselves: a finding with no
|
|
1417
|
+
// lens is a gate's (target gates, oracle, perimeter, frozen tests).
|
|
1418
|
+
const gateLeft = left.filter((issue) => issue.lens === undefined);
|
|
1419
|
+
if (gateLeft.length > 0) {
|
|
1420
|
+
return yield* failed(story, `its gates are still red after ${rounds} story review round${rounds === 1 ? "" : "s"}:\n${issueLines(ReviewResult.make({ issues: gateLeft }))}`);
|
|
1421
|
+
}
|
|
1422
|
+
const judgeLeft = left.filter((issue) => issue.lens === "judge" && (!deferring || isBlocking(issue)));
|
|
1423
|
+
if (judgeLeft.length > 0) {
|
|
1424
|
+
return yield* failed(story, `judge not cleared after ${rounds} story review round${rounds === 1 ? "" : "s"}:\n${issueLines(ReviewResult.make({ issues: judgeLeft }))}`);
|
|
1425
|
+
}
|
|
1426
|
+
const waiting = deferring ? left : [];
|
|
1427
|
+
for (const issue of waiting) {
|
|
1428
|
+
deferred.push(deferredLine("story review", issue));
|
|
1429
|
+
}
|
|
1430
|
+
const carried = deferring ? [] : left.filter((issue) => issue.lens !== "judge");
|
|
1431
|
+
if (carried.length > 0) {
|
|
1432
|
+
yield* appendFindings(story, "carried: review rounds ran out", ReviewResult.make({ issues: carried }));
|
|
1433
|
+
}
|
|
1434
|
+
return judge === undefined
|
|
1435
|
+
? undefined
|
|
1436
|
+
: `judge cleared (story review round ${rounds}${waiting.length === 0 ? "" : `, ${waiting.length} finding(s) deferred`})`;
|
|
1437
|
+
});
|
|
1438
|
+
let judgeNote;
|
|
1439
|
+
let inPlace = false;
|
|
1440
|
+
const storyDiff = reviewAtStory ? yield* git.diffVsBase(epicBranch) : "";
|
|
1441
|
+
if (reviewAtStory && storyDiff.trim().length > 0) {
|
|
1442
|
+
judgeNote = yield* stage(laneOf(story), `story ${story.id}: review`, reviewStory());
|
|
1443
|
+
}
|
|
1444
|
+
else if (options.judge !== undefined) {
|
|
1445
|
+
const judge = options.judge;
|
|
1446
|
+
for (let round = 1; round <= judgeRounds; round += 1) {
|
|
1447
|
+
const { verdict, empty } = yield* judgeOnce(judge, round);
|
|
1301
1448
|
// Deferring, a verdict with nothing blocking clears: what it found
|
|
1302
1449
|
// waits for the follow-up round instead of another revision.
|
|
1303
1450
|
if (verdict.isClean || (deferring && !verdict.issues.some(isBlocking))) {
|
|
@@ -1383,6 +1530,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1383
1530
|
status: "merged",
|
|
1384
1531
|
...(last === undefined ? {} : { executor: last })
|
|
1385
1532
|
}));
|
|
1533
|
+
yield* files.remove(reviewStatePath(options.stateDir, story.id));
|
|
1386
1534
|
const inherited = inheritedByStory.get(story.id);
|
|
1387
1535
|
return StoryOutcome.make({
|
|
1388
1536
|
id: story.id,
|
|
@@ -1408,7 +1556,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1408
1556
|
: { [attr.session]: context.trace.runId, [attr.run]: context.trace.runId })
|
|
1409
1557
|
});
|
|
1410
1558
|
const annotated = (story, effect) => Object.entries(storySpanAttributes(story)).reduce((acc, [key, value]) => Effect.annotateSpans(key, value)(acc), effect);
|
|
1411
|
-
const attempt = (story) => annotated(story, stage(laneOf(story), `story ${story.id}`,
|
|
1559
|
+
const attempt = (story) => annotated(story, stage(laneOf(story), `story ${story.id}`,
|
|
1560
|
+
// A story that ends failed starts its review over next time (ADR 0044).
|
|
1561
|
+
Effect.tapError(runStory(story), () => Effect.ignore(files.remove(reviewStatePath(options.stateDir, story.id)))), {
|
|
1412
1562
|
kind: "AGENT",
|
|
1413
1563
|
root: true,
|
|
1414
1564
|
attributes: storySpanAttributes(story)
|