@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.
Files changed (101) hide show
  1. package/dist/Approval.d.ts +1 -1
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Artifacts.d.ts +2 -2
  4. package/dist/Artifacts.d.ts.map +1 -1
  5. package/dist/BenchReport.d.ts +2 -2
  6. package/dist/BenchReport.d.ts.map +1 -1
  7. package/dist/Chat.d.ts +5 -2
  8. package/dist/Chat.d.ts.map +1 -1
  9. package/dist/Chat.js +7 -4
  10. package/dist/Chat.js.map +1 -1
  11. package/dist/CostLedger.d.ts +2 -2
  12. package/dist/CostLedger.d.ts.map +1 -1
  13. package/dist/Diagnostics.d.ts +27 -0
  14. package/dist/Diagnostics.d.ts.map +1 -0
  15. package/dist/Diagnostics.js +84 -0
  16. package/dist/Diagnostics.js.map +1 -0
  17. package/dist/Equiv.d.ts +3 -3
  18. package/dist/Equiv.d.ts.map +1 -1
  19. package/dist/Flow.d.ts +16 -1
  20. package/dist/Flow.d.ts.map +1 -1
  21. package/dist/Flow.js +29 -3
  22. package/dist/Flow.js.map +1 -1
  23. package/dist/FlowError.d.ts +15 -2
  24. package/dist/FlowError.d.ts.map +1 -1
  25. package/dist/FlowError.js +15 -0
  26. package/dist/FlowError.js.map +1 -1
  27. package/dist/FlowEvents.d.ts +12 -1
  28. package/dist/FlowEvents.d.ts.map +1 -1
  29. package/dist/FlowEvents.js +11 -0
  30. package/dist/FlowEvents.js.map +1 -1
  31. package/dist/Gates.d.ts +1 -1
  32. package/dist/Gates.d.ts.map +1 -1
  33. package/dist/Judgment.d.ts +2 -2
  34. package/dist/Judgment.d.ts.map +1 -1
  35. package/dist/Landing.d.ts +1 -1
  36. package/dist/Landing.d.ts.map +1 -1
  37. package/dist/NodePreflight.d.ts +1 -1
  38. package/dist/NodePreflight.d.ts.map +1 -1
  39. package/dist/Pack.d.ts +25 -1
  40. package/dist/Pack.d.ts.map +1 -1
  41. package/dist/Pack.js +38 -2
  42. package/dist/Pack.js.map +1 -1
  43. package/dist/Persistence.d.ts +2 -2
  44. package/dist/Persistence.d.ts.map +1 -1
  45. package/dist/PlanExecution.d.ts +1 -1
  46. package/dist/PlanExecution.d.ts.map +1 -1
  47. package/dist/Port.d.ts +57 -0
  48. package/dist/Port.d.ts.map +1 -0
  49. package/dist/Port.js +134 -0
  50. package/dist/Port.js.map +1 -0
  51. package/dist/ProgramJudge.d.ts +1 -1
  52. package/dist/ProgramJudge.d.ts.map +1 -1
  53. package/dist/ProgramJudge.js +6 -3
  54. package/dist/ProgramJudge.js.map +1 -1
  55. package/dist/Replay.d.ts +2 -2
  56. package/dist/Replay.d.ts.map +1 -1
  57. package/dist/Retro.d.ts +51 -4
  58. package/dist/Retro.d.ts.map +1 -1
  59. package/dist/Retro.js +204 -12
  60. package/dist/Retro.js.map +1 -1
  61. package/dist/Review.d.ts +38 -6
  62. package/dist/Review.d.ts.map +1 -1
  63. package/dist/Review.js +147 -8
  64. package/dist/Review.js.map +1 -1
  65. package/dist/ReviewCache.d.ts +1 -1
  66. package/dist/ReviewCache.d.ts.map +1 -1
  67. package/dist/Reviewer.d.ts +2 -0
  68. package/dist/Reviewer.d.ts.map +1 -1
  69. package/dist/Reviewer.js +3 -1
  70. package/dist/Reviewer.js.map +1 -1
  71. package/dist/Roster.d.ts +1 -1
  72. package/dist/Roster.d.ts.map +1 -1
  73. package/dist/RosterSeats.d.ts +1 -1
  74. package/dist/RosterSeats.d.ts.map +1 -1
  75. package/dist/Stall.d.ts +29 -0
  76. package/dist/Stall.d.ts.map +1 -0
  77. package/dist/Stall.js +49 -0
  78. package/dist/Stall.js.map +1 -0
  79. package/dist/Stories.d.ts +8 -1
  80. package/dist/Stories.d.ts.map +1 -1
  81. package/dist/Stories.js +7 -1
  82. package/dist/Stories.js.map +1 -1
  83. package/dist/WorkQueue.d.ts +63 -0
  84. package/dist/WorkQueue.d.ts.map +1 -0
  85. package/dist/WorkQueue.js +117 -0
  86. package/dist/WorkQueue.js.map +1 -0
  87. package/package.json +6 -2
  88. package/src/Chat.ts +15 -7
  89. package/src/Diagnostics.ts +105 -0
  90. package/src/Flow.ts +48 -3
  91. package/src/FlowError.ts +16 -0
  92. package/src/FlowEvents.ts +14 -0
  93. package/src/Pack.ts +63 -1
  94. package/src/Port.ts +178 -0
  95. package/src/ProgramJudge.ts +3 -0
  96. package/src/Retro.ts +237 -5
  97. package/src/Review.ts +202 -8
  98. package/src/Reviewer.ts +3 -1
  99. package/src/Stall.ts +85 -0
  100. package/src/Stories.ts +14 -0
  101. package/src/WorkQueue.ts +194 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/flow",
3
- "version": "2.31.1",
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.31.1"
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 { collect } from "@llm4ts/core/Streaming"
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
- return collect(options.events === undefined ? stream : withToolActivity(options.events, stream))
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) => isContextOverflow(error) && full.length > system.length + 1,
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: options.reviewers ?? minimalReviewers,
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 { parseReviewer, type Reviewer } from "./Reviewer.ts"
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
+ }