@llm4ts/flow 2.45.0 → 2.47.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 (71) 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/FlowEvents.d.ts +25 -1
  14. package/dist/FlowEvents.d.ts.map +1 -1
  15. package/dist/FlowEvents.js +25 -1
  16. package/dist/FlowEvents.js.map +1 -1
  17. package/dist/FlowRecorder.d.ts +8 -0
  18. package/dist/FlowRecorder.d.ts.map +1 -1
  19. package/dist/FlowRecorder.js +10 -3
  20. package/dist/FlowRecorder.js.map +1 -1
  21. package/dist/GateTriage.d.ts +26 -0
  22. package/dist/GateTriage.d.ts.map +1 -1
  23. package/dist/GateTriage.js +98 -5
  24. package/dist/GateTriage.js.map +1 -1
  25. package/dist/GitTool.d.ts +9 -5
  26. package/dist/GitTool.d.ts.map +1 -1
  27. package/dist/GitTool.js +44 -18
  28. package/dist/GitTool.js.map +1 -1
  29. package/dist/OperatorSchemas.d.ts +256 -0
  30. package/dist/OperatorSchemas.d.ts.map +1 -0
  31. package/dist/OperatorSchemas.js +204 -0
  32. package/dist/OperatorSchemas.js.map +1 -0
  33. package/dist/Retro.d.ts +2 -0
  34. package/dist/Retro.d.ts.map +1 -1
  35. package/dist/Retro.js +6 -0
  36. package/dist/Retro.js.map +1 -1
  37. package/dist/Review.d.ts +2 -0
  38. package/dist/Review.d.ts.map +1 -1
  39. package/dist/Review.js +17 -9
  40. package/dist/Review.js.map +1 -1
  41. package/dist/RunState.d.ts +26 -0
  42. package/dist/RunState.d.ts.map +1 -0
  43. package/dist/RunState.js +59 -0
  44. package/dist/RunState.js.map +1 -0
  45. package/dist/Stories.d.ts +30 -0
  46. package/dist/Stories.d.ts.map +1 -1
  47. package/dist/Stories.js +54 -1
  48. package/dist/Stories.js.map +1 -1
  49. package/dist/StoryPlan.d.ts +6 -0
  50. package/dist/StoryPlan.d.ts.map +1 -1
  51. package/dist/StoryPlan.js +7 -0
  52. package/dist/StoryPlan.js.map +1 -1
  53. package/dist/TestEvidence.d.ts +55 -0
  54. package/dist/TestEvidence.d.ts.map +1 -0
  55. package/dist/TestEvidence.js +90 -0
  56. package/dist/TestEvidence.js.map +1 -0
  57. package/package.json +5 -2
  58. package/src/Approval.ts +6 -0
  59. package/src/AutonomyContract.ts +10 -3
  60. package/src/CostReport.ts +152 -2
  61. package/src/FlowEvents.ts +25 -1
  62. package/src/FlowRecorder.ts +19 -3
  63. package/src/GateTriage.ts +113 -7
  64. package/src/GitTool.ts +46 -18
  65. package/src/OperatorSchemas.ts +221 -0
  66. package/src/Retro.ts +11 -0
  67. package/src/Review.ts +25 -11
  68. package/src/RunState.ts +72 -0
  69. package/src/Stories.ts +113 -3
  70. package/src/StoryPlan.ts +7 -0
  71. package/src/TestEvidence.ts +137 -0
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(
@@ -0,0 +1,221 @@
1
+ import * as Option from "effect/Option"
2
+ import * as Schema from "effect/Schema"
3
+ import { AuthStatus, Availability } from "@llm4ts/core/Models"
4
+ import { BoardStatus } from "./BoardSync.ts"
5
+ import { CostReport } from "./CostReport.ts"
6
+ import { RunOutcome } from "./FlowRecorder.ts"
7
+ import { StoryStatus } from "./Stories.ts"
8
+
9
+ /** Where a run the operator started stands, as the registry last recorded it. */
10
+ export const OperatorRunState = Schema.Literals([
11
+ "starting",
12
+ "running",
13
+ "exited",
14
+ "interrupted",
15
+ "lost"
16
+ ])
17
+ export type OperatorRunState = typeof OperatorRunState.Type
18
+
19
+ /** The typed knobs a client may set when it starts a run; every one is optional. */
20
+ export const OperatorKnobs = Schema.Struct({
21
+ task: Schema.optionalKey(Schema.String),
22
+ epic: Schema.optionalKey(Schema.String),
23
+ pack: Schema.optionalKey(Schema.String),
24
+ executors: Schema.optionalKey(Schema.Array(Schema.String)),
25
+ coder: Schema.optionalKey(Schema.String),
26
+ judgeRounds: Schema.optionalKey(Schema.Int),
27
+ mergeRevisions: Schema.optionalKey(Schema.Int),
28
+ /** The branch to land. */
29
+ land: Schema.optionalKey(Schema.String),
30
+ flags: Schema.optionalKey(Schema.Array(Schema.String)),
31
+ requestId: Schema.optionalKey(Schema.String)
32
+ })
33
+ export type OperatorKnobs = typeof OperatorKnobs.Type
34
+
35
+ /** One registry record: a run the operator started, appended on every state change. */
36
+ export class OperatorRun extends Schema.Class<OperatorRun>("OperatorRun")({
37
+ runId: Schema.String,
38
+ repo: Schema.String,
39
+ flow: Schema.String,
40
+ args: Schema.Array(Schema.String),
41
+ knobs: OperatorKnobs,
42
+ startedAt: Schema.Number,
43
+ pid: Schema.optionalKey(Schema.Int),
44
+ tracePath: Schema.optionalKey(Schema.String),
45
+ logDir: Schema.String,
46
+ state: OperatorRunState,
47
+ exitCode: Schema.optionalKey(Schema.Int),
48
+ signal: Schema.optionalKey(Schema.String),
49
+ endedAt: Schema.optionalKey(Schema.Number),
50
+ requestId: Schema.optionalKey(Schema.String)
51
+ }) {}
52
+
53
+ export const decodeOperatorRunLine = Schema.decodeUnknownOption(Schema.fromJsonString(OperatorRun))
54
+ export const encodeOperatorRunLine = Schema.encodeSync(Schema.fromJsonString(OperatorRun))
55
+
56
+ /** The last record per run, newest `startedAt` first; a line that does not decode is skipped. */
57
+ export const latestRuns = (lines: ReadonlyArray<string>): ReadonlyArray<OperatorRun> => {
58
+ const byRun = new Map<string, OperatorRun>()
59
+ for (const line of lines) {
60
+ if (line.trim().length === 0) continue
61
+ for (const run of Option.toArray(decodeOperatorRunLine(line))) byRun.set(run.runId, run)
62
+ }
63
+ return [...byRun.values()].sort((a, b) => b.startedAt - a.startedAt)
64
+ }
65
+
66
+ export const LaneStatusOut = Schema.Struct({
67
+ id: Schema.String,
68
+ status: Schema.Literals(["running", "done", "failed"]),
69
+ stage: Schema.optionalKey(Schema.String),
70
+ executor: Schema.optionalKey(Schema.String),
71
+ activity: Schema.optionalKey(Schema.String),
72
+ lastEventAgeMs: Schema.Int,
73
+ costUsd: Schema.Number
74
+ })
75
+ export type LaneStatusOut = typeof LaneStatusOut.Type
76
+
77
+ export class RunStatus extends Schema.Class<RunStatus>("RunStatus")({
78
+ runId: Schema.String,
79
+ state: OperatorRunState,
80
+ ended: Schema.optionalKey(RunOutcome),
81
+ elapsedMs: Schema.Int,
82
+ lanes: Schema.Array(LaneStatusOut),
83
+ stories: Schema.Array(Schema.Struct({ id: Schema.String, status: BoardStatus })),
84
+ tokens: Schema.Int,
85
+ costUsd: Schema.Number,
86
+ warnings: Schema.Array(Schema.String),
87
+ seq: Schema.Int,
88
+ summary: Schema.String
89
+ }) {}
90
+
91
+ export const MeaningfulEvent = Schema.Struct({
92
+ seq: Schema.Int,
93
+ at: Schema.Number,
94
+ kind: Schema.String,
95
+ lane: Schema.optionalKey(Schema.String),
96
+ text: Schema.String
97
+ })
98
+ export type MeaningfulEvent = typeof MeaningfulEvent.Type
99
+
100
+ export class RunWaitResult extends Schema.Class<RunWaitResult>("RunWaitResult")({
101
+ events: Schema.Array(MeaningfulEvent),
102
+ seq: Schema.Int,
103
+ ended: Schema.optionalKey(RunOutcome),
104
+ exited: Schema.Boolean,
105
+ summary: Schema.String
106
+ }) {}
107
+
108
+ export const ConnectorHealthOut = Schema.Struct({
109
+ id: Schema.String,
110
+ availability: Availability,
111
+ authStatus: AuthStatus,
112
+ // "auth": the CLI's own login check answered; "version": only `--version`
113
+ // ran, which proves no live login; "unknown": nothing more can be said.
114
+ probe: Schema.Literals(["version", "auth", "unknown"]),
115
+ latencyMs: Schema.optionalKey(Schema.Int)
116
+ })
117
+ export type ConnectorHealthOut = typeof ConnectorHealthOut.Type
118
+
119
+ export const ExecutorHealthOut = Schema.Struct({
120
+ id: Schema.String,
121
+ harness: Schema.String,
122
+ roles: Schema.Array(Schema.String),
123
+ disabled: Schema.Boolean,
124
+ healthy: Schema.optionalKey(Schema.Boolean)
125
+ })
126
+ export type ExecutorHealthOut = typeof ExecutorHealthOut.Type
127
+
128
+ export class PreflightResult extends Schema.Class<PreflightResult>("PreflightResult")({
129
+ llm4ts: Schema.String,
130
+ node: Schema.String,
131
+ connectors: Schema.Array(ConnectorHealthOut),
132
+ executors: Schema.Array(ExecutorHealthOut),
133
+ notes: Schema.Array(Schema.String)
134
+ }) {}
135
+
136
+ export class StoryDetail extends Schema.Class<StoryDetail>("StoryDetail")({
137
+ id: Schema.String,
138
+ title: Schema.String,
139
+ state: Schema.optionalKey(
140
+ Schema.Struct({
141
+ status: StoryStatus,
142
+ branch: Schema.String,
143
+ worktree: Schema.String,
144
+ executor: Schema.optionalKey(Schema.String)
145
+ })
146
+ ),
147
+ board: Schema.optionalKey(
148
+ Schema.Struct({ status: BoardStatus, detail: Schema.optionalKey(Schema.String) })
149
+ ),
150
+ tasks: Schema.Array(Schema.Struct({ title: Schema.String, completed: Schema.Boolean })),
151
+ findings: Schema.optionalKey(Schema.String),
152
+ verdict: Schema.optionalKey(Schema.Json),
153
+ gateLogs: Schema.Array(Schema.String)
154
+ }) {}
155
+
156
+ export class Diagnosis extends Schema.Class<Diagnosis>("Diagnosis")({
157
+ runId: Schema.String,
158
+ story: Schema.optionalKey(Schema.String),
159
+ digest: Schema.String,
160
+ failure: Schema.optionalKey(
161
+ Schema.Struct({
162
+ stage: Schema.optionalKey(Schema.String),
163
+ message: Schema.String,
164
+ lane: Schema.optionalKey(Schema.String)
165
+ })
166
+ ),
167
+ gateLogTails: Schema.Array(Schema.Struct({ path: Schema.String, tail: Schema.String })),
168
+ transcriptTail: Schema.Array(Schema.String)
169
+ }) {}
170
+
171
+ export class CostsOut extends Schema.Class<CostsOut>("CostsOut")({
172
+ report: CostReport,
173
+ /** The run's cost: its trace, topped up from its ledger line where the trace was short. */
174
+ total: Schema.Number,
175
+ ledger: Schema.optionalKey(Schema.Number),
176
+ /** USD the ledger line supplied beyond the trace, when the trace lost lines. */
177
+ fromLedger: Schema.optionalKey(Schema.Number),
178
+ /** The trace says more than the ledger line, which no line can fill. */
179
+ differs: Schema.Boolean
180
+ }) {}
181
+
182
+ export class UnknownRepo extends Schema.TaggedError<UnknownRepo>()("UnknownRepo", {
183
+ message: Schema.String
184
+ }) {}
185
+
186
+ export class UnknownRun extends Schema.TaggedError<UnknownRun>()("UnknownRun", {
187
+ message: Schema.String
188
+ }) {}
189
+
190
+ export class UnknownStory extends Schema.TaggedError<UnknownStory>()("UnknownStory", {
191
+ message: Schema.String
192
+ }) {}
193
+
194
+ export class UnknownFlow extends Schema.TaggedError<UnknownFlow>()("UnknownFlow", {
195
+ message: Schema.String
196
+ }) {}
197
+
198
+ export class NotAllowed extends Schema.TaggedError<NotAllowed>()("NotAllowed", {
199
+ message: Schema.String
200
+ }) {}
201
+
202
+ /** A start knob that would reach the flow's argv or environment in an unsafe shape. */
203
+ export class InvalidKnob extends Schema.TaggedError<InvalidKnob>()("InvalidKnob", {
204
+ knob: Schema.String,
205
+ message: Schema.String
206
+ }) {}
207
+
208
+ export class RunInProgress extends Schema.TaggedError<RunInProgress>()("RunInProgress", {
209
+ message: Schema.String,
210
+ runId: Schema.String
211
+ }) {}
212
+
213
+ /** An epic id that is not a folder under the repository's `.llm4ts/epics/`. */
214
+ export class UnknownEpic extends Schema.TaggedError<UnknownEpic>()("UnknownEpic", {
215
+ message: Schema.String
216
+ }) {}
217
+
218
+ /** A state file that exists but could not be read or parsed; the message names its path. */
219
+ export class ReadFailed extends Schema.TaggedError<ReadFailed>()("ReadFailed", {
220
+ message: Schema.String
221
+ }) {}
package/src/Retro.ts CHANGED
@@ -431,6 +431,17 @@ const runLinePrefixes = [
431
431
  "retro:"
432
432
  ]
433
433
 
434
+ /** One story's digest section, whatever its outcome; `undefined` when the plan has no such story. */
435
+ export const renderStoryDigest = (
436
+ inputs: RetroInputs,
437
+ storyId: string,
438
+ share: number = perStoryShare
439
+ ): string | undefined => {
440
+ const trace = traceView(inputs.trace)
441
+ const entry = inputs.stories.find((candidate) => candidate.story.id === storyId)
442
+ return entry === undefined ? undefined : storySection(inputs, trace, entry, share)
443
+ }
444
+
434
445
  /** The digest a retro seat is given: code-built, deterministic, capped. */
435
446
  export const renderRetroDigest = (inputs: RetroInputs): string => {
436
447
  const trace = traceView(inputs.trace)
package/src/Review.ts CHANGED
@@ -19,7 +19,14 @@ import {
19
19
  type FlowError
20
20
  } from "./FlowError.ts"
21
21
  import type { PlainFileStoreShape } from "./Persistence.ts"
22
- import { GateBaseline, gateIssuePrefix, isGateIssue, triageGates } from "./GateTriage.ts"
22
+ import {
23
+ GateBaseline,
24
+ describeGateReason,
25
+ gateIssuePrefix,
26
+ isGateIssue,
27
+ testGateReasonOf,
28
+ triageGates
29
+ } from "./GateTriage.ts"
23
30
  import {
24
31
  type OracleRules,
25
32
  checkOracle,
@@ -75,6 +82,8 @@ export class ReviewIssue extends Schema.Class<ReviewIssue>("ReviewIssue")({
75
82
  origin: Schema.optionalKey(Schema.Literals(["new", "base", "flaky"])),
76
83
  /** How a gate ended: a red exit, killed at the timeout, or a signal exit. */
77
84
  gateClass: Schema.optionalKey(Schema.Literals(["red", "hang", "crash"])),
85
+ /** Why a test gate's output could not stand for green: no tests ran, or test files failed to load. */
86
+ gateReason: Schema.optionalKey(Schema.Literals(["zero-tests", "load-failure"])),
78
87
  /** Where the gate's full output was written, when the caller asked for a log. */
79
88
  logPath: Schema.optionalKey(Schema.String)
80
89
  }) {}
@@ -538,7 +547,11 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
538
547
  )
539
548
  )
540
549
  )
541
- const failed = result === undefined || result.exitCode !== 0
550
+ const output = result === undefined ? "" : [...result.stdout, ...result.stderr].join("\n")
551
+ // A runner that collected nothing, or whose files failed to load, is red
552
+ // whatever it exited with: a green exit over zero tests proves nothing.
553
+ const reason = result === undefined ? undefined : testGateReasonOf(output)
554
+ const failed = result === undefined || result.exitCode !== 0 || reason !== undefined
542
555
  yield* events.publish(
543
556
  Timed.make({
544
557
  kind: "gate",
@@ -550,16 +563,15 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
550
563
  )
551
564
  let logPath: string | undefined
552
565
  if (options.log !== undefined) {
553
- const output =
554
- result === undefined ? "" : [...result.stdout, ...result.stderr].join("\n").trim()
555
- yield* options.log.files.writeAtomic(options.log.path, output)
566
+ yield* options.log.files.writeAtomic(options.log.path, output.trim())
556
567
  logPath = options.log.path
557
568
  }
569
+ // No summary line to count and a reason not to trust the run: zero passed.
558
570
  const passed =
559
571
  result === undefined
560
572
  ? undefined
561
- : passedCountIn([...result.stdout, ...result.stderr].join("\n"))
562
- if (result !== undefined && result.exitCode === 0) {
573
+ : (passedCountIn(output) ?? (reason === undefined ? undefined : 0))
574
+ if (!failed) {
563
575
  return ReviewResult.make({
564
576
  issues: [],
565
577
  summary: "lint passed",
@@ -568,16 +580,18 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
568
580
  }
569
581
  const seconds =
570
582
  options.timeout === undefined ? 0 : Math.round(Duration.toSeconds(options.timeout))
583
+ const problem =
584
+ result === undefined
585
+ ? `gate killed: no exit after ${seconds} seconds (LLM4TS_GATE_TIMEOUT)`
586
+ : processProblem(result.stdout, result.stderr, result.exitCode)
571
587
  return ReviewResult.make({
572
588
  issues: [
573
589
  ReviewIssue.make({
574
590
  severity: "Critical",
575
591
  title: `${gateIssuePrefix}${label}`,
576
- description:
577
- result === undefined
578
- ? `gate killed: no exit after ${seconds} seconds (LLM4TS_GATE_TIMEOUT)`
579
- : processProblem(result.stdout, result.stderr, result.exitCode),
592
+ description: reason === undefined ? problem : `${describeGateReason(reason)}\n\n${problem}`,
580
593
  gateClass: result === undefined ? "hang" : gateClassOf(result.exitCode),
594
+ ...(reason === undefined ? {} : { gateReason: reason }),
581
595
  ...(logPath === undefined ? {} : { logPath })
582
596
  })
583
597
  ],
@@ -0,0 +1,72 @@
1
+ // The run's own state lives under `<repo>/.llm4ts/`: traces, the cost
2
+ // ledger, plans, transcripts, epics. It belongs to the machine the run
3
+ // happened on, not to the repository: a committed trace is read by every
4
+ // reviewer at real cost, and a coder that reaches it can wipe an earlier
5
+ // run. So the first write under a `.llm4ts/` directory leaves a
6
+ // `.gitignore` there that ignores everything, and a flow commit never
7
+ // stages the directory (see `GitTool.commitAll`).
8
+ import * as Effect from "effect/Effect"
9
+ import type { PlainFileStoreShape } from "./Persistence.ts"
10
+
11
+ /** The directory name, as a path segment. */
12
+ export const RunStateDirectory = ".llm4ts"
13
+
14
+ /** What `<dir>/.llm4ts/.gitignore` holds: everything under it is ignored. */
15
+ export const runStateIgnoreContents = "*\n"
16
+
17
+ /**
18
+ * The glob every CLI seat is denied by default (ADR 0027 amendment,
19
+ * 2026-10-22): the run state, relative to the seat's working directory.
20
+ */
21
+ export const runStateDeniedPaths: ReadonlyArray<string> = [`${RunStateDirectory}/**`]
22
+
23
+ const ignoreName = ".gitignore"
24
+
25
+ /**
26
+ * The `<dir>/.llm4ts` prefix of `path` when the path has a `.llm4ts`
27
+ * segment; the first such segment, so a worktree nested under the run
28
+ * state (`.llm4ts/port/shards/1/.llm4ts/…`) still ignores the outer one
29
+ * first. Separators are kept as written.
30
+ */
31
+ export const runStateRootOf = (path: string): string | undefined => {
32
+ const match = /^(.*?)(^|[\\/])\.llm4ts(?=[\\/]|$)/.exec(path)
33
+ return match === null ? undefined : `${match[1] ?? ""}${match[2] ?? ""}${RunStateDirectory}`
34
+ }
35
+
36
+ /**
37
+ * The store with one rule on top: the first write under any `.llm4ts/`
38
+ * directory creates its `.gitignore` (`*`) when none exists, so nothing
39
+ * the run writes there is ever staged. An ignore file the repository
40
+ * already has is left as it is: a team that commits its own flows or
41
+ * forked packs keeps its exceptions. Idempotent to wrap twice.
42
+ */
43
+ export const ignoringRunState = (store: PlainFileStoreShape): PlainFileStoreShape => {
44
+ const ensured = new Set<string>()
45
+ const ensureIgnore = (path: string): Effect.Effect<void, never> => {
46
+ const root = runStateRootOf(path)
47
+ if (root === undefined || ensured.has(root)) {
48
+ return Effect.void
49
+ }
50
+ const separator = root.includes("\\") && !root.includes("/") ? "\\" : "/"
51
+ const ignorePath = `${root}${separator}${ignoreName}`
52
+ if (path === ignorePath) {
53
+ return Effect.void
54
+ }
55
+ return store.read(ignorePath).pipe(
56
+ Effect.flatMap((existing) =>
57
+ existing === undefined ? store.writeAtomic(ignorePath, runStateIgnoreContents) : Effect.void
58
+ ),
59
+ // A store that cannot write the ignore file is about to fail the
60
+ // write itself, with its own error; nothing to add here.
61
+ Effect.catch(() => Effect.void),
62
+ Effect.tap(() => Effect.sync(() => void ensured.add(root)))
63
+ )
64
+ }
65
+ return {
66
+ ...store,
67
+ writeAtomic: (path, contents) =>
68
+ ensureIgnore(path).pipe(Effect.andThen(store.writeAtomic(path, contents))),
69
+ append: (path, contents) =>
70
+ ensureIgnore(path).pipe(Effect.andThen(store.append(path, contents)))
71
+ }
72
+ }