@llm4ts/flow 2.45.0 → 2.46.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 (51) hide show
  1. package/dist/Approval.d.ts +2 -0
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Approval.js +4 -0
  4. package/dist/Approval.js.map +1 -1
  5. package/dist/AutonomyContract.d.ts +2 -2
  6. package/dist/AutonomyContract.d.ts.map +1 -1
  7. package/dist/AutonomyContract.js +9 -3
  8. package/dist/AutonomyContract.js.map +1 -1
  9. package/dist/CostReport.d.ts +27 -0
  10. package/dist/CostReport.d.ts.map +1 -1
  11. package/dist/CostReport.js +103 -2
  12. package/dist/CostReport.js.map +1 -1
  13. package/dist/FlowRecorder.d.ts +8 -0
  14. package/dist/FlowRecorder.d.ts.map +1 -1
  15. package/dist/FlowRecorder.js +10 -3
  16. package/dist/FlowRecorder.js.map +1 -1
  17. package/dist/GateTriage.d.ts +26 -0
  18. package/dist/GateTriage.d.ts.map +1 -1
  19. package/dist/GateTriage.js +98 -5
  20. package/dist/GateTriage.js.map +1 -1
  21. package/dist/GitTool.d.ts +9 -5
  22. package/dist/GitTool.d.ts.map +1 -1
  23. package/dist/GitTool.js +44 -18
  24. package/dist/GitTool.js.map +1 -1
  25. package/dist/OperatorSchemas.d.ts +256 -0
  26. package/dist/OperatorSchemas.d.ts.map +1 -0
  27. package/dist/OperatorSchemas.js +204 -0
  28. package/dist/OperatorSchemas.js.map +1 -0
  29. package/dist/Retro.d.ts +2 -0
  30. package/dist/Retro.d.ts.map +1 -1
  31. package/dist/Retro.js +6 -0
  32. package/dist/Retro.js.map +1 -1
  33. package/dist/Review.d.ts +2 -0
  34. package/dist/Review.d.ts.map +1 -1
  35. package/dist/Review.js +17 -9
  36. package/dist/Review.js.map +1 -1
  37. package/dist/RunState.d.ts +26 -0
  38. package/dist/RunState.d.ts.map +1 -0
  39. package/dist/RunState.js +59 -0
  40. package/dist/RunState.js.map +1 -0
  41. package/package.json +4 -2
  42. package/src/Approval.ts +6 -0
  43. package/src/AutonomyContract.ts +10 -3
  44. package/src/CostReport.ts +152 -2
  45. package/src/FlowRecorder.ts +19 -3
  46. package/src/GateTriage.ts +113 -7
  47. package/src/GitTool.ts +46 -18
  48. package/src/OperatorSchemas.ts +221 -0
  49. package/src/Retro.ts +11 -0
  50. package/src/Review.ts +25 -11
  51. package/src/RunState.ts +72 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/flow",
3
- "version": "2.45.0",
3
+ "version": "2.46.0",
4
4
  "description": "Effect-native LLM workflow, persistence, review, and repository automation",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,6 +38,7 @@
38
38
  "./Domains": "./dist/Domains.js",
39
39
  "./EpicBrief": "./dist/EpicBrief.js",
40
40
  "./EpicRuns": "./dist/EpicRuns.js",
41
+ "./OperatorSchemas": "./dist/OperatorSchemas.js",
41
42
  "./Equiv": "./dist/Equiv.js",
42
43
  "./EquivReport": "./dist/EquivReport.js",
43
44
  "./EstimatedUsage": "./dist/EstimatedUsage.js",
@@ -71,6 +72,7 @@
71
72
  "./OracleGuard": "./dist/OracleGuard.js",
72
73
  "./Orientation": "./dist/Orientation.js",
73
74
  "./Persistence": "./dist/Persistence.js",
75
+ "./RunState": "./dist/RunState.js",
74
76
  "./Plan": "./dist/Plan.js",
75
77
  "./PlanExecution": "./dist/PlanExecution.js",
76
78
  "./Port": "./dist/Port.js",
@@ -127,7 +129,7 @@
127
129
  "typescript"
128
130
  ],
129
131
  "dependencies": {
130
- "@llm4ts/core": "2.45.0"
132
+ "@llm4ts/core": "2.46.0"
131
133
  },
132
134
  "peerDependencies": {
133
135
  "effect": "4.0.0"
package/src/Approval.ts CHANGED
@@ -28,6 +28,12 @@ export class MissingModernizeArtifact extends Schema.TaggedError<MissingModerniz
28
28
  export const ApprovedMarker = "- [x] Approved"
29
29
  export const DraftApprovalMarker = "- [ ] Approved"
30
30
 
31
+ /** `- [ ] Approved` → `- [x] Approved` in a markdown document; unchanged when already approved. */
32
+ export const approveMarkdown = (markdown: string): string =>
33
+ markdown.includes(ApprovedMarker)
34
+ ? markdown
35
+ : markdown.replace(DraftApprovalMarker, ApprovedMarker)
36
+
31
37
  export const withDraftApproval = (markdown: string): string =>
32
38
  `${markdown.trimEnd()}\n\n${DraftApprovalMarker}\n`
33
39
 
@@ -17,6 +17,12 @@ const workspace = [
17
17
  "files only where your instructions name them. What it expects of you is in your instructions."
18
18
  ]
19
19
 
20
+ const runState = [
21
+ "The directory `.llm4ts/` at the root of your working directory is the run's own state — traces,",
22
+ "plans, transcripts and costs that belong to the tool running you. Never read, modify, delete or",
23
+ "commit anything under it; the one exception is a path there that your instructions name as yours."
24
+ ]
25
+
20
26
  const noGaming = [
21
27
  "Never edit, skip, delete or weaken a test to make a gate pass. A test you believe is wrong is a",
22
28
  "finding to report, not a line to change; a pre-existing bug outside your task is a finding, not",
@@ -41,8 +47,8 @@ const evidence = [
41
47
 
42
48
  /**
43
49
  * The contract for a profile. `full` is the default; `minimal` keeps scope,
44
- * the workspace rule and the no-gaming rule for a model that over-verifies
45
- * under the full text;
50
+ * the workspace rule, the run-state rule and the no-gaming rule for a model
51
+ * that over-verifies under the full text;
46
52
  * `off` is the empty string, for a flow that writes its own rules.
47
53
  */
48
54
  export const autonomyContract = (profile: ContractProfile = "full"): string => {
@@ -50,12 +56,13 @@ export const autonomyContract = (profile: ContractProfile = "full"): string => {
50
56
  case "off":
51
57
  return ""
52
58
  case "minimal":
53
- return [...scope, ...workspace, ...noGaming].join("\n")
59
+ return [...scope, ...workspace, ...runState, ...noGaming].join("\n")
54
60
  case "full":
55
61
  return [
56
62
  ...unattended,
57
63
  ...scope,
58
64
  ...workspace,
65
+ ...runState,
59
66
  ...minimalChange,
60
67
  ...noGaming,
61
68
  ...evidence
package/src/CostReport.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import * as DateTime from "effect/DateTime"
2
+ import * as Effect from "effect/Effect"
2
3
  import * as Option from "effect/Option"
3
4
  import * as Schema from "effect/Schema"
4
5
  import type { TokenUsage } from "@llm4ts/core/Models"
6
+ import type { CostRecord } from "./CostLedger.ts"
5
7
  import { isEstimatedModel } from "./EstimatedUsage.ts"
6
8
  import { TokensUsed, cloneName } from "./FlowEvents.ts"
7
9
  import type { TraceLine } from "./FlowRecorder.ts"
@@ -16,10 +18,22 @@ import { estimateCostUsd, PricesAsOf } from "./PriceList.ts"
16
18
  * backend's own token counts) and estimated usage (`EstimatedUsage`'s
17
19
  * character-count fallback, labelled `estimated:<model>`) are kept in separate
18
20
  * columns so a budget never mistakes one for the other.
21
+ *
22
+ * The trace is the source for timing, the cost ledger (`CostLedger`) for the
23
+ * run's total: both come from the same `TokensUsed` events, but the trace is
24
+ * a file in the target's working tree that a coder's shell, a `git clean`
25
+ * or a recorder that lost its disk can shorten, while the ledger line is
26
+ * written once from the run's own cost tracker, the same numbers the run
27
+ * printed. `reconcileWithLedger` tops a short trace up from its ledger line,
28
+ * so `llm4ts costs` and the run's summary agree.
19
29
  */
20
30
 
21
31
  export const UnknownModel = "(unknown)"
22
32
 
33
+ /** Where a sample's numbers came from: a trace line, or the run's ledger line filling a short trace. */
34
+ export const UsageSource = Schema.Literals(["trace", "ledger"])
35
+ export type UsageSource = typeof UsageSource.Type
36
+
23
37
  export class UsageSample extends Schema.Class<UsageSample>("UsageSample")({
24
38
  /** Epoch milliseconds of the token report. */
25
39
  at: Schema.Number,
@@ -34,7 +48,11 @@ export class UsageSample extends Schema.Class<UsageSample>("UsageSample")({
34
48
  estimated: Schema.Boolean,
35
49
  /** The lane's executor and clone, when the usage came from a roster lane (ADR 0033). */
36
50
  executor: Schema.optionalKey(Schema.String),
37
- clone: Schema.optionalKey(Schema.Int)
51
+ clone: Schema.optionalKey(Schema.Int),
52
+ source: UsageSource.pipe(
53
+ Schema.withConstructorDefault(Effect.succeed("trace" as const)),
54
+ Schema.withDecodingDefaultKey(Effect.succeed("trace" as const))
55
+ )
38
56
  }) {}
39
57
 
40
58
  export class UsageTotals extends Schema.Class<UsageTotals>("UsageTotals")({
@@ -173,7 +191,8 @@ const totalsOf = (samples: ReadonlyArray<UsageSample>): UsageTotals => {
173
191
  const cached = sumOptional(samples.map((sample) => sample.cached))
174
192
  const costUsd = sumOptional(samples.map((sample) => sample.costUsd))
175
193
  return UsageTotals.make({
176
- requests: samples.length,
194
+ // A ledger sample is a remainder, not a call the trace saw.
195
+ requests: samples.filter((sample) => sample.source === "trace").length,
177
196
  prompt: samples.reduce((sum, sample) => sum + sample.prompt, 0),
178
197
  completion: samples.reduce((sum, sample) => sum + sample.completion, 0),
179
198
  total: samples.reduce((sum, sample) => sum + sample.total, 0),
@@ -182,6 +201,137 @@ const totalsOf = (samples: ReadonlyArray<UsageSample>): UsageTotals => {
182
201
  })
183
202
  }
184
203
 
204
+ interface UsageSums {
205
+ prompt: number
206
+ completion: number
207
+ total: number
208
+ cached: number | undefined
209
+ costUsd: number | undefined
210
+ }
211
+
212
+ const emptySums = (): UsageSums => ({
213
+ prompt: 0,
214
+ completion: 0,
215
+ total: 0,
216
+ cached: undefined,
217
+ costUsd: undefined
218
+ })
219
+
220
+ const addSums = (into: UsageSums, usage: UsageSums): UsageSums => ({
221
+ prompt: into.prompt + usage.prompt,
222
+ completion: into.completion + usage.completion,
223
+ total: into.total + usage.total,
224
+ cached: sumOptional([into.cached, usage.cached]),
225
+ costUsd: sumOptional([into.costUsd, usage.costUsd])
226
+ })
227
+
228
+ const positive = (value: number): number => (value > 0 ? value : 0)
229
+
230
+ /** What `b` has beyond `a`, per field, never below zero. */
231
+ const remainderOf = (ledger: UsageSums, trace: UsageSums): UsageSums => ({
232
+ prompt: positive(ledger.prompt - trace.prompt),
233
+ completion: positive(ledger.completion - trace.completion),
234
+ total: positive(ledger.total - trace.total),
235
+ cached: ledger.cached === undefined ? undefined : positive(ledger.cached - (trace.cached ?? 0)),
236
+ costUsd:
237
+ ledger.costUsd === undefined ? undefined : positive(ledger.costUsd - (trace.costUsd ?? 0))
238
+ })
239
+
240
+ const hasRemainder = (sums: UsageSums): boolean =>
241
+ sums.prompt > 0 ||
242
+ sums.completion > 0 ||
243
+ sums.total > 0 ||
244
+ (sums.cached ?? 0) > 0 ||
245
+ (sums.costUsd ?? 0) > 1e-9
246
+
247
+ interface UsageRow {
248
+ readonly agent: string
249
+ readonly model: string
250
+ readonly prompt: number
251
+ readonly completion: number
252
+ readonly total: number
253
+ readonly cached?: number
254
+ readonly costUsd?: number
255
+ }
256
+
257
+ /** A run's sums per agent and model, from its trace samples or its ledger cells. */
258
+ const sumsByAgentModel = (
259
+ rows: ReadonlyArray<UsageRow>
260
+ ): ReadonlyMap<
261
+ string,
262
+ { readonly agent: string; readonly model: string; readonly sums: UsageSums }
263
+ > => {
264
+ const groups = new Map<string, { agent: string; model: string; sums: UsageSums }>()
265
+ for (const row of rows) {
266
+ const key = JSON.stringify([row.agent, row.model])
267
+ const group = groups.get(key) ?? { agent: row.agent, model: row.model, sums: emptySums() }
268
+ groups.set(key, {
269
+ ...group,
270
+ sums: addSums(group.sums, {
271
+ prompt: row.prompt,
272
+ completion: row.completion,
273
+ total: row.total,
274
+ cached: row.cached,
275
+ costUsd: row.costUsd
276
+ })
277
+ })
278
+ }
279
+ return groups
280
+ }
281
+
282
+ export interface LedgerReconciliation {
283
+ readonly samples: ReadonlyArray<UsageSample>
284
+ /** Runs whose ledger line carried usage the trace did not: filled, in ledger order. */
285
+ readonly filledRuns: ReadonlyArray<string>
286
+ }
287
+
288
+ /**
289
+ * Tops the trace's samples up from the ledger: for every ledger record, what
290
+ * its cells hold beyond the trace's samples of that run, per agent and
291
+ * model, becomes one `ledger` sample at the run's start (the only time the
292
+ * ledger knows). A run with no trace at all is taken wholly from its line;
293
+ * a run whose trace matches its line adds nothing. The result's total
294
+ * therefore equals the ledger's, which is what the run itself printed.
295
+ */
296
+ export const reconcileWithLedger = (
297
+ samples: ReadonlyArray<UsageSample>,
298
+ records: ReadonlyArray<CostRecord>
299
+ ): LedgerReconciliation => {
300
+ const filled: Array<UsageSample> = []
301
+ const filledRuns: Array<string> = []
302
+ for (const record of records) {
303
+ const traced = sumsByAgentModel(samples.filter((sample) => sample.runId === record.runId))
304
+ const ledgered = sumsByAgentModel(record.cells)
305
+ let filledThisRun = false
306
+ for (const [key, { agent, model, sums }] of ledgered) {
307
+ const remainder = remainderOf(sums, traced.get(key)?.sums ?? emptySums())
308
+ if (!hasRemainder(remainder)) {
309
+ continue
310
+ }
311
+ filledThisRun = true
312
+ filled.push(
313
+ UsageSample.make({
314
+ at: Date.parse(record.at),
315
+ runId: record.runId,
316
+ agent,
317
+ model,
318
+ prompt: remainder.prompt,
319
+ completion: remainder.completion,
320
+ total: remainder.total,
321
+ ...(remainder.cached === undefined ? {} : { cached: remainder.cached }),
322
+ ...(remainder.costUsd === undefined ? {} : { costUsd: remainder.costUsd }),
323
+ estimated: isEstimatedModel(model),
324
+ source: "ledger"
325
+ })
326
+ )
327
+ }
328
+ if (filledThisRun) {
329
+ filledRuns.push(record.runId)
330
+ }
331
+ }
332
+ return { samples: [...samples, ...filled], filledRuns }
333
+ }
334
+
185
335
  const distinctRuns = (samples: ReadonlyArray<UsageSample>): number =>
186
336
  new Set(samples.map((sample) => sample.runId)).size
187
337
 
@@ -24,9 +24,18 @@ export class TraceLine extends Schema.Class<TraceLine>("TraceLine")({
24
24
  export const RunOutcome = Schema.Literals(["completed", "failed", "interrupted"])
25
25
  export type RunOutcome = typeof RunOutcome.Type
26
26
 
27
+ /** Lines the recorder could not write: the trace is short by this much, from the first failure on. */
28
+ export interface TraceShortfall {
29
+ readonly lines: number
30
+ /** The first write failure, as the file store described it. */
31
+ readonly reason: string
32
+ }
33
+
27
34
  export interface FlowRecorderShape extends StreamRecorder {
28
35
  readonly runId: string
29
36
  readonly tracePath: string
37
+ /** What the trace is missing, once a write failed; `undefined` while every line landed. */
38
+ readonly shortfall: Effect.Effect<TraceShortfall | undefined>
30
39
  readonly record: (event: FlowEvent) => Effect.Effect<void>
31
40
  readonly consume: (hub: FlowEventHub) => Effect.Effect<void, never, Scope.Scope>
32
41
  /** Appends the `RunEnded` line: a reader knows the run is over, not stalled. */
@@ -53,12 +62,18 @@ export const makeFlowRecorder = Effect.fn("@llm4ts/flow/FlowRecorder.make")(func
53
62
  const sequence = yield* Ref.make(0)
54
63
  const consumed = yield* Ref.make(0)
55
64
  const lock = yield* Semaphore.make(1)
56
- const degraded = yield* Ref.make(false)
65
+ // Set by the first failed write and never cleared: a disk that refused one
66
+ // line is not asked again, but every line it then loses is counted, so
67
+ // the run can say how short its trace is (`llm4ts costs` reads the
68
+ // ledger for the rest).
69
+ const degraded = yield* Ref.make<TraceShortfall | undefined>(undefined)
57
70
 
58
71
  const append = (kind: string, fields: Readonly<Record<string, JsonValue>>): Effect.Effect<void> =>
59
72
  lock.withPermit(
60
73
  Effect.gen(function* () {
61
- if (yield* Ref.get(degraded)) {
74
+ const shortfall = yield* Ref.get(degraded)
75
+ if (shortfall !== undefined) {
76
+ yield* Ref.set(degraded, { ...shortfall, lines: shortfall.lines + 1 })
62
77
  return
63
78
  }
64
79
  const seq = yield* Ref.getAndUpdate(sequence, (current) => current + 1)
@@ -73,7 +88,7 @@ export const makeFlowRecorder = Effect.fn("@llm4ts/flow/FlowRecorder.make")(func
73
88
  })
74
89
  yield* files
75
90
  .append(tracePath, `${encode(line)}\n`)
76
- .pipe(Effect.catch(() => Ref.set(degraded, true)))
91
+ .pipe(Effect.catch((error) => Ref.set(degraded, { lines: 1, reason: error.message })))
77
92
  })
78
93
  )
79
94
 
@@ -83,6 +98,7 @@ export const makeFlowRecorder = Effect.fn("@llm4ts/flow/FlowRecorder.make")(func
83
98
  const recorder: FlowRecorderShape = {
84
99
  runId,
85
100
  tracePath,
101
+ shortfall: Ref.get(degraded),
86
102
  record,
87
103
  rawLine: (provider, model, line) =>
88
104
  append("RawLine", {
package/src/GateTriage.ts CHANGED
@@ -16,6 +16,97 @@ export type GateClass = typeof GateClass.Type
16
16
  export const FailureOrigin = Schema.Literals(["new", "base", "flaky"])
17
17
  export type FailureOrigin = typeof FailureOrigin.Type
18
18
 
19
+ /**
20
+ * Why a test gate's exit code proves nothing: the runner collected or ran
21
+ * zero tests, or test files failed to load (an import that no longer
22
+ * resolves, a suite that errors before its first test). Either makes the
23
+ * gate red whatever the runner's exit code was.
24
+ */
25
+ export const GateReason = Schema.Literals(["zero-tests", "load-failure"])
26
+ export type GateReason = typeof GateReason.Type
27
+
28
+ // One suite that errored before its tests ran, per runner: Vitest's
29
+ // "Failed Suites", Jest's "Test suite failed to run", pytest's collection
30
+ // errors, JUnit's class initialisation error under Surefire.
31
+ const loadFailureMarkers: ReadonlyArray<RegExp> = [
32
+ /Failed Suites\s+\d+/u,
33
+ /Test suite failed to run/u,
34
+ /\berrors? during collection\b/u,
35
+ /ExceptionInInitializerError|initializationError/u
36
+ ]
37
+ const vitestNoTests = /\bTests\s+no tests\b/u
38
+ const vitestFilesFailed = /\bTest Files\s+\d+ failed\b/u
39
+ // Nothing collected: Vitest, Jest, pytest, Surefire, Gradle.
40
+ const zeroTestsMarkers: ReadonlyArray<RegExp> = [
41
+ /No test files found/u,
42
+ vitestNoTests,
43
+ /No tests found, exiting with code/u,
44
+ /\bTests:\s+0 total\b/u,
45
+ /\bno tests ran\b/u,
46
+ /\bcollected 0 items\b/u,
47
+ /No tests to run\./u,
48
+ /No tests found for given includes/u
49
+ ]
50
+ const cargoRunning = /\brunning (\d+) tests?\b/gu
51
+ const junitRun = /\bTests run:\s*(\d+)/gu
52
+ const mochaPassing = /^\s*0 passing\b/mu
53
+ const mochaFailing = /^\s*\d+ failing\b/mu
54
+
55
+ const everyCountZero = (pattern: RegExp, output: string): boolean => {
56
+ let seen = false
57
+ pattern.lastIndex = 0
58
+ for (let match = pattern.exec(output); match !== null; match = pattern.exec(output)) {
59
+ seen = true
60
+ if (Number.parseInt(match[1] ?? "0", 10) > 0) {
61
+ return false
62
+ }
63
+ }
64
+ return seen
65
+ }
66
+
67
+ /**
68
+ * The reason a test runner's output cannot stand for a green gate, when
69
+ * it has one: `load-failure` when any suite errored before its tests ran
70
+ * (it wins, since a load failure also leaves tests unrun), `zero-tests`
71
+ * when the runner collected nothing (Vitest, Jest, pytest, cargo test,
72
+ * mocha, JUnit via Maven or Gradle). `undefined` means tests ran.
73
+ */
74
+ export const testGateReasonOf = (output: string): GateReason | undefined => {
75
+ if (loadFailureMarkers.some((pattern) => pattern.test(output))) {
76
+ return "load-failure"
77
+ }
78
+ if (vitestNoTests.test(output) && vitestFilesFailed.test(output)) {
79
+ return "load-failure"
80
+ }
81
+ if (zeroTestsMarkers.some((pattern) => pattern.test(output))) {
82
+ return "zero-tests"
83
+ }
84
+ if (everyCountZero(cargoRunning, output) || everyCountZero(junitRun, output)) {
85
+ return "zero-tests"
86
+ }
87
+ if (mochaPassing.test(output) && !mochaFailing.test(output)) {
88
+ return "zero-tests"
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ /** The reason as the fix prompt and the findings read it. */
94
+ export const describeGateReason = (reason: GateReason): string =>
95
+ reason === "zero-tests"
96
+ ? "test gate ran zero tests (zero-tests): the runner collected no test, so its exit code proves nothing"
97
+ : "test files failed to load (load-failure): a suite errored before its tests ran, so the gate is red whatever its exit code"
98
+
99
+ /**
100
+ * The reason as a failing line, so a baseline records it and triage
101
+ * compares it like any other line: a target whose base already ran zero
102
+ * tests inherits that, a change that breaks loading is charged with it.
103
+ */
104
+ export const gateReasonLine = (reason: GateReason): string => `gate reason: ${reason}`
105
+
106
+ /** The first gate reason a result carries, if any. */
107
+ export const gateReasonOf = (result: ReviewResult): GateReason | undefined =>
108
+ result.issues.find((issue) => isGateIssue(issue) && issue.gateReason !== undefined)?.gateReason
109
+
19
110
  export class GateBaseline extends Schema.Class<GateBaseline>("GateBaseline")({
20
111
  baseCommit: Schema.String,
21
112
  appDir: Schema.String,
@@ -87,17 +178,22 @@ const legacyGateIssuePrefix = "lint failed: "
87
178
  export const isGateIssue = (issue: ReviewIssue): boolean =>
88
179
  issue.title.startsWith(gateIssuePrefix) || issue.title.startsWith(legacyGateIssuePrefix)
89
180
 
181
+ /** A gate issue's comparable lines: its reason first, then its normalized output. */
182
+ const failingLinesOfIssue = (
183
+ issue: ReviewIssue,
184
+ roots: ReadonlyArray<string>
185
+ ): ReadonlyArray<string> => [
186
+ ...(issue.gateReason === undefined ? [] : [gateReasonLine(issue.gateReason)]),
187
+ ...normalizeGateOutput(issue.description, roots)
188
+ ]
189
+
90
190
  /** Every gate issue's normalized failing lines, in order, each once. */
91
191
  export const failingLinesOf = (
92
192
  result: ReviewResult,
93
193
  roots: ReadonlyArray<string>
94
194
  ): ReadonlyArray<string> =>
95
195
  Array.from(
96
- new Set(
97
- result.issues
98
- .filter(isGateIssue)
99
- .flatMap((issue) => normalizeGateOutput(issue.description, roots))
100
- )
196
+ new Set(result.issues.filter(isGateIssue).flatMap((issue) => failingLinesOfIssue(issue, roots)))
101
197
  )
102
198
 
103
199
  export interface GateTriage {
@@ -134,18 +230,28 @@ export const triageGates = (
134
230
  issues.push(issue)
135
231
  continue
136
232
  }
137
- const lines = normalizeGateOutput(issue.description, roots)
233
+ const lines = failingLinesOfIssue(issue, roots)
138
234
  const fresh = lines.filter((line) => !known.has(line))
139
235
  inherited.push(...lines.filter((line) => known.has(line)))
140
236
  if (lines.length > 0 && fresh.length === 0) {
141
237
  continue
142
238
  }
143
239
  newLines.push(...fresh)
240
+ // A fresh reason keeps its sentence, so the fix prompt says what the
241
+ // runner's output meant, not only which lines were new.
242
+ const reasonLine = issue.gateReason === undefined ? undefined : gateReasonLine(issue.gateReason)
243
+ const freshOutput = fresh.filter((line) => line !== reasonLine)
244
+ const description =
245
+ issue.gateReason !== undefined && fresh.includes(reasonLine ?? "")
246
+ ? [describeGateReason(issue.gateReason), ...freshOutput].join("\n")
247
+ : fresh.length > 0
248
+ ? fresh.join("\n")
249
+ : issue.description
144
250
  issues.push(
145
251
  ReviewIssue.make({
146
252
  ...issue,
147
253
  origin: "new",
148
- description: fresh.length > 0 ? fresh.join("\n") : issue.description
254
+ description
149
255
  })
150
256
  )
151
257
  }
package/src/GitTool.ts CHANGED
@@ -6,6 +6,7 @@ import { MergeConflict, ProcessError, type FlowError } from "./FlowError.ts"
6
6
  import { Began, Timed, type FlowEventsShape } from "./FlowEvents.ts"
7
7
  import { timeEffect } from "./Timing.ts"
8
8
  import { guarded } from "./CapabilityGuard.ts"
9
+ import { RunStateDirectory } from "./RunState.ts"
9
10
 
10
11
  export class BranchCreated extends Schema.TaggedClass<BranchCreated>()("Created", {}) {}
11
12
  export class BranchAlreadyExists extends Schema.TaggedClass<BranchAlreadyExists>()(
@@ -149,15 +150,20 @@ const problem = (result: ProcessResult): string => {
149
150
  }
150
151
 
151
152
  /**
152
- * The runner's own bookkeeping under `workDir/.llm4ts/`: the run trace and
153
- * the cost ledger. `commitAll` never stages them — they grow while the run
154
- * commits and belong to the machine, not the repository — while the rest of
155
- * `.llm4ts/` (plans, forked packs) is committed as before.
153
+ * The run's own state under `workDir/.llm4ts/`: traces, the cost ledger,
154
+ * plans, transcripts, epics. `commitAll` never stages it — it grows while
155
+ * the run commits and belongs to the machine, not the repository (a
156
+ * committed trace was read by every reviewer at real cost). `commitPaths`
157
+ * stages exactly what it is given, so a flow that keeps something there on
158
+ * purpose (a forked pack, a port ledger) names it.
156
159
  */
157
- export const runnerBookkeeping: ReadonlyArray<string> = [
158
- ".llm4ts/trace-*.jsonl",
159
- ".llm4ts/costs.jsonl"
160
- ]
160
+ export const runStatePaths: ReadonlyArray<string> = [RunStateDirectory]
161
+
162
+ /** Whether a repository-relative path is the run state or inside it. */
163
+ export const isRunStatePath = (path: string): boolean => {
164
+ const normalized = path.replaceAll("\\", "/").replace(/^\.\//u, "")
165
+ return normalized === RunStateDirectory || normalized.startsWith(`${RunStateDirectory}/`)
166
+ }
161
167
 
162
168
  export const makeGitTool = (
163
169
  process: ProcessExecutorShape,
@@ -277,12 +283,14 @@ export const makeGitTool = (
277
283
  const checkout = (name: string): Effect.Effect<void, FlowError> =>
278
284
  write("git checkout", runOrFail(["checkout", name]).pipe(Effect.asVoid))
279
285
 
280
- // Stage everything, then take the runner's bookkeeping back out of the
281
- // index: `reset -- <glob>` leaves an ignored or unmatched glob alone and
282
- // keeps a previously committed trace at its HEAD version, whereas an
283
- // `:(exclude)` pathspec makes `add` refuse the ignored files outright.
286
+ // Stage everything, then take the run state back out of the index:
287
+ // `reset -- <path>` leaves an ignored or missing path alone and keeps a
288
+ // previously committed plan or trace at its HEAD version (so neither its
289
+ // change nor its deletion is staged), whereas an `:(exclude)` pathspec
290
+ // makes `add` refuse the ignored files outright. The `.gitignore` the
291
+ // runner leaves there (`RunState`) already keeps new files out of `add`.
284
292
  const addAll = runOrFail(["add", "-A"]).pipe(
285
- Effect.andThen(runOrFail(["reset", "-q", "--", ...runnerBookkeeping])),
293
+ Effect.andThen(runOrFail(["reset", "-q", "--", ...runStatePaths])),
286
294
  Effect.asVoid
287
295
  )
288
296
 
@@ -294,9 +302,15 @@ export const makeGitTool = (
294
302
  }
295
303
  // "nothing to commit, working tree clean" when the tree is clean;
296
304
  // "nothing added to commit but untracked files present" when the only
297
- // changes are files this commit was not asked to stage.
305
+ // changes are files this commit was not asked to stage; "no changes
306
+ // added to commit" when the only changes are to tracked files it was
307
+ // not asked to stage (a plan under the run state, revised by the run).
298
308
  const detail = problem(result)
299
- if (detail.includes("nothing to commit") || detail.includes("nothing added to commit")) {
309
+ if (
310
+ detail.includes("nothing to commit") ||
311
+ detail.includes("nothing added to commit") ||
312
+ detail.includes("no changes added to commit")
313
+ ) {
300
314
  return new NothingToCommit()
301
315
  }
302
316
  return yield* ProcessError.make({
@@ -311,13 +325,27 @@ export const makeGitTool = (
311
325
  // `add -A -- <paths>` stages additions, modifications, AND deletions of
312
326
  // exactly those paths; a plain `add` would refuse a deleted file. `--only`
313
327
  // on commit would also work but ignores paths that were never tracked.
328
+ // A path under the run state is force-added: it is wanted (a forked
329
+ // pack, a port ledger) where the runner's own ignore file would refuse
330
+ // it. Any other path keeps the repository's ignore rules.
314
331
  const commitPathsEffect = (
315
332
  message: string,
316
333
  paths: ReadonlyArray<string>
317
- ): Effect.Effect<CommitResult, FlowError> =>
318
- paths.length === 0
334
+ ): Effect.Effect<CommitResult, FlowError> => {
335
+ const underRunState = paths.filter(isRunStatePath)
336
+ const elsewhere = paths.filter((path) => !isRunStatePath(path))
337
+ return paths.length === 0
319
338
  ? Effect.succeed(new NothingToCommit())
320
- : runOrFail(["add", "-A", "--", ...paths]).pipe(Effect.andThen(commitStaged(message)))
339
+ : Effect.gen(function* () {
340
+ if (elsewhere.length > 0) {
341
+ yield* runOrFail(["add", "-A", "--", ...elsewhere])
342
+ }
343
+ if (underRunState.length > 0) {
344
+ yield* runOrFail(["add", "-A", "-f", "--", ...underRunState])
345
+ }
346
+ return yield* commitStaged(message)
347
+ })
348
+ }
321
349
 
322
350
  return {
323
351
  init: write(