@llm4ts/flow 2.30.1 → 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.
Files changed (74) hide show
  1. package/dist/AutonomyContract.d.ts +12 -0
  2. package/dist/AutonomyContract.d.ts.map +1 -0
  3. package/dist/AutonomyContract.js +51 -0
  4. package/dist/AutonomyContract.js.map +1 -0
  5. package/dist/CarriedNotes.d.ts +10 -0
  6. package/dist/CarriedNotes.d.ts.map +1 -1
  7. package/dist/CarriedNotes.js +27 -2
  8. package/dist/CarriedNotes.js.map +1 -1
  9. package/dist/Evidence.d.ts +12 -0
  10. package/dist/Evidence.d.ts.map +1 -0
  11. package/dist/Evidence.js +30 -0
  12. package/dist/Evidence.js.map +1 -0
  13. package/dist/Flow.d.ts +35 -3
  14. package/dist/Flow.d.ts.map +1 -1
  15. package/dist/Flow.js +58 -4
  16. package/dist/Flow.js.map +1 -1
  17. package/dist/FlowEvents.d.ts +14 -1
  18. package/dist/FlowEvents.d.ts.map +1 -1
  19. package/dist/FlowEvents.js +13 -0
  20. package/dist/FlowEvents.js.map +1 -1
  21. package/dist/GateTriage.d.ts +81 -0
  22. package/dist/GateTriage.d.ts.map +1 -0
  23. package/dist/GateTriage.js +161 -0
  24. package/dist/GateTriage.js.map +1 -0
  25. package/dist/Gates.d.ts +35 -0
  26. package/dist/Gates.d.ts.map +1 -0
  27. package/dist/Gates.js +63 -0
  28. package/dist/Gates.js.map +1 -0
  29. package/dist/OracleGuard.d.ts +53 -0
  30. package/dist/OracleGuard.d.ts.map +1 -0
  31. package/dist/OracleGuard.js +165 -0
  32. package/dist/OracleGuard.js.map +1 -0
  33. package/dist/Pack.d.ts +7 -0
  34. package/dist/Pack.d.ts.map +1 -1
  35. package/dist/Pack.js +9 -0
  36. package/dist/Pack.js.map +1 -1
  37. package/dist/Retro.d.ts.map +1 -1
  38. package/dist/Retro.js +5 -0
  39. package/dist/Retro.js.map +1 -1
  40. package/dist/Review.d.ts +68 -2
  41. package/dist/Review.d.ts.map +1 -1
  42. package/dist/Review.js +158 -27
  43. package/dist/Review.js.map +1 -1
  44. package/dist/Roster.d.ts +2 -0
  45. package/dist/Roster.d.ts.map +1 -1
  46. package/dist/Roster.js +3 -1
  47. package/dist/Roster.js.map +1 -1
  48. package/dist/RosterSeats.d.ts +2 -0
  49. package/dist/RosterSeats.d.ts.map +1 -1
  50. package/dist/RosterSeats.js.map +1 -1
  51. package/dist/Stories.d.ts +36 -2
  52. package/dist/Stories.d.ts.map +1 -1
  53. package/dist/Stories.js +159 -12
  54. package/dist/Stories.js.map +1 -1
  55. package/dist/StoryPlan.d.ts +6 -0
  56. package/dist/StoryPlan.d.ts.map +1 -1
  57. package/dist/StoryPlan.js +9 -2
  58. package/dist/StoryPlan.js.map +1 -1
  59. package/package.json +7 -2
  60. package/src/AutonomyContract.ts +61 -0
  61. package/src/CarriedNotes.ts +37 -2
  62. package/src/Evidence.ts +43 -0
  63. package/src/Flow.ts +109 -3
  64. package/src/FlowEvents.ts +13 -0
  65. package/src/GateTriage.ts +250 -0
  66. package/src/Gates.ts +104 -0
  67. package/src/OracleGuard.ts +205 -0
  68. package/src/Pack.ts +16 -0
  69. package/src/Retro.ts +7 -0
  70. package/src/Review.ts +261 -34
  71. package/src/Roster.ts +3 -1
  72. package/src/RosterSeats.ts +2 -0
  73. package/src/Stories.ts +289 -16
  74. 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 = options.carry === undefined ? undefined : findingsIn(reply)
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 gate = yield* options.lint
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
+ })