@llm4ts/flow 2.30.0 → 2.31.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/AutonomyContract.d.ts +12 -0
- package/dist/AutonomyContract.d.ts.map +1 -0
- package/dist/AutonomyContract.js +51 -0
- package/dist/AutonomyContract.js.map +1 -0
- package/dist/CarriedNotes.d.ts +10 -0
- package/dist/CarriedNotes.d.ts.map +1 -1
- package/dist/CarriedNotes.js +27 -2
- package/dist/CarriedNotes.js.map +1 -1
- package/dist/Evidence.d.ts +12 -0
- package/dist/Evidence.d.ts.map +1 -0
- package/dist/Evidence.js +30 -0
- package/dist/Evidence.js.map +1 -0
- package/dist/Flow.d.ts +35 -3
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +58 -4
- package/dist/Flow.js.map +1 -1
- package/dist/FlowEvents.d.ts +14 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +13 -0
- package/dist/FlowEvents.js.map +1 -1
- package/dist/GateTriage.d.ts +81 -0
- package/dist/GateTriage.d.ts.map +1 -0
- package/dist/GateTriage.js +161 -0
- package/dist/GateTriage.js.map +1 -0
- package/dist/Gates.d.ts +35 -0
- package/dist/Gates.d.ts.map +1 -0
- package/dist/Gates.js +63 -0
- package/dist/Gates.js.map +1 -0
- package/dist/OracleGuard.d.ts +53 -0
- package/dist/OracleGuard.d.ts.map +1 -0
- package/dist/OracleGuard.js +165 -0
- package/dist/OracleGuard.js.map +1 -0
- package/dist/Pack.d.ts +7 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +9 -0
- package/dist/Pack.js.map +1 -1
- package/dist/Retro.d.ts.map +1 -1
- package/dist/Retro.js +5 -0
- package/dist/Retro.js.map +1 -1
- package/dist/Review.d.ts +68 -2
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +158 -27
- package/dist/Review.js.map +1 -1
- package/dist/Roster.d.ts +2 -0
- package/dist/Roster.d.ts.map +1 -1
- package/dist/Roster.js +3 -1
- package/dist/Roster.js.map +1 -1
- package/dist/RosterSeats.d.ts +2 -0
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/RosterSeats.js.map +1 -1
- package/dist/Stories.d.ts +36 -2
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +159 -12
- package/dist/Stories.js.map +1 -1
- package/dist/StoryPlan.d.ts +6 -0
- package/dist/StoryPlan.d.ts.map +1 -1
- package/dist/StoryPlan.js +9 -2
- package/dist/StoryPlan.js.map +1 -1
- package/package.json +7 -2
- package/src/AutonomyContract.ts +61 -0
- package/src/CarriedNotes.ts +37 -2
- package/src/Evidence.ts +43 -0
- package/src/Flow.ts +109 -3
- package/src/FlowEvents.ts +13 -0
- package/src/GateTriage.ts +250 -0
- package/src/Gates.ts +104 -0
- package/src/OracleGuard.ts +205 -0
- package/src/Pack.ts +16 -0
- package/src/Retro.ts +7 -0
- package/src/Review.ts +261 -34
- package/src/Roster.ts +3 -1
- package/src/RosterSeats.ts +2 -0
- package/src/Stories.ts +289 -16
- package/src/StoryPlan.ts +11 -1
package/src/Flow.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import * as Clock from "effect/Clock"
|
|
1
3
|
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as Ref from "effect/Ref"
|
|
2
5
|
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
3
6
|
import { collect } from "@llm4ts/core/Streaming"
|
|
4
7
|
import { withToolActivity } from "./Activity.ts"
|
|
5
8
|
import {
|
|
6
9
|
appendNote,
|
|
7
10
|
findingsIn,
|
|
11
|
+
trailerIn,
|
|
12
|
+
type Trailer,
|
|
8
13
|
findingsRequest,
|
|
9
14
|
withNotes,
|
|
10
15
|
type CarriedNotes
|
|
@@ -21,16 +26,23 @@ import {
|
|
|
21
26
|
type FlowEventsShape
|
|
22
27
|
} from "./FlowEvents.ts"
|
|
23
28
|
import type { Plan, Task } from "./Plan.ts"
|
|
24
|
-
import type { PlanStoreShape } from "./Persistence.ts"
|
|
29
|
+
import type { PlainFileStoreShape, PlanStoreShape } from "./Persistence.ts"
|
|
30
|
+
import { ensureBaseline } from "./GateTriage.ts"
|
|
25
31
|
import { implementTaskLoop, stage } from "./PlanExecution.ts"
|
|
26
32
|
import { truth } from "@llm4ts/core/judgment/Schemas"
|
|
27
33
|
import { certaintyOf, decide, judgmentOf, type JudgmentMode } from "./Judgment.ts"
|
|
28
34
|
import {
|
|
35
|
+
applyTriage,
|
|
29
36
|
minimalReviewers,
|
|
30
37
|
reviewAndFixLoop,
|
|
38
|
+
withOracle,
|
|
39
|
+
type FixPromptOptions,
|
|
40
|
+
type GateTriageOptions,
|
|
41
|
+
type OracleGateOptions,
|
|
31
42
|
type ReviewResult,
|
|
32
43
|
type ReviewCacheLocation
|
|
33
44
|
} from "./Review.ts"
|
|
45
|
+
import type { OracleRules } from "./OracleGuard.ts"
|
|
34
46
|
import type { Reviewer } from "./Reviewer.ts"
|
|
35
47
|
|
|
36
48
|
export { publishUsage, structuredAndPublish } from "./Usage.ts"
|
|
@@ -77,6 +89,28 @@ export interface ImplementPlanOptions {
|
|
|
77
89
|
readonly maxRounds?: number
|
|
78
90
|
readonly lint?: Effect.Effect<ReviewResult, FlowError>
|
|
79
91
|
readonly format?: Effect.Effect<void, FlowError>
|
|
92
|
+
/** Charge the lint gate only with failures the task caused (ADR 0027). */
|
|
93
|
+
readonly triage?: GateTriageOptions
|
|
94
|
+
/**
|
|
95
|
+
* Without `triage`: record the gates' result on each task's starting
|
|
96
|
+
* commit under this store and triage against it (ADR 0027). The gates run
|
|
97
|
+
* once per new commit, before the task; `lint` is what they are.
|
|
98
|
+
*/
|
|
99
|
+
readonly baseline?: {
|
|
100
|
+
readonly files: PlainFileStoreShape
|
|
101
|
+
readonly dir: string
|
|
102
|
+
readonly commands: ReadonlyArray<ReadonlyArray<string>>
|
|
103
|
+
readonly appDir?: string
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The oracle guard (ADR 0027 decision 4) over each task's diff: deleted
|
|
107
|
+
* test files, skip or focus markers and a passed-count drop (against the
|
|
108
|
+
* baseline, when both counts are known) fail the round unless
|
|
109
|
+
* `testsChange` is true.
|
|
110
|
+
*/
|
|
111
|
+
readonly oracle?: { readonly rules?: OracleRules; readonly testsChange?: boolean }
|
|
112
|
+
/** How gate output reaches the coder in the fix prompt. */
|
|
113
|
+
readonly fix?: FixPromptOptions
|
|
80
114
|
/** Where each review lens's answer is kept, so a rerun over the same diff asks nothing. */
|
|
81
115
|
readonly reviewCache?: ReviewCacheLocation
|
|
82
116
|
/** Told after every review round of every task, settled or not. */
|
|
@@ -111,6 +145,17 @@ export interface ImplementPlanOptions {
|
|
|
111
145
|
* appended. Omit to carry nothing.
|
|
112
146
|
*/
|
|
113
147
|
readonly carry?: CarriedNotes
|
|
148
|
+
/**
|
|
149
|
+
* Called with every task's final reply and its parsed trailer, after the
|
|
150
|
+
* findings are kept (ADR 0027 decision 6): the caller checks the
|
|
151
|
+
* `verified:` claims against what actually ran.
|
|
152
|
+
*/
|
|
153
|
+
readonly onTaskReply?: (
|
|
154
|
+
task: Task,
|
|
155
|
+
reply: string,
|
|
156
|
+
trailer: Trailer,
|
|
157
|
+
startedAt: number
|
|
158
|
+
) => Effect.Effect<void, FlowError>
|
|
114
159
|
}
|
|
115
160
|
|
|
116
161
|
/** The judgment form of the empty-diff probe: one Truth question over the coder's reply. */
|
|
@@ -166,6 +211,40 @@ const defaultCommitMessage = (plan: Plan, task: Task): string => `${plan.epicId}
|
|
|
166
211
|
const composeSystem = (base: string | undefined, note: string): string =>
|
|
167
212
|
[base, note].filter((part): part is string => part !== undefined && part.length > 0).join("\n\n")
|
|
168
213
|
|
|
214
|
+
/**
|
|
215
|
+
* The task's triage: the explicit one, or one built from `baseline` over the
|
|
216
|
+
* commit the task starts from (HEAD now), or none.
|
|
217
|
+
*/
|
|
218
|
+
const triageFor = (
|
|
219
|
+
context: FlowContextShape,
|
|
220
|
+
options: ImplementPlanOptions
|
|
221
|
+
): Effect.Effect<GateTriageOptions | undefined, FlowError> =>
|
|
222
|
+
Effect.gen(function* () {
|
|
223
|
+
if (options.triage !== undefined) {
|
|
224
|
+
return options.triage
|
|
225
|
+
}
|
|
226
|
+
if (options.baseline === undefined || options.lint === undefined) {
|
|
227
|
+
return undefined
|
|
228
|
+
}
|
|
229
|
+
const base = options.baseline
|
|
230
|
+
const appDir = base.appDir ?? "."
|
|
231
|
+
const roots = [join(context.workDir, appDir), context.workDir]
|
|
232
|
+
const commit = yield* context.git.checkpoint
|
|
233
|
+
return {
|
|
234
|
+
baseline: ensureBaseline({
|
|
235
|
+
files: base.files,
|
|
236
|
+
stateDir: base.dir,
|
|
237
|
+
commit,
|
|
238
|
+
appDir,
|
|
239
|
+
commands: base.commands,
|
|
240
|
+
run: options.lint,
|
|
241
|
+
roots,
|
|
242
|
+
now: Clock.currentTimeMillis
|
|
243
|
+
}),
|
|
244
|
+
roots
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
169
248
|
export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(function* (
|
|
170
249
|
context: FlowContextShape,
|
|
171
250
|
options: ImplementPlanOptions
|
|
@@ -208,13 +287,17 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
208
287
|
// details. `planSoFar`, threaded through by implementTaskLoop, is the
|
|
209
288
|
// single source of truth for completion progress instead.
|
|
210
289
|
const notes = options.carry === undefined ? undefined : yield* options.carry.read
|
|
290
|
+
const taskStartedAt = yield* Clock.currentTimeMillis
|
|
211
291
|
/** What the task's reply learned goes to the carried notes, whichever turn did the work. */
|
|
212
292
|
const keepFindings = (reply: string): Effect.Effect<void, FlowError> =>
|
|
213
293
|
Effect.gen(function* () {
|
|
214
|
-
const found =
|
|
294
|
+
const found = findingsIn(reply)
|
|
215
295
|
if (options.carry !== undefined && found !== undefined) {
|
|
216
296
|
yield* options.carry.write(appendNote(notes, task.title, found))
|
|
217
297
|
}
|
|
298
|
+
if (options.onTaskReply !== undefined) {
|
|
299
|
+
yield* options.onTaskReply(task, reply, trailerIn(found), taskStartedAt)
|
|
300
|
+
}
|
|
218
301
|
})
|
|
219
302
|
yield* keepFindings(
|
|
220
303
|
yield* coder.ask(
|
|
@@ -270,6 +353,20 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
270
353
|
})
|
|
271
354
|
}
|
|
272
355
|
}
|
|
356
|
+
const triage = yield* triageFor(context, options)
|
|
357
|
+
const oracle: OracleGateOptions | undefined =
|
|
358
|
+
options.oracle === undefined
|
|
359
|
+
? undefined
|
|
360
|
+
: {
|
|
361
|
+
diff: context.git.diffAll,
|
|
362
|
+
...(options.oracle.rules === undefined ? {} : { rules: options.oracle.rules }),
|
|
363
|
+
...(options.oracle.testsChange === undefined
|
|
364
|
+
? {}
|
|
365
|
+
: { declared: options.oracle.testsChange }),
|
|
366
|
+
...(triage === undefined
|
|
367
|
+
? {}
|
|
368
|
+
: { baseCount: Effect.map(triage.baseline, (base) => base?.passedCount) })
|
|
369
|
+
}
|
|
273
370
|
yield* reviewAndFixLoop({
|
|
274
371
|
reviewers: options.reviewers ?? minimalReviewers,
|
|
275
372
|
reviewerService: flowReviewer(context),
|
|
@@ -280,6 +377,9 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
280
377
|
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds }),
|
|
281
378
|
...(options.lint === undefined ? {} : { lint: options.lint }),
|
|
282
379
|
...(options.format === undefined ? {} : { format: options.format }),
|
|
380
|
+
...(triage === undefined ? {} : { triage }),
|
|
381
|
+
...(oracle === undefined ? {} : { oracle }),
|
|
382
|
+
...(options.fix === undefined ? {} : { fix: options.fix }),
|
|
283
383
|
...(options.reviewCache === undefined ? {} : { cache: options.reviewCache }),
|
|
284
384
|
...(options.onReview === undefined
|
|
285
385
|
? {}
|
|
@@ -291,7 +391,13 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
291
391
|
})
|
|
292
392
|
})
|
|
293
393
|
if (options.lint !== undefined) {
|
|
294
|
-
const
|
|
394
|
+
const reported = yield* Ref.make<ReadonlySet<string>>(new Set())
|
|
395
|
+
const gate = yield* withOracle(
|
|
396
|
+
yield* applyTriage(yield* options.lint, triage, context.events, reported),
|
|
397
|
+
oracle,
|
|
398
|
+
context.events,
|
|
399
|
+
yield* Ref.make(true)
|
|
400
|
+
)
|
|
295
401
|
if (!gate.isClean) {
|
|
296
402
|
return yield* FlowAborted.make({
|
|
297
403
|
message: [
|
package/src/FlowEvents.ts
CHANGED
|
@@ -171,6 +171,18 @@ export class CapabilityUnenforceable extends Schema.TaggedClass<CapabilityUnenfo
|
|
|
171
171
|
}
|
|
172
172
|
) {}
|
|
173
173
|
|
|
174
|
+
/** A task's `verified:` claims against the tool calls its transcript shows (ADR 0027). */
|
|
175
|
+
export class EvidenceChecked extends Schema.TaggedClass<EvidenceChecked>()("EvidenceChecked", {
|
|
176
|
+
task: Schema.String,
|
|
177
|
+
claimed: Schema.Int,
|
|
178
|
+
/** Claims no tool call carried: fabricated status. */
|
|
179
|
+
unverified: Schema.Int,
|
|
180
|
+
confidence: Schema.optionalKey(Schema.Literals(["high", "medium", "low"])),
|
|
181
|
+
/** The run kept no tool transcript for this coder (an API seat), so nothing was checked. */
|
|
182
|
+
unchecked: Schema.optionalKey(Schema.Boolean),
|
|
183
|
+
lane: Schema.optionalKey(Schema.String)
|
|
184
|
+
}) {}
|
|
185
|
+
|
|
174
186
|
export class Declassified extends Schema.TaggedClass<Declassified>()("Declassified", {
|
|
175
187
|
label: Schema.String
|
|
176
188
|
}) {}
|
|
@@ -334,6 +346,7 @@ export const FlowEvent = Schema.Union([
|
|
|
334
346
|
CapabilityUsedEvent,
|
|
335
347
|
CapabilityDeniedEvent,
|
|
336
348
|
CapabilityUnenforceable,
|
|
349
|
+
EvidenceChecked,
|
|
337
350
|
Declassified,
|
|
338
351
|
ExecutorLeased,
|
|
339
352
|
ExecutorReleased,
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Gates with a memory (ADR 0027): what the target's gates said on the code a
|
|
2
|
+
// change started from, and which failing lines the change can be charged
|
|
3
|
+
// with. Pure helpers and a small store; the seams are lintCommand and
|
|
4
|
+
// reviewAndFixLoop (Review.ts), implementPlanFlow (Flow.ts) and the story
|
|
5
|
+
// executor (Stories.ts). Gates.ts re-exports this module beside gatesIn.
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import * as Schema from "effect/Schema"
|
|
8
|
+
import type { PersistenceError } from "./FlowError.ts"
|
|
9
|
+
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
10
|
+
import { ReviewIssue, ReviewResult } from "./Review.ts"
|
|
11
|
+
import { fingerprintOf } from "./ReviewCache.ts"
|
|
12
|
+
|
|
13
|
+
export const GateClass = Schema.Literals(["red", "hang", "crash"])
|
|
14
|
+
export type GateClass = typeof GateClass.Type
|
|
15
|
+
|
|
16
|
+
export const FailureOrigin = Schema.Literals(["new", "base", "flaky"])
|
|
17
|
+
export type FailureOrigin = typeof FailureOrigin.Type
|
|
18
|
+
|
|
19
|
+
export class GateBaseline extends Schema.Class<GateBaseline>("GateBaseline")({
|
|
20
|
+
baseCommit: Schema.String,
|
|
21
|
+
appDir: Schema.String,
|
|
22
|
+
/** Each gate command as configured, joined with one space. */
|
|
23
|
+
commands: Schema.Array(Schema.String),
|
|
24
|
+
failingLines: Schema.Array(Schema.String),
|
|
25
|
+
/** Tests passing on the base, when the test gate's output said (the oracle guard compares). */
|
|
26
|
+
passedCount: Schema.optionalKey(Schema.Int),
|
|
27
|
+
recordedAt: Schema.Number
|
|
28
|
+
}) {}
|
|
29
|
+
|
|
30
|
+
/** Every baseline a run recorded, by `baselineKey`; one file, deleted on land. */
|
|
31
|
+
const Baselines = Schema.Record(Schema.String, GateBaseline)
|
|
32
|
+
const BaselinesJson = Schema.fromJsonString(Baselines)
|
|
33
|
+
const decodeBaselines = Schema.decodeUnknownEffect(BaselinesJson)
|
|
34
|
+
const encodeBaselines = Schema.encodeSync(BaselinesJson)
|
|
35
|
+
|
|
36
|
+
// Built from the escape code point so the pattern holds no control character literal.
|
|
37
|
+
const ansi = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "gu")
|
|
38
|
+
const duration = /\b\d+(?:\.\d+)?\s?(?:ms|s|m)\b/gu
|
|
39
|
+
const isoTimestamp = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?/gu
|
|
40
|
+
const stackFrame = /^\s*at\s/u
|
|
41
|
+
const summaryLine =
|
|
42
|
+
/^\s*(?:Test Files|Tests|Duration|Start at|Snapshots|Found \d+ errors?|\d+ (?:passing|failing|pending))\b/u
|
|
43
|
+
const failureMarker =
|
|
44
|
+
/FAIL|✗|×|✘|✖|❯|error|Error|AssertionError|Expected|Received|expected|TS\d{4}/u
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The lines of a gate's output a change can be blamed for, made comparable
|
|
48
|
+
* across runs and worktrees: colours, timings, timestamps and the given root
|
|
49
|
+
* prefixes removed; stack frames, summaries and blank lines dropped; only
|
|
50
|
+
* lines carrying a failure marker kept, each once. A heuristic by design:
|
|
51
|
+
* per-tool parsers are kit material (a pack `diagnostics:` command, later).
|
|
52
|
+
*/
|
|
53
|
+
export const normalizeGateOutput = (
|
|
54
|
+
text: string,
|
|
55
|
+
roots: ReadonlyArray<string>
|
|
56
|
+
): ReadonlyArray<string> => {
|
|
57
|
+
const prefixes = roots.filter((root) => root.length > 0).map((root) => root.replace(/\/+$/u, ""))
|
|
58
|
+
const seen = new Set<string>()
|
|
59
|
+
const out: Array<string> = []
|
|
60
|
+
for (const raw of text.split(/\r?\n/u)) {
|
|
61
|
+
let line = raw.replace(ansi, "")
|
|
62
|
+
for (const prefix of prefixes) {
|
|
63
|
+
line = line.replaceAll(`${prefix}/`, "")
|
|
64
|
+
}
|
|
65
|
+
line = line.replace(isoTimestamp, "<ts>").replace(duration, "<t>").trim()
|
|
66
|
+
if (line.length === 0 || stackFrame.test(line) || summaryLine.test(line)) {
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (!failureMarker.test(line)) {
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
line = line.replace(/\s+/gu, " ")
|
|
73
|
+
if (!seen.has(line)) {
|
|
74
|
+
seen.add(line)
|
|
75
|
+
out.push(line)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The title prefix `lintCommand` gives every gate issue; nothing else is a gate. */
|
|
82
|
+
export const gateIssuePrefix = "lint failed: "
|
|
83
|
+
|
|
84
|
+
export const isGateIssue = (issue: ReviewIssue): boolean => issue.title.startsWith(gateIssuePrefix)
|
|
85
|
+
|
|
86
|
+
/** Every gate issue's normalized failing lines, in order, each once. */
|
|
87
|
+
export const failingLinesOf = (
|
|
88
|
+
result: ReviewResult,
|
|
89
|
+
roots: ReadonlyArray<string>
|
|
90
|
+
): ReadonlyArray<string> =>
|
|
91
|
+
Array.from(
|
|
92
|
+
new Set(
|
|
93
|
+
result.issues
|
|
94
|
+
.filter(isGateIssue)
|
|
95
|
+
.flatMap((issue) => normalizeGateOutput(issue.description, roots))
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
export interface GateTriage {
|
|
100
|
+
/** The lint result with only the failures the change caused; clean when it caused none. */
|
|
101
|
+
readonly blocking: ReviewResult
|
|
102
|
+
readonly newLines: ReadonlyArray<string>
|
|
103
|
+
/** Failing lines already red on the base: listed, never charged. */
|
|
104
|
+
readonly inherited: ReadonlyArray<string>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Splits a lint result against the baseline. A gate issue whose every
|
|
109
|
+
* failing line is on the baseline is dropped (inherited); one with new lines
|
|
110
|
+
* is kept with `origin: "new"` and its description reduced to those lines,
|
|
111
|
+
* so the fix prompt names only what the change broke. A gate issue with no
|
|
112
|
+
* recognizable failing line at all (a hang, a crash, an unparsed output) is
|
|
113
|
+
* kept whole. Non-gate issues (the perimeter, the oracle guard) pass through.
|
|
114
|
+
* Without a baseline the result is returned as is.
|
|
115
|
+
*/
|
|
116
|
+
export const triageGates = (
|
|
117
|
+
result: ReviewResult,
|
|
118
|
+
baseline: GateBaseline | undefined,
|
|
119
|
+
roots: ReadonlyArray<string>
|
|
120
|
+
): GateTriage => {
|
|
121
|
+
if (baseline === undefined || result.isClean) {
|
|
122
|
+
return { blocking: result, newLines: [], inherited: [] }
|
|
123
|
+
}
|
|
124
|
+
const known = new Set(baseline.failingLines)
|
|
125
|
+
const newLines: Array<string> = []
|
|
126
|
+
const inherited: Array<string> = []
|
|
127
|
+
const issues: Array<ReviewIssue> = []
|
|
128
|
+
for (const issue of result.issues) {
|
|
129
|
+
if (!isGateIssue(issue)) {
|
|
130
|
+
issues.push(issue)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
const lines = normalizeGateOutput(issue.description, roots)
|
|
134
|
+
const fresh = lines.filter((line) => !known.has(line))
|
|
135
|
+
inherited.push(...lines.filter((line) => known.has(line)))
|
|
136
|
+
if (lines.length > 0 && fresh.length === 0) {
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
newLines.push(...fresh)
|
|
140
|
+
issues.push(
|
|
141
|
+
ReviewIssue.make({
|
|
142
|
+
...issue,
|
|
143
|
+
origin: "new",
|
|
144
|
+
description: fresh.length > 0 ? fresh.join("\n") : issue.description
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
blocking: ReviewResult.make({
|
|
150
|
+
issues,
|
|
151
|
+
summary: issues.length === 0 ? "lint passed (inherited failures only)" : result.summary
|
|
152
|
+
}),
|
|
153
|
+
newLines: Array.from(new Set(newLines)),
|
|
154
|
+
inherited: Array.from(new Set(inherited))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const baselineKey = (parts: {
|
|
159
|
+
readonly baseCommit: string
|
|
160
|
+
readonly appDir: string
|
|
161
|
+
readonly commands: ReadonlyArray<ReadonlyArray<string>>
|
|
162
|
+
}): string =>
|
|
163
|
+
fingerprintOf([
|
|
164
|
+
parts.baseCommit,
|
|
165
|
+
parts.appDir,
|
|
166
|
+
...parts.commands.map((command) => command.join(" "))
|
|
167
|
+
]).slice(0, 16)
|
|
168
|
+
|
|
169
|
+
/** The one file a run keeps its baselines in. */
|
|
170
|
+
export const baselinesPath = (stateDir: string): string =>
|
|
171
|
+
`${stateDir.replace(/\/+$/u, "")}/gates/baselines.json`
|
|
172
|
+
|
|
173
|
+
const readAll = (
|
|
174
|
+
files: PlainFileStoreShape,
|
|
175
|
+
path: string
|
|
176
|
+
): Effect.Effect<Readonly<Record<string, GateBaseline>>, PersistenceError> =>
|
|
177
|
+
files.read(path).pipe(
|
|
178
|
+
Effect.flatMap((text) =>
|
|
179
|
+
text === undefined
|
|
180
|
+
? Effect.succeed({})
|
|
181
|
+
: decodeBaselines(text).pipe(
|
|
182
|
+
// An unreadable file is no baseline: the caller records a fresh one.
|
|
183
|
+
Effect.catch(() => Effect.succeed({}))
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
export const readBaseline = (
|
|
189
|
+
files: PlainFileStoreShape,
|
|
190
|
+
stateDir: string,
|
|
191
|
+
key: string
|
|
192
|
+
): Effect.Effect<GateBaseline | undefined, PersistenceError> =>
|
|
193
|
+
Effect.map(readAll(files, baselinesPath(stateDir)), (all) => all[key])
|
|
194
|
+
|
|
195
|
+
export interface EnsureBaselineArgs<E> {
|
|
196
|
+
readonly files: PlainFileStoreShape
|
|
197
|
+
readonly stateDir: string
|
|
198
|
+
/** The commit the change starts from. */
|
|
199
|
+
readonly commit: string
|
|
200
|
+
readonly appDir: string
|
|
201
|
+
readonly commands: ReadonlyArray<ReadonlyArray<string>>
|
|
202
|
+
/** Runs the gates on that commit; called only when no baseline is stored. */
|
|
203
|
+
readonly run: Effect.Effect<ReviewResult, E>
|
|
204
|
+
/** Root prefixes stripped from gate output (the work dir, the app dir). */
|
|
205
|
+
readonly roots: ReadonlyArray<string>
|
|
206
|
+
readonly now: Effect.Effect<number>
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The baseline for a commit: the stored one, or a fresh one recorded from
|
|
211
|
+
* `run`. Lazy on purpose, so a second change on an unchanged commit costs
|
|
212
|
+
* no gate run.
|
|
213
|
+
*/
|
|
214
|
+
export const ensureBaseline = <E>(
|
|
215
|
+
args: EnsureBaselineArgs<E>
|
|
216
|
+
): Effect.Effect<GateBaseline, E | PersistenceError> =>
|
|
217
|
+
Effect.gen(function* () {
|
|
218
|
+
const key = baselineKey({
|
|
219
|
+
baseCommit: args.commit,
|
|
220
|
+
appDir: args.appDir,
|
|
221
|
+
commands: args.commands
|
|
222
|
+
})
|
|
223
|
+
const stored = yield* readBaseline(args.files, args.stateDir, key)
|
|
224
|
+
if (stored !== undefined) {
|
|
225
|
+
return stored
|
|
226
|
+
}
|
|
227
|
+
const result = yield* args.run
|
|
228
|
+
const baseline = GateBaseline.make({
|
|
229
|
+
baseCommit: args.commit,
|
|
230
|
+
appDir: args.appDir,
|
|
231
|
+
commands: args.commands.map((command) => command.join(" ")),
|
|
232
|
+
failingLines: failingLinesOf(result, args.roots),
|
|
233
|
+
...(result.passed === undefined ? {} : { passedCount: result.passed }),
|
|
234
|
+
recordedAt: yield* args.now
|
|
235
|
+
})
|
|
236
|
+
yield* writeBaseline(args.files, args.stateDir, key, baseline)
|
|
237
|
+
return baseline
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
export const writeBaseline = (
|
|
241
|
+
files: PlainFileStoreShape,
|
|
242
|
+
stateDir: string,
|
|
243
|
+
key: string,
|
|
244
|
+
baseline: GateBaseline
|
|
245
|
+
): Effect.Effect<void, PersistenceError> => {
|
|
246
|
+
const path = baselinesPath(stateDir)
|
|
247
|
+
return Effect.flatMap(readAll(files, path), (all) =>
|
|
248
|
+
files.writeAtomic(path, encodeBaselines({ ...all, [key]: baseline }))
|
|
249
|
+
)
|
|
250
|
+
}
|
package/src/Gates.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The target's gates as a module (ADR 0027): run them in a directory with a
|
|
2
|
+
// timeout and a log per gate, remember what they said on a base, and keep
|
|
3
|
+
// only what matters when an epic lands. The pure half lives in
|
|
4
|
+
// GateTriage.ts and is re-exported here.
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import type * as Duration from "effect/Duration"
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
|
|
9
|
+
import type { FlowError } from "./FlowError.ts"
|
|
10
|
+
import type { FlowEventsShape } from "./FlowEvents.ts"
|
|
11
|
+
import { baselinesPath, normalizeGateOutput } from "./GateTriage.ts"
|
|
12
|
+
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
13
|
+
import { lintCommand, mergeReviewResults, type ReviewResult } from "./Review.ts"
|
|
14
|
+
|
|
15
|
+
export * from "./GateTriage.ts"
|
|
16
|
+
|
|
17
|
+
export interface GateLogDir {
|
|
18
|
+
readonly files: PlainFileStoreShape
|
|
19
|
+
readonly dir: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GateRunOptions {
|
|
23
|
+
readonly timeout?: Duration.Duration
|
|
24
|
+
/** Where this runner's logs go unless a call names its own; `undefined` writes none. */
|
|
25
|
+
readonly logDir?: GateLogDir
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `<index>-<command slug>.log`: one file per gate command, overwritten each run. */
|
|
29
|
+
export const gateLogName = (index: number, command: ReadonlyArray<string>): string =>
|
|
30
|
+
`${index}-${command
|
|
31
|
+
.join(" ")
|
|
32
|
+
.replace(/[^a-z0-9]+/giu, "-")
|
|
33
|
+
.replace(/^-|-$/gu, "")
|
|
34
|
+
.toLowerCase()}.log`
|
|
35
|
+
|
|
36
|
+
/** Runs the gates in a directory, stopping at the first red one (later output would be noise). */
|
|
37
|
+
export const gatesIn =
|
|
38
|
+
(
|
|
39
|
+
process: ProcessExecutorShape,
|
|
40
|
+
events: FlowEventsShape,
|
|
41
|
+
commands: ReadonlyArray<ReadonlyArray<string>>,
|
|
42
|
+
options: GateRunOptions = {}
|
|
43
|
+
) =>
|
|
44
|
+
(
|
|
45
|
+
workDir: string,
|
|
46
|
+
laneEvents?: FlowEventsShape,
|
|
47
|
+
log?: GateLogDir
|
|
48
|
+
): Effect.Effect<ReviewResult, FlowError> =>
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
const sink = log ?? options.logDir
|
|
51
|
+
const results: Array<ReviewResult> = []
|
|
52
|
+
for (const [index, command] of commands.entries()) {
|
|
53
|
+
const result = yield* lintCommand(process, laneEvents ?? events, command, workDir, {
|
|
54
|
+
...(options.timeout === undefined ? {} : { timeout: options.timeout }),
|
|
55
|
+
...(sink === undefined
|
|
56
|
+
? {}
|
|
57
|
+
: { log: { files: sink.files, path: join(sink.dir, gateLogName(index, command)) } })
|
|
58
|
+
})
|
|
59
|
+
results.push(result)
|
|
60
|
+
if (!result.isClean) {
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return mergeReviewResults(results)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** A landed epic keeps why a gate was red, not the target's whole test output. */
|
|
68
|
+
export const compactGateLog = (text: string, roots: ReadonlyArray<string>): string =>
|
|
69
|
+
normalizeGateOutput(text, roots).join("\n")
|
|
70
|
+
|
|
71
|
+
/** The gate log directory of one story under a run's state folder. */
|
|
72
|
+
export const storyGateLogDir = (stateDir: string, storyId: string): string =>
|
|
73
|
+
join(stateDir, "stories", storyId, "gates")
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What `--land` keeps of the gates: no baselines (reproducible), and each
|
|
77
|
+
* story's gate logs reduced to their failing lines. Logs are enumerated from
|
|
78
|
+
* the story ids and the gate commands, so no listing is needed.
|
|
79
|
+
*/
|
|
80
|
+
export const compactGateArtifacts = Effect.fn("@llm4ts/flow/Gates.compactArtifacts")(function* (
|
|
81
|
+
files: PlainFileStoreShape,
|
|
82
|
+
stateDir: string,
|
|
83
|
+
storyIds: ReadonlyArray<string>,
|
|
84
|
+
commands: ReadonlyArray<ReadonlyArray<string>>
|
|
85
|
+
): Effect.fn.Return<{ readonly baselines: number; readonly logs: number }, FlowError> {
|
|
86
|
+
const path = baselinesPath(stateDir)
|
|
87
|
+
const hadBaselines = (yield* files.read(path)) !== undefined
|
|
88
|
+
if (hadBaselines) {
|
|
89
|
+
yield* files.remove(path)
|
|
90
|
+
}
|
|
91
|
+
let logs = 0
|
|
92
|
+
for (const storyId of storyIds) {
|
|
93
|
+
for (const [index, command] of commands.entries()) {
|
|
94
|
+
const logPath = join(storyGateLogDir(stateDir, storyId), gateLogName(index, command))
|
|
95
|
+
const text = yield* files.read(logPath)
|
|
96
|
+
if (text === undefined) {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
yield* files.writeAtomic(logPath, compactGateLog(text, []))
|
|
100
|
+
logs += 1
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { baselines: hadBaselines ? 1 : 0, logs }
|
|
104
|
+
})
|