@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
@@ -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
+ }