@llm4ts/flow 2.31.1 → 2.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Approval.d.ts +1 -1
- package/dist/Approval.d.ts.map +1 -1
- package/dist/Artifacts.d.ts +2 -2
- package/dist/Artifacts.d.ts.map +1 -1
- package/dist/BenchReport.d.ts +2 -2
- package/dist/BenchReport.d.ts.map +1 -1
- package/dist/Chat.d.ts +5 -2
- package/dist/Chat.d.ts.map +1 -1
- package/dist/Chat.js +7 -4
- package/dist/Chat.js.map +1 -1
- package/dist/CostLedger.d.ts +2 -2
- package/dist/CostLedger.d.ts.map +1 -1
- package/dist/Diagnostics.d.ts +27 -0
- package/dist/Diagnostics.d.ts.map +1 -0
- package/dist/Diagnostics.js +84 -0
- package/dist/Diagnostics.js.map +1 -0
- package/dist/Equiv.d.ts +3 -3
- package/dist/Equiv.d.ts.map +1 -1
- package/dist/Flow.d.ts +16 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +29 -3
- package/dist/Flow.js.map +1 -1
- package/dist/FlowError.d.ts +15 -2
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +15 -0
- package/dist/FlowError.js.map +1 -1
- package/dist/FlowEvents.d.ts +12 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +11 -0
- package/dist/FlowEvents.js.map +1 -1
- package/dist/Gates.d.ts +1 -1
- package/dist/Gates.d.ts.map +1 -1
- package/dist/Judgment.d.ts +2 -2
- package/dist/Judgment.d.ts.map +1 -1
- package/dist/Landing.d.ts +1 -1
- package/dist/Landing.d.ts.map +1 -1
- package/dist/NodePreflight.d.ts +1 -1
- package/dist/NodePreflight.d.ts.map +1 -1
- package/dist/Pack.d.ts +25 -1
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +38 -2
- package/dist/Pack.js.map +1 -1
- package/dist/Persistence.d.ts +2 -2
- package/dist/Persistence.d.ts.map +1 -1
- package/dist/PlanExecution.d.ts +1 -1
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/Port.d.ts +57 -0
- package/dist/Port.d.ts.map +1 -0
- package/dist/Port.js +134 -0
- package/dist/Port.js.map +1 -0
- package/dist/ProgramJudge.d.ts +1 -1
- package/dist/ProgramJudge.d.ts.map +1 -1
- package/dist/ProgramJudge.js +6 -3
- package/dist/ProgramJudge.js.map +1 -1
- package/dist/Replay.d.ts +2 -2
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Retro.d.ts +51 -4
- package/dist/Retro.d.ts.map +1 -1
- package/dist/Retro.js +204 -12
- package/dist/Retro.js.map +1 -1
- package/dist/Review.d.ts +38 -6
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +147 -8
- package/dist/Review.js.map +1 -1
- package/dist/ReviewCache.d.ts +1 -1
- package/dist/ReviewCache.d.ts.map +1 -1
- package/dist/Reviewer.d.ts +2 -0
- package/dist/Reviewer.d.ts.map +1 -1
- package/dist/Reviewer.js +3 -1
- package/dist/Reviewer.js.map +1 -1
- package/dist/Roster.d.ts +1 -1
- package/dist/Roster.d.ts.map +1 -1
- package/dist/RosterSeats.d.ts +1 -1
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/Stall.d.ts +29 -0
- package/dist/Stall.d.ts.map +1 -0
- package/dist/Stall.js +49 -0
- package/dist/Stall.js.map +1 -0
- package/dist/Stories.d.ts +8 -1
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +7 -1
- package/dist/Stories.js.map +1 -1
- package/dist/WorkQueue.d.ts +63 -0
- package/dist/WorkQueue.d.ts.map +1 -0
- package/dist/WorkQueue.js +117 -0
- package/dist/WorkQueue.js.map +1 -0
- package/package.json +6 -2
- package/src/Chat.ts +15 -7
- package/src/Diagnostics.ts +105 -0
- package/src/Flow.ts +48 -3
- package/src/FlowError.ts +16 -0
- package/src/FlowEvents.ts +14 -0
- package/src/Pack.ts +63 -1
- package/src/Port.ts +178 -0
- package/src/ProgramJudge.ts +3 -0
- package/src/Retro.ts +237 -5
- package/src/Review.ts +202 -8
- package/src/Reviewer.ts +3 -1
- package/src/Stall.ts +85 -0
- package/src/Stories.ts +14 -0
- package/src/WorkQueue.ts +194 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/flow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.33.0",
|
|
4
4
|
"description": "Effect-native LLM workflow, persistence, review, and repository automation",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"./CostTracker": "./dist/CostTracker.js",
|
|
35
35
|
"./CoverageLedger": "./dist/CoverageLedger.js",
|
|
36
36
|
"./Decisions": "./dist/Decisions.js",
|
|
37
|
+
"./Diagnostics": "./dist/Diagnostics.js",
|
|
37
38
|
"./Domains": "./dist/Domains.js",
|
|
38
39
|
"./EpicBrief": "./dist/EpicBrief.js",
|
|
39
40
|
"./EpicRuns": "./dist/EpicRuns.js",
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
"./Persistence": "./dist/Persistence.js",
|
|
68
69
|
"./Plan": "./dist/Plan.js",
|
|
69
70
|
"./PlanExecution": "./dist/PlanExecution.js",
|
|
71
|
+
"./Port": "./dist/Port.js",
|
|
70
72
|
"./Planner": "./dist/Planner.js",
|
|
71
73
|
"./PrSummary": "./dist/PrSummary.js",
|
|
72
74
|
"./PriceList": "./dist/PriceList.js",
|
|
@@ -82,6 +84,7 @@
|
|
|
82
84
|
"./Spans": "./dist/Spans.js",
|
|
83
85
|
"./Roster": "./dist/Roster.js",
|
|
84
86
|
"./RosterSeats": "./dist/RosterSeats.js",
|
|
87
|
+
"./Stall": "./dist/Stall.js",
|
|
85
88
|
"./Stories": "./dist/Stories.js",
|
|
86
89
|
"./StoryPlan": "./dist/StoryPlan.js",
|
|
87
90
|
"./Survey": "./dist/Survey.js",
|
|
@@ -90,6 +93,7 @@
|
|
|
90
93
|
"./TransientRetry": "./dist/TransientRetry.js",
|
|
91
94
|
"./Usage": "./dist/Usage.js",
|
|
92
95
|
"./Wall": "./dist/Wall.js",
|
|
96
|
+
"./WorkQueue": "./dist/WorkQueue.js",
|
|
93
97
|
"./Workspace": "./dist/Workspace.js",
|
|
94
98
|
"./WorkspaceLayout": "./dist/WorkspaceLayout.js",
|
|
95
99
|
"./WorkspaceTools": "./dist/WorkspaceTools.js",
|
|
@@ -118,7 +122,7 @@
|
|
|
118
122
|
"typescript"
|
|
119
123
|
],
|
|
120
124
|
"dependencies": {
|
|
121
|
-
"@llm4ts/core": "2.
|
|
125
|
+
"@llm4ts/core": "2.33.0"
|
|
122
126
|
},
|
|
123
127
|
"peerDependencies": {
|
|
124
128
|
"effect": "4.0.0"
|
package/src/Chat.ts
CHANGED
|
@@ -3,9 +3,10 @@ import * as Ref from "effect/Ref"
|
|
|
3
3
|
import * as Semaphore from "effect/Semaphore"
|
|
4
4
|
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
5
5
|
import { Message, type LlmResponse } from "@llm4ts/core/Models"
|
|
6
|
-
import {
|
|
6
|
+
import { collectAny } from "@llm4ts/core/Streaming"
|
|
7
7
|
import { withToolActivity } from "./Activity.ts"
|
|
8
|
-
import { FlowLlmError } from "./FlowError.ts"
|
|
8
|
+
import { FlowLlmError, type Stalled } from "./FlowError.ts"
|
|
9
|
+
import { noRepeats, stallGuard, type StallOptions } from "./Stall.ts"
|
|
9
10
|
import { Info, TokensUsed, type FlowEventsShape } from "./FlowEvents.ts"
|
|
10
11
|
import { isContextOverflow } from "./TransientRetry.ts"
|
|
11
12
|
|
|
@@ -24,6 +25,8 @@ export interface ChatOptions {
|
|
|
24
25
|
readonly events?: FlowEventsShape
|
|
25
26
|
/** Request label for published `TokensUsed` events. Default: "chat". */
|
|
26
27
|
readonly agent?: string
|
|
28
|
+
/** End a turn that repeats one tool call or goes silent (ADR 0027 decision 11). */
|
|
29
|
+
readonly stall?: StallOptions
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const publishUsage = (options: ChatOptions, response: LlmResponse): Effect.Effect<void> =>
|
|
@@ -38,7 +41,7 @@ const publishUsage = (options: ChatOptions, response: LlmResponse): Effect.Effec
|
|
|
38
41
|
)
|
|
39
42
|
|
|
40
43
|
export interface Chat {
|
|
41
|
-
readonly ask: (prompt: string) => Effect.Effect<string, FlowLlmError>
|
|
44
|
+
readonly ask: (prompt: string) => Effect.Effect<string, FlowLlmError | Stalled>
|
|
42
45
|
readonly messages: Effect.Effect<ReadonlyArray<Message>>
|
|
43
46
|
}
|
|
44
47
|
|
|
@@ -60,15 +63,19 @@ export const makeChat = Effect.fn("@llm4ts/flow/Chat.make")(function* (
|
|
|
60
63
|
): Effect.fn.Return<Chat> {
|
|
61
64
|
const history = yield* Ref.make(initialHistory(options))
|
|
62
65
|
const gate = yield* Semaphore.make(1)
|
|
66
|
+
const repeats = yield* Ref.make(noRepeats)
|
|
63
67
|
|
|
64
68
|
const send = (messages: ReadonlyArray<Message>) => {
|
|
65
69
|
const stream = service.executeStreamWithHistory(messages)
|
|
66
|
-
|
|
70
|
+
const watched = options.events === undefined ? stream : withToolActivity(options.events, stream)
|
|
71
|
+
return collectAny(
|
|
72
|
+
options.stall === undefined ? watched : stallGuard(watched, options.stall, repeats)
|
|
73
|
+
)
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
const askRound = Effect.fn("@llm4ts/flow/Chat.ask")(function* (
|
|
70
77
|
prompt: string
|
|
71
|
-
): Effect.fn.Return<string, FlowLlmError> {
|
|
78
|
+
): Effect.fn.Return<string, FlowLlmError | Stalled> {
|
|
72
79
|
const userTurn = Message.make({ role: "User", content: prompt })
|
|
73
80
|
const earlier = yield* Ref.get(history)
|
|
74
81
|
const full = [...earlier, userTurn]
|
|
@@ -81,7 +88,8 @@ export const makeChat = Effect.fn("@llm4ts/flow/Chat.make")(function* (
|
|
|
81
88
|
// this turn alone, and continue the conversation from there.
|
|
82
89
|
const response = yield* send(full).pipe(
|
|
83
90
|
Effect.catchIf(
|
|
84
|
-
(error) =>
|
|
91
|
+
(error) =>
|
|
92
|
+
error._tag !== "Stalled" && isContextOverflow(error) && full.length > system.length + 1,
|
|
85
93
|
(error) =>
|
|
86
94
|
Effect.gen(function* () {
|
|
87
95
|
messages = [
|
|
@@ -106,7 +114,7 @@ export const makeChat = Effect.fn("@llm4ts/flow/Chat.make")(function* (
|
|
|
106
114
|
return yield* send(messages)
|
|
107
115
|
})
|
|
108
116
|
),
|
|
109
|
-
Effect.mapError(FlowLlmError.from)
|
|
117
|
+
Effect.mapError((error) => (error._tag === "Stalled" ? error : FlowLlmError.from(error)))
|
|
110
118
|
)
|
|
111
119
|
yield* publishUsage(options, response)
|
|
112
120
|
yield* Ref.set(history, [
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Compiler and test-runner output as a work queue (ADR 0028): a pack's
|
|
2
|
+
// `## Diagnostics` command prints what is red, this module reads it and
|
|
3
|
+
// groups it by unit so each fixer gets one unit and nothing else.
|
|
4
|
+
import { dirname } from "node:path"
|
|
5
|
+
import * as Schema from "effect/Schema"
|
|
6
|
+
|
|
7
|
+
export class Diagnostic extends Schema.Class<Diagnostic>("Diagnostic")({
|
|
8
|
+
file: Schema.String,
|
|
9
|
+
line: Schema.optionalKey(Schema.Int),
|
|
10
|
+
message: Schema.String,
|
|
11
|
+
/** The unit the fixer owns (a crate, a package, a folder); default: the file's directory. */
|
|
12
|
+
unit: Schema.optionalKey(Schema.String)
|
|
13
|
+
}) {}
|
|
14
|
+
|
|
15
|
+
export const DiagnosticsFormat = Schema.Literals(["json", "cargo"])
|
|
16
|
+
export type DiagnosticsFormat = typeof DiagnosticsFormat.Type
|
|
17
|
+
|
|
18
|
+
const decodeJsonLine = Schema.decodeUnknownOption(Schema.fromJsonString(Diagnostic))
|
|
19
|
+
|
|
20
|
+
const CargoMessage = Schema.fromJsonString(
|
|
21
|
+
Schema.Struct({
|
|
22
|
+
reason: Schema.optionalKey(Schema.String),
|
|
23
|
+
target: Schema.optionalKey(Schema.Struct({ name: Schema.optionalKey(Schema.String) })),
|
|
24
|
+
message: Schema.optionalKey(
|
|
25
|
+
Schema.Struct({
|
|
26
|
+
level: Schema.optionalKey(Schema.String),
|
|
27
|
+
message: Schema.optionalKey(Schema.String),
|
|
28
|
+
spans: Schema.optionalKey(
|
|
29
|
+
Schema.Array(
|
|
30
|
+
Schema.Struct({
|
|
31
|
+
file_name: Schema.optionalKey(Schema.String),
|
|
32
|
+
line_start: Schema.optionalKey(Schema.Int),
|
|
33
|
+
is_primary: Schema.optionalKey(Schema.Boolean)
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
const decodeCargo = Schema.decodeUnknownOption(CargoMessage)
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `json`: one `{file, line?, message, unit?}` object per line. `cargo`:
|
|
45
|
+
* `cargo check --message-format=json`, errors only, the crate as the unit.
|
|
46
|
+
* Lines that do not parse are skipped: a build prints more than diagnostics.
|
|
47
|
+
*/
|
|
48
|
+
export const parseDiagnostics = (
|
|
49
|
+
text: string,
|
|
50
|
+
format: DiagnosticsFormat = "json"
|
|
51
|
+
): ReadonlyArray<Diagnostic> => {
|
|
52
|
+
const out: Array<Diagnostic> = []
|
|
53
|
+
for (const raw of text.split("\n")) {
|
|
54
|
+
const line = raw.trim()
|
|
55
|
+
if (line.length === 0 || !line.startsWith("{")) continue
|
|
56
|
+
if (format === "json") {
|
|
57
|
+
const decoded = decodeJsonLine(line)
|
|
58
|
+
if (decoded._tag === "Some") out.push(decoded.value)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
const decoded = decodeCargo(line)
|
|
62
|
+
if (decoded._tag !== "Some") continue
|
|
63
|
+
const value = decoded.value
|
|
64
|
+
if (value.reason !== "compiler-message" || value.message?.level !== "error") continue
|
|
65
|
+
const span =
|
|
66
|
+
value.message.spans?.find((candidate) => candidate.is_primary === true) ??
|
|
67
|
+
value.message.spans?.[0]
|
|
68
|
+
if (span?.file_name === undefined) continue
|
|
69
|
+
out.push(
|
|
70
|
+
Diagnostic.make({
|
|
71
|
+
file: span.file_name,
|
|
72
|
+
...(span.line_start === undefined ? {} : { line: span.line_start }),
|
|
73
|
+
message: value.message.message ?? "error",
|
|
74
|
+
...(value.target?.name === undefined ? {} : { unit: value.target.name })
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Diagnostics by the unit a fixer owns, in a stable order. */
|
|
82
|
+
export const groupDiagnostics = (
|
|
83
|
+
diagnostics: ReadonlyArray<Diagnostic>
|
|
84
|
+
): ReadonlyArray<{ readonly unit: string; readonly diagnostics: ReadonlyArray<Diagnostic> }> => {
|
|
85
|
+
const groups = new Map<string, Array<Diagnostic>>()
|
|
86
|
+
for (const diagnostic of diagnostics) {
|
|
87
|
+
const unit = diagnostic.unit ?? dirname(diagnostic.file)
|
|
88
|
+
groups.set(unit, [...(groups.get(unit) ?? []), diagnostic])
|
|
89
|
+
}
|
|
90
|
+
return [...groups.entries()]
|
|
91
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
92
|
+
.map(([unit, list]) => ({ unit, diagnostics: list }))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The diagnostics of one unit as the fixer reads them, capped by count. */
|
|
96
|
+
export const renderDiagnostics = (diagnostics: ReadonlyArray<Diagnostic>, limit = 60): string =>
|
|
97
|
+
[
|
|
98
|
+
...diagnostics
|
|
99
|
+
.slice(0, limit)
|
|
100
|
+
.map(
|
|
101
|
+
(diagnostic) =>
|
|
102
|
+
`- ${diagnostic.file}${diagnostic.line === undefined ? "" : `:${diagnostic.line}`}: ${diagnostic.message.split("\n")[0] ?? ""}`
|
|
103
|
+
),
|
|
104
|
+
...(diagnostics.length > limit ? [`- … ${diagnostics.length - limit} more`] : [])
|
|
105
|
+
].join("\n")
|
package/src/Flow.ts
CHANGED
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
} from "./Review.ts"
|
|
45
45
|
import type { OracleRules } from "./OracleGuard.ts"
|
|
46
46
|
import type { Reviewer } from "./Reviewer.ts"
|
|
47
|
+
import { withContract } from "./AutonomyContract.ts"
|
|
48
|
+
import type { StallOptions } from "./Stall.ts"
|
|
47
49
|
|
|
48
50
|
export { publishUsage, structuredAndPublish } from "./Usage.ts"
|
|
49
51
|
|
|
@@ -109,6 +111,18 @@ export interface ImplementPlanOptions {
|
|
|
109
111
|
* `testsChange` is true.
|
|
110
112
|
*/
|
|
111
113
|
readonly oracle?: { readonly rules?: OracleRules; readonly testsChange?: boolean }
|
|
114
|
+
/** Independent votes of the adversarial lens per round (ADR 0027 decision 7). Default 1. */
|
|
115
|
+
readonly votes?: number
|
|
116
|
+
/**
|
|
117
|
+
* Who applies review findings (ADR 0027 decision 8): the implementer's own
|
|
118
|
+
* chat (default), or a fresh chat on the coder seat briefed to apply the
|
|
119
|
+
* findings and nothing else, with the carried notes for orientation.
|
|
120
|
+
*/
|
|
121
|
+
readonly fixer?: "coder" | "separate"
|
|
122
|
+
/** The repository's own review rules (`.llm4ts/review-rules.md`), loaded by the caller, as one extra lens. */
|
|
123
|
+
readonly repoRules?: Effect.Effect<Reviewer | undefined, FlowError>
|
|
124
|
+
/** End a coder turn that repeats one tool call or goes silent (ADR 0027 decision 11). */
|
|
125
|
+
readonly stall?: StallOptions
|
|
112
126
|
/** How gate output reaches the coder in the fix prompt. */
|
|
113
127
|
readonly fix?: FixPromptOptions
|
|
114
128
|
/** Where each review lens's answer is kept, so a rerun over the same diff asks nothing. */
|
|
@@ -211,6 +225,14 @@ const defaultCommitMessage = (plan: Plan, task: Task): string => `${plan.epicId}
|
|
|
211
225
|
const composeSystem = (base: string | undefined, note: string): string =>
|
|
212
226
|
[base, note].filter((part): part is string => part !== undefined && part.length > 0).join("\n\n")
|
|
213
227
|
|
|
228
|
+
/** The separate fixer's brief (ADR 0027 decision 8): the Bun port's fixer, word for word in spirit. */
|
|
229
|
+
export const fixerBrief = [
|
|
230
|
+
"You apply review findings to a change another coder made. Apply the findings. Nothing else.",
|
|
231
|
+
"Surgical edits only: no refactors, no extras, no new tests beyond what a finding asks for.",
|
|
232
|
+
"If a finding is wrong (the reviewer misread the code), skip it and say so in one line.",
|
|
233
|
+
"Then stop."
|
|
234
|
+
].join("\n")
|
|
235
|
+
|
|
214
236
|
/**
|
|
215
237
|
* The task's triage: the explicit one, or one built from `baseline` over the
|
|
216
238
|
* commit the task starts from (HEAD now), or none.
|
|
@@ -258,7 +280,8 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
258
280
|
sharedCoder = yield* makeChat(context.coder, {
|
|
259
281
|
events: context.events,
|
|
260
282
|
agent: "coder",
|
|
261
|
-
...(options.system === undefined ? {} : { system: options.system })
|
|
283
|
+
...(options.system === undefined ? {} : { system: options.system }),
|
|
284
|
+
...(options.stall === undefined ? {} : { stall: options.stall })
|
|
262
285
|
})
|
|
263
286
|
}
|
|
264
287
|
|
|
@@ -279,7 +302,8 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
279
302
|
coder = yield* makeChat(context.coder, {
|
|
280
303
|
events: context.events,
|
|
281
304
|
agent: "coder",
|
|
282
|
-
system: composeSystem(options.system, planSoFar.render)
|
|
305
|
+
system: composeSystem(options.system, planSoFar.render),
|
|
306
|
+
...(options.stall === undefined ? {} : { stall: options.stall })
|
|
283
307
|
})
|
|
284
308
|
}
|
|
285
309
|
// `plan.taskPrompt` deliberately reads the frozen `plan` captured at
|
|
@@ -354,6 +378,24 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
354
378
|
}
|
|
355
379
|
}
|
|
356
380
|
const triage = yield* triageFor(context, options)
|
|
381
|
+
const repoRules = options.repoRules === undefined ? undefined : yield* options.repoRules
|
|
382
|
+
const reviewers = [
|
|
383
|
+
...(options.reviewers ?? minimalReviewers),
|
|
384
|
+
...(repoRules === undefined ? [] : [repoRules])
|
|
385
|
+
]
|
|
386
|
+
const fixWith =
|
|
387
|
+
options.fixer === "separate"
|
|
388
|
+
? (prompt: string): Effect.Effect<string, FlowError> =>
|
|
389
|
+
Effect.flatMap(
|
|
390
|
+
makeChat(context.coder, {
|
|
391
|
+
events: context.events,
|
|
392
|
+
agent: "coder",
|
|
393
|
+
system: withContract(fixerBrief),
|
|
394
|
+
...(options.stall === undefined ? {} : { stall: options.stall })
|
|
395
|
+
}),
|
|
396
|
+
(fixer) => fixer.ask(withNotes(prompt, notes))
|
|
397
|
+
)
|
|
398
|
+
: undefined
|
|
357
399
|
const oracle: OracleGateOptions | undefined =
|
|
358
400
|
options.oracle === undefined
|
|
359
401
|
? undefined
|
|
@@ -368,7 +410,7 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
368
410
|
: { baseCount: Effect.map(triage.baseline, (base) => base?.passedCount) })
|
|
369
411
|
}
|
|
370
412
|
yield* reviewAndFixLoop({
|
|
371
|
-
reviewers
|
|
413
|
+
reviewers,
|
|
372
414
|
reviewerService: flowReviewer(context),
|
|
373
415
|
coder,
|
|
374
416
|
taskTitle: task.title,
|
|
@@ -379,6 +421,9 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
379
421
|
...(options.format === undefined ? {} : { format: options.format }),
|
|
380
422
|
...(triage === undefined ? {} : { triage }),
|
|
381
423
|
...(oracle === undefined ? {} : { oracle }),
|
|
424
|
+
...(options.votes === undefined ? {} : { votes: options.votes }),
|
|
425
|
+
...(fixWith === undefined ? {} : { fixWith }),
|
|
426
|
+
...(options.stall === undefined ? {} : { stallOnIdenticalDiff: true }),
|
|
382
427
|
...(options.fix === undefined ? {} : { fix: options.fix }),
|
|
383
428
|
...(options.reviewCache === undefined ? {} : { cache: options.reviewCache }),
|
|
384
429
|
...(options.onReview === undefined
|
package/src/FlowError.ts
CHANGED
|
@@ -267,6 +267,21 @@ export class RosterExhausted extends Schema.TaggedError<RosterExhausted>()("Rost
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
/** One story failed; carries the story id so a fail-fast run names its cause. */
|
|
270
|
+
/**
|
|
271
|
+
* A loop that stopped making progress (ADR 0027 decision 11): the review
|
|
272
|
+
* round saw the same diff twice, the coder repeated one tool call too many
|
|
273
|
+
* times, or the seat went silent for too long. A stopping condition the
|
|
274
|
+
* model does not control; the story fails and the retro diagnoses.
|
|
275
|
+
*/
|
|
276
|
+
export class Stalled extends Schema.TaggedError<Stalled>()("Stalled", {
|
|
277
|
+
signal: Schema.Literals(["identical-diff", "repeated-tool-call", "silence", "no-progress"]),
|
|
278
|
+
detail: Schema.String
|
|
279
|
+
}) {
|
|
280
|
+
get message(): string {
|
|
281
|
+
return `stalled (${this.signal}): ${this.detail}`
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
270
285
|
export class StoryFailed extends Schema.TaggedError<StoryFailed>()("StoryFailed", {
|
|
271
286
|
story: Schema.String,
|
|
272
287
|
reason: Schema.String
|
|
@@ -371,6 +386,7 @@ export const FlowError = Schema.Union([
|
|
|
371
386
|
EpicIncomplete,
|
|
372
387
|
LandingFailed,
|
|
373
388
|
StoryFailed,
|
|
389
|
+
Stalled,
|
|
374
390
|
DecisionsInvalid,
|
|
375
391
|
OpenPointsPending,
|
|
376
392
|
ContractConflict,
|
package/src/FlowEvents.ts
CHANGED
|
@@ -171,6 +171,19 @@ export class CapabilityUnenforceable extends Schema.TaggedClass<CapabilityUnenfo
|
|
|
171
171
|
}
|
|
172
172
|
) {}
|
|
173
173
|
|
|
174
|
+
/** A review finding moved down a severity because it could not be placed in the diff (ADR 0027). */
|
|
175
|
+
export class ReviewFindingDemoted extends Schema.TaggedClass<ReviewFindingDemoted>()(
|
|
176
|
+
"ReviewFindingDemoted",
|
|
177
|
+
{
|
|
178
|
+
lens: Schema.String,
|
|
179
|
+
title: Schema.String,
|
|
180
|
+
from: Schema.Literals(["Critical", "Warning", "Info"]),
|
|
181
|
+
to: Schema.Literals(["Critical", "Warning", "Info"]),
|
|
182
|
+
reason: Schema.String,
|
|
183
|
+
lane: Schema.optionalKey(Schema.String)
|
|
184
|
+
}
|
|
185
|
+
) {}
|
|
186
|
+
|
|
174
187
|
/** A task's `verified:` claims against the tool calls its transcript shows (ADR 0027). */
|
|
175
188
|
export class EvidenceChecked extends Schema.TaggedClass<EvidenceChecked>()("EvidenceChecked", {
|
|
176
189
|
task: Schema.String,
|
|
@@ -356,6 +369,7 @@ export const FlowEvent = Schema.Union([
|
|
|
356
369
|
CapabilityDeniedEvent,
|
|
357
370
|
CapabilityUnenforceable,
|
|
358
371
|
EvidenceChecked,
|
|
372
|
+
ReviewFindingDemoted,
|
|
359
373
|
Declassified,
|
|
360
374
|
ExecutorLeased,
|
|
361
375
|
ExecutorReleased,
|
package/src/Pack.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Dimension } from "@llm4ts/core/eval/Eval"
|
|
|
4
4
|
import type { ConsolidateRules } from "./Domains.ts"
|
|
5
5
|
import { PlanParseError } from "./FlowError.ts"
|
|
6
6
|
import { oracleRulesFrom, type OracleRules } from "./OracleGuard.ts"
|
|
7
|
-
import {
|
|
7
|
+
import { Reviewer, parseReviewer } from "./Reviewer.ts"
|
|
8
8
|
import { CoverageRule } from "./SpecChecks.ts"
|
|
9
9
|
import type { WorkspaceError, WorkspaceShape } from "./Workspace.ts"
|
|
10
10
|
|
|
@@ -74,6 +74,26 @@ export interface Pack {
|
|
|
74
74
|
* over the defaults. Absent: the defaults.
|
|
75
75
|
*/
|
|
76
76
|
readonly oracle: OracleRules | undefined
|
|
77
|
+
/**
|
|
78
|
+
* The `## Review rules` section (ADR 0027 decision 9): extra rules every
|
|
79
|
+
* pack lens carries after the shared preamble, or `- preamble: off`.
|
|
80
|
+
*/
|
|
81
|
+
readonly reviewRules: { readonly text: string; readonly preamble: boolean } | undefined
|
|
82
|
+
/**
|
|
83
|
+
* `target:` (ADR 0028): the template of a source file's port
|
|
84
|
+
* (`{{dir}}/{{base}}.rs`); present only in a porting pack.
|
|
85
|
+
*/
|
|
86
|
+
readonly target: string | undefined
|
|
87
|
+
/** `comment:` (ADR 0028): the target language's line-comment marker for the PORT STATUS trailer; default `//`. */
|
|
88
|
+
readonly comment: string | undefined
|
|
89
|
+
/**
|
|
90
|
+
* `## Diagnostics` (ADR 0028): `- command: …` prints what is red after a
|
|
91
|
+
* build, `- format: json | cargo` says how to read it. Present only in a
|
|
92
|
+
* porting pack.
|
|
93
|
+
*/
|
|
94
|
+
readonly diagnostics:
|
|
95
|
+
| { readonly command: ReadonlyArray<string>; readonly format: "json" | "cargo" }
|
|
96
|
+
| undefined
|
|
77
97
|
readonly dir: string
|
|
78
98
|
readonly gate: (name: string) => ReadonlyArray<string> | undefined
|
|
79
99
|
readonly prompt: (name: string) => string | undefined
|
|
@@ -237,9 +257,36 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
237
257
|
const gates = Object.fromEntries(
|
|
238
258
|
Object.entries(gateValues).map(([name, command]) => [name, command.split(/\s+/)])
|
|
239
259
|
)
|
|
260
|
+
const reviewRulesBody = section(manifest.sections, "Review rules")
|
|
261
|
+
const reviewRulesValues = namedItems(reviewRulesBody)
|
|
262
|
+
const reviewRules =
|
|
263
|
+
reviewRulesBody === undefined
|
|
264
|
+
? undefined
|
|
265
|
+
: {
|
|
266
|
+
text: reviewRulesBody
|
|
267
|
+
.split(/\r?\n/)
|
|
268
|
+
.filter((line) => !/^- preamble:/iu.test(line.trim()))
|
|
269
|
+
.join("\n")
|
|
270
|
+
.trim(),
|
|
271
|
+
preamble: reviewRulesValues.preamble?.toLowerCase() !== "off"
|
|
272
|
+
}
|
|
240
273
|
const lenses = Object.entries(lensFiles)
|
|
241
274
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
242
275
|
.map(([name, text]) => parseReviewer(name, text))
|
|
276
|
+
// The pack's own review rules ride on every lens; `preamble: off` leaves
|
|
277
|
+
// the shared preamble out (ADR 0027 decision 9).
|
|
278
|
+
.map((lens) =>
|
|
279
|
+
reviewRules === undefined
|
|
280
|
+
? lens
|
|
281
|
+
: Reviewer.make({
|
|
282
|
+
...lens,
|
|
283
|
+
systemPrompt:
|
|
284
|
+
reviewRules.text.length === 0
|
|
285
|
+
? lens.systemPrompt
|
|
286
|
+
: `${lens.systemPrompt}\n\nPack review rules:\n${reviewRules.text}`,
|
|
287
|
+
...(reviewRules.preamble ? {} : { preamble: false })
|
|
288
|
+
})
|
|
289
|
+
)
|
|
243
290
|
const fields = manifest.fields
|
|
244
291
|
if (fields["programFiles"] !== undefined) {
|
|
245
292
|
return yield* PlanParseError.make({
|
|
@@ -273,6 +320,17 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
273
320
|
message: `pack manifest 'spec-schema:' must be 'pagespec' when set, got: ${specSchema}`
|
|
274
321
|
})
|
|
275
322
|
}
|
|
323
|
+
const diagnosticsValues = namedItems(section(manifest.sections, "Diagnostics"))
|
|
324
|
+
const diagnostics =
|
|
325
|
+
diagnosticsValues.command === undefined
|
|
326
|
+
? undefined
|
|
327
|
+
: {
|
|
328
|
+
command: diagnosticsValues.command.split(/\s+/),
|
|
329
|
+
format:
|
|
330
|
+
diagnosticsValues.format?.toLowerCase() === "cargo"
|
|
331
|
+
? ("cargo" as const)
|
|
332
|
+
: ("json" as const)
|
|
333
|
+
}
|
|
276
334
|
const oracleValues = namedItems(section(manifest.sections, "Oracle"))
|
|
277
335
|
const oracle: OracleRules | undefined =
|
|
278
336
|
section(manifest.sections, "Oracle") === undefined
|
|
@@ -346,6 +404,10 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
346
404
|
specSchema,
|
|
347
405
|
consolidate,
|
|
348
406
|
oracle,
|
|
407
|
+
reviewRules,
|
|
408
|
+
target: fields["target"],
|
|
409
|
+
comment: fields["comment"],
|
|
410
|
+
diagnostics,
|
|
349
411
|
dir: directory,
|
|
350
412
|
gate: (name) => gates[name],
|
|
351
413
|
prompt: (name) => prompts[name],
|
package/src/Port.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Porting a code base file by file (ADR 0028): the manifest, its batches, the
|
|
2
|
+
// deterministic target path, the machine-readable PORT STATUS trailer every
|
|
3
|
+
// draft ends with, and the pilot report. Pure; the flows own the seats.
|
|
4
|
+
import { basename, dirname, extname } from "node:path"
|
|
5
|
+
import * as Schema from "effect/Schema"
|
|
6
|
+
import { DraftApprovalMarker } from "./Approval.ts"
|
|
7
|
+
import type { QueueOutcome } from "./WorkQueue.ts"
|
|
8
|
+
|
|
9
|
+
export class PortEntry extends Schema.Class<PortEntry>("PortEntry")({
|
|
10
|
+
/** Repo-relative source path; also the queue item id. */
|
|
11
|
+
id: Schema.String,
|
|
12
|
+
source: Schema.String,
|
|
13
|
+
target: Schema.String,
|
|
14
|
+
loc: Schema.Int
|
|
15
|
+
}) {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The target path of a source, from the pack's `target:` template:
|
|
19
|
+
* `{{dir}}` (the source's directory, "" at the root), `{{base}}` (its name
|
|
20
|
+
* without extension), `{{ext}}` (the extension without the dot), `{{path}}`
|
|
21
|
+
* (the whole source path).
|
|
22
|
+
*/
|
|
23
|
+
export const targetPathOf = (template: string, source: string): string => {
|
|
24
|
+
const dir = dirname(source)
|
|
25
|
+
const ext = extname(source)
|
|
26
|
+
const rendered = template
|
|
27
|
+
.replaceAll("{{dir}}", dir === "." ? "" : dir)
|
|
28
|
+
.replaceAll("{{base}}", basename(source, ext))
|
|
29
|
+
.replaceAll("{{ext}}", ext.replace(/^\./u, ""))
|
|
30
|
+
.replaceAll("{{path}}", source)
|
|
31
|
+
return rendered.replace(/^\/+/u, "").replaceAll("//", "/")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const linesOf = (text: string): number =>
|
|
35
|
+
text.length === 0 ? 0 : text.split(/\r?\n/u).filter((line) => line.trim().length > 0).length
|
|
36
|
+
|
|
37
|
+
export interface BatchPolicy {
|
|
38
|
+
/** Files per batch. Default 100. */
|
|
39
|
+
readonly files?: number
|
|
40
|
+
/** A batch whose first file is longer than this (lines) shrinks to `small`. Default 2200. */
|
|
41
|
+
readonly bigLoc?: number
|
|
42
|
+
/** Default 6. */
|
|
43
|
+
readonly small?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Contiguous batches of the manifest; a batch led by a big file is small (the Bun rule). */
|
|
47
|
+
export const batchesOf = (
|
|
48
|
+
entries: ReadonlyArray<PortEntry>,
|
|
49
|
+
policy: BatchPolicy = {}
|
|
50
|
+
): ReadonlyArray<ReadonlyArray<PortEntry>> => {
|
|
51
|
+
const files = Math.max(1, policy.files ?? 100)
|
|
52
|
+
const bigLoc = policy.bigLoc ?? 2_200
|
|
53
|
+
const small = Math.max(1, policy.small ?? 6)
|
|
54
|
+
const batches: Array<ReadonlyArray<PortEntry>> = []
|
|
55
|
+
let index = 0
|
|
56
|
+
while (index < entries.length) {
|
|
57
|
+
const first = entries[index]
|
|
58
|
+
const size = first !== undefined && first.loc > bigLoc ? small : files
|
|
59
|
+
batches.push(entries.slice(index, index + size))
|
|
60
|
+
index += size
|
|
61
|
+
}
|
|
62
|
+
return batches
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const PortConfidence = Schema.Literals(["high", "medium", "low"])
|
|
66
|
+
export type PortConfidence = typeof PortConfidence.Type
|
|
67
|
+
|
|
68
|
+
export interface PortStatus {
|
|
69
|
+
readonly confidence: PortConfidence | undefined
|
|
70
|
+
readonly todos: number | undefined
|
|
71
|
+
readonly notes: string | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const statusWindow = 40
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The PORT STATUS trailer of a draft: in any comment syntax, within the last
|
|
78
|
+
* lines of the file, `confidence: high|medium|low`, `todos: <n>`, `notes: …`.
|
|
79
|
+
* All undefined when the draft has no trailer.
|
|
80
|
+
*/
|
|
81
|
+
export const portStatusIn = (text: string): PortStatus => {
|
|
82
|
+
const lines = text.split(/\r?\n/u)
|
|
83
|
+
const tail = lines.slice(Math.max(0, lines.length - statusWindow))
|
|
84
|
+
const start = tail.findIndex((line) => /PORT STATUS/u.test(line))
|
|
85
|
+
if (start < 0) {
|
|
86
|
+
return { confidence: undefined, todos: undefined, notes: undefined }
|
|
87
|
+
}
|
|
88
|
+
const block = tail.slice(start).join("\n")
|
|
89
|
+
const confidence = /confidence:\s*(high|medium|low)\b/iu.exec(block)?.[1]?.toLowerCase()
|
|
90
|
+
const todos = /todos?:\s*(\d+)/iu.exec(block)?.[1]
|
|
91
|
+
const notes = /notes?:\s*(.+)$/imu.exec(block)?.[1]?.trim()
|
|
92
|
+
return {
|
|
93
|
+
confidence:
|
|
94
|
+
confidence === "high" || confidence === "medium" || confidence === "low"
|
|
95
|
+
? confidence
|
|
96
|
+
: undefined,
|
|
97
|
+
todos: todos === undefined ? undefined : Number.parseInt(todos, 10),
|
|
98
|
+
notes: notes === undefined || notes.length === 0 ? undefined : notes.replace(/\s*\*\/\s*$/u, "")
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The instruction every implementer gets: how to end the file. */
|
|
103
|
+
export const portStatusInstruction = (comment: string): string =>
|
|
104
|
+
[
|
|
105
|
+
`End the file with a machine-readable trailer in ${comment} comments, exactly this shape:`,
|
|
106
|
+
`${comment} PORT STATUS`,
|
|
107
|
+
`${comment} source: <the source path>`,
|
|
108
|
+
`${comment} confidence: high | medium | low`,
|
|
109
|
+
`${comment} todos: <number of TODO(port) markers you left>`,
|
|
110
|
+
`${comment} notes: <one line: what you could not translate faithfully, or "none">`,
|
|
111
|
+
"`confidence: low` means the logic is probably wrong and must be re-read against the source."
|
|
112
|
+
].join("\n")
|
|
113
|
+
|
|
114
|
+
export interface PilotFacts {
|
|
115
|
+
readonly outcomes: ReadonlyArray<QueueOutcome>
|
|
116
|
+
readonly piloted: number
|
|
117
|
+
readonly remaining: number
|
|
118
|
+
readonly elapsedMs: number
|
|
119
|
+
readonly estimatedCostUsd: number | undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const minutes = (ms: number): string => `${(ms / 60_000).toFixed(1)} min`
|
|
123
|
+
|
|
124
|
+
/** The pilot report (R7 of the plan): rates measured, totals extrapolated, an approval to continue. */
|
|
125
|
+
export const renderPilotReport = (facts: PilotFacts): string => {
|
|
126
|
+
const done = facts.outcomes.filter((outcome) => outcome.status === "done")
|
|
127
|
+
const failed = facts.outcomes.filter((outcome) => outcome.status === "failed")
|
|
128
|
+
const count = (level: PortConfidence) =>
|
|
129
|
+
done.filter((outcome) => outcome.confidence === level).length
|
|
130
|
+
const todos = done.reduce((sum, outcome) => sum + (outcome.todos ?? 0), 0)
|
|
131
|
+
const perItemMs = facts.piloted === 0 ? 0 : facts.elapsedMs / facts.piloted
|
|
132
|
+
const perItemCost =
|
|
133
|
+
facts.estimatedCostUsd === undefined || facts.piloted === 0
|
|
134
|
+
? undefined
|
|
135
|
+
: facts.estimatedCostUsd / facts.piloted
|
|
136
|
+
return [
|
|
137
|
+
"# Port pilot",
|
|
138
|
+
"",
|
|
139
|
+
`- piloted: ${facts.piloted} file(s), ${done.length} done, ${failed.length} failed, in ${minutes(facts.elapsedMs)}`,
|
|
140
|
+
`- confidence: ${count("high")} high, ${count("medium")} medium, ${count("low")} low; ${todos} TODO(port) marker(s)`,
|
|
141
|
+
...(perItemCost === undefined
|
|
142
|
+
? []
|
|
143
|
+
: [
|
|
144
|
+
`- cost: ~$${(facts.estimatedCostUsd ?? 0).toFixed(2)} (~$${perItemCost.toFixed(3)} per file)`
|
|
145
|
+
]),
|
|
146
|
+
`- remaining: ${facts.remaining} file(s) → about ${minutes(perItemMs * facts.remaining)} at this rate${
|
|
147
|
+
perItemCost === undefined ? "" : ` and ~$${(perItemCost * facts.remaining).toFixed(2)}`
|
|
148
|
+
}`,
|
|
149
|
+
"",
|
|
150
|
+
...(failed.length === 0
|
|
151
|
+
? []
|
|
152
|
+
: [
|
|
153
|
+
"## Failed",
|
|
154
|
+
"",
|
|
155
|
+
...failed.map((outcome) => `- ${outcome.id}: ${outcome.note ?? ""}`),
|
|
156
|
+
""
|
|
157
|
+
]),
|
|
158
|
+
"Read the drafted files beside their sources, then tick the box to let the next run",
|
|
159
|
+
"port the rest; edit `prompts/porting.md` in the pack first if the drafts show a",
|
|
160
|
+
"pattern the rulebook should settle.",
|
|
161
|
+
"",
|
|
162
|
+
DraftApprovalMarker,
|
|
163
|
+
""
|
|
164
|
+
].join("\n")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A whole new file rendered as a unified diff of additions, for reviewers that judge diffs. */
|
|
168
|
+
export const asAddedFileDiff = (path: string, content: string): string => {
|
|
169
|
+
const lines = content.split(/\r?\n/u)
|
|
170
|
+
return [
|
|
171
|
+
`diff --git a/${path} b/${path}`,
|
|
172
|
+
"new file mode 100644",
|
|
173
|
+
"--- /dev/null",
|
|
174
|
+
`+++ b/${path}`,
|
|
175
|
+
`@@ -0,0 +1,${lines.length} @@`,
|
|
176
|
+
...lines.map((line) => `+${line}`)
|
|
177
|
+
].join("\n")
|
|
178
|
+
}
|