@llm4ts/flow 2.50.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/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 +10 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +237 -88
- package/dist/Stories.js.map +1 -1
- package/package.json +4 -2
- package/src/FlowEvents.ts +3 -1
- 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 +228 -14
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
|
|
@@ -516,6 +519,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
516
519
|
// Parallel tasks are opt-in (ADR 0034 amendment): one coder per story unless asked.
|
|
517
520
|
const taskConcurrency = Math.max(1, options.taskConcurrency ?? 1);
|
|
518
521
|
const deferring = options.deferNonBlocking === true;
|
|
522
|
+
const reviewAtStory = options.reviewAt === "story";
|
|
523
|
+
const reviewRounds = Math.max(1, options.reviewRounds ?? 3);
|
|
519
524
|
const statePath = (story) => join(options.stateDir, `stories/${story.id}.json`);
|
|
520
525
|
const planPath = (story) => join(options.stateDir, `stories/${story.id}.plan.md`);
|
|
521
526
|
/** The judge's last verdict beside a fingerprint of what it judged. */
|
|
@@ -1155,7 +1160,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1155
1160
|
// A lens's answer is kept beside the task and diff it answered, so a
|
|
1156
1161
|
// rerun over the same change asks nothing; every round is written to
|
|
1157
1162
|
// the story's findings log for people to read.
|
|
1158
|
-
|
|
1163
|
+
// Reviewing per story (ADR 0044), a task runs on its gates alone.
|
|
1164
|
+
...(reviewAtStory
|
|
1165
|
+
? { reviewers: [] }
|
|
1166
|
+
: { reviewCache: { files, dir: reviewCacheDir(story) } }),
|
|
1159
1167
|
onReview: (task, round, result, settled) => Effect.gen(function* () {
|
|
1160
1168
|
// A settled round's leftovers are deferred, not dropped: when the
|
|
1161
1169
|
// rounds ran out, blocking ones too, since the task commits anyway.
|
|
@@ -1168,15 +1176,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1168
1176
|
onTaskReply: (task, _reply, trailer, startedAt) => checkEvidence(task, trailer, startedAt),
|
|
1169
1177
|
// The repository's own review rules ride along as one extra lens
|
|
1170
1178
|
// (ADR 0027 decision 9), read from the epic checkout.
|
|
1171
|
-
repoRules: loadRepoReviewRules(files, context.workDir),
|
|
1179
|
+
...(reviewAtStory ? {} : { repoRules: loadRepoReviewRules(files, context.workDir) }),
|
|
1172
1180
|
...(options.votes === undefined ? {} : { votes: options.votes }),
|
|
1173
1181
|
...(options.fixer === undefined ? {} : { fixer: options.fixer }),
|
|
1174
1182
|
...(options.stall === undefined ? {} : { stall: options.stall }),
|
|
1175
|
-
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
1183
|
+
...(options.reviewers === undefined || reviewAtStory ? {} : { reviewers: options.reviewers }),
|
|
1176
1184
|
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds }),
|
|
1177
1185
|
...(deferring ? { settle: "blocking" } : {})
|
|
1178
1186
|
});
|
|
1179
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));
|
|
1180
1190
|
// The branch already caught up with the epic above: the gates in the
|
|
1181
1191
|
// worktree now see the combination that was red after the merge.
|
|
1182
1192
|
yield* appendRevision("make the epic gates green after merging", [
|
|
@@ -1211,94 +1221,230 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1211
1221
|
const addRevision = (verdict, empty) => appendRevision("close the judge's findings", empty
|
|
1212
1222
|
? `The story "${story.title}" has no changes yet, and what it must provide is not all in place on the epic branch.`
|
|
1213
1223
|
: `The story "${story.title}" was judged short of done.`, verdict);
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
+
];
|
|
1217
1328
|
const judge = options.judge;
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
const judgedCode = empty ? yield* git.diffVsBaseScoped(emptyTree, story.owned, false) : diff;
|
|
1225
|
-
// What the coder claimed and could not show travels with the subject
|
|
1226
|
-
// (ADR 0027 decision 6): the judge weighs the code knowing the
|
|
1227
|
-
// story's own account of its testing is not evidence.
|
|
1228
|
-
const subject = evidenceNotes.length === 0
|
|
1229
|
-
? 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
|
+
? []
|
|
1230
1335
|
: [
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
+
: [
|
|
1245
1388
|
{
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
],
|
|
1254
|
-
summary: `judge:${story.id}`
|
|
1255
|
-
}),
|
|
1256
|
-
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
|
+
})
|
|
1257
1396
|
}
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
]), withTimedRole("judge", judge(story, subject, watchedSeats, empty ? "code" : "diff")), { reuse: round === 1 });
|
|
1266
|
-
const scored = outcome.value instanceof StoryVerdict ? outcome.value.dimensions : [];
|
|
1267
|
-
yield* Effect.annotateCurrentSpan({
|
|
1268
|
-
"llm4ts.judge.cleared": outcome.value.isClean,
|
|
1269
|
-
"llm4ts.judge.reused": outcome.reused,
|
|
1270
|
-
...Object.fromEntries(scored.map((d) => [`llm4ts.judge.${d.id}`, d.score]))
|
|
1271
|
-
});
|
|
1272
|
-
const now = yield* Clock.currentTimeNanos;
|
|
1273
|
-
yield* Effect.option(Effect.currentSpan).pipe(Effect.map((span) => {
|
|
1274
|
-
if (span._tag === "Some") {
|
|
1275
|
-
for (const issue of outcome.value.issues) {
|
|
1276
|
-
span.value.event("judge finding", now, {
|
|
1277
|
-
title: issue.title,
|
|
1278
|
-
severity: issue.severity
|
|
1279
|
-
});
|
|
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));
|
|
1280
1404
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
lane: story.id,
|
|
1289
|
-
message: `judge round ${round}: reused the verdict for an unchanged ${empty ? "code" : "diff"}`
|
|
1290
|
-
}));
|
|
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
|
+
}
|
|
1291
1412
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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);
|
|
1302
1448
|
// Deferring, a verdict with nothing blocking clears: what it found
|
|
1303
1449
|
// waits for the follow-up round instead of another revision.
|
|
1304
1450
|
if (verdict.isClean || (deferring && !verdict.issues.some(isBlocking))) {
|
|
@@ -1384,6 +1530,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1384
1530
|
status: "merged",
|
|
1385
1531
|
...(last === undefined ? {} : { executor: last })
|
|
1386
1532
|
}));
|
|
1533
|
+
yield* files.remove(reviewStatePath(options.stateDir, story.id));
|
|
1387
1534
|
const inherited = inheritedByStory.get(story.id);
|
|
1388
1535
|
return StoryOutcome.make({
|
|
1389
1536
|
id: story.id,
|
|
@@ -1409,7 +1556,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1409
1556
|
: { [attr.session]: context.trace.runId, [attr.run]: context.trace.runId })
|
|
1410
1557
|
});
|
|
1411
1558
|
const annotated = (story, effect) => Object.entries(storySpanAttributes(story)).reduce((acc, [key, value]) => Effect.annotateSpans(key, value)(acc), effect);
|
|
1412
|
-
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)))), {
|
|
1413
1562
|
kind: "AGENT",
|
|
1414
1563
|
root: true,
|
|
1415
1564
|
attributes: storySpanAttributes(story)
|