@llm4ts/flow 2.28.1 → 2.30.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/CarriedNotes.d.ts +25 -0
- package/dist/CarriedNotes.d.ts.map +1 -0
- package/dist/CarriedNotes.js +76 -0
- package/dist/CarriedNotes.js.map +1 -0
- package/dist/Flow.d.ts +8 -0
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +13 -1
- package/dist/Flow.js.map +1 -1
- package/dist/Orientation.d.ts +25 -0
- package/dist/Orientation.d.ts.map +1 -0
- package/dist/Orientation.js +150 -0
- package/dist/Orientation.js.map +1 -0
- package/dist/PlanExecution.d.ts +7 -1
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/PlanExecution.js +8 -2
- package/dist/PlanExecution.js.map +1 -1
- package/dist/RefineRound.d.ts.map +1 -1
- package/dist/RefineRound.js +13 -1
- package/dist/RefineRound.js.map +1 -1
- package/dist/Retro.d.ts.map +1 -1
- package/dist/Retro.js +16 -3
- package/dist/Retro.js.map +1 -1
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +5 -3
- package/dist/Review.js.map +1 -1
- package/dist/Spans.d.ts +63 -0
- package/dist/Spans.d.ts.map +1 -0
- package/dist/Spans.js +104 -0
- package/dist/Spans.js.map +1 -0
- package/dist/Stories.d.ts +5 -4
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +107 -34
- package/dist/Stories.js.map +1 -1
- package/dist/StoryPlan.d.ts +33 -1
- package/dist/StoryPlan.d.ts.map +1 -1
- package/dist/StoryPlan.js +62 -4
- package/dist/StoryPlan.js.map +1 -1
- package/dist/Timing.d.ts +21 -1
- package/dist/Timing.d.ts.map +1 -1
- package/dist/Timing.js +218 -18
- package/dist/Timing.js.map +1 -1
- package/dist/Transcript.d.ts.map +1 -1
- package/dist/Transcript.js +3 -2
- package/dist/Transcript.js.map +1 -1
- package/package.json +5 -2
- package/src/CarriedNotes.ts +103 -0
- package/src/Flow.ts +31 -1
- package/src/Orientation.ts +192 -0
- package/src/PlanExecution.ts +21 -10
- package/src/RefineRound.ts +13 -1
- package/src/Retro.ts +26 -3
- package/src/Review.ts +18 -12
- package/src/Spans.ts +150 -0
- package/src/Stories.ts +140 -45
- package/src/StoryPlan.ts +78 -2
- package/src/Timing.ts +343 -16
- package/src/Transcript.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/flow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.30.0",
|
|
4
4
|
"description": "Effect-native LLM workflow, persistence, review, and repository automation",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"./Blackboard": "./dist/Blackboard.js",
|
|
25
25
|
"./BoardSync": "./dist/BoardSync.js",
|
|
26
26
|
"./CapabilityGuard": "./dist/CapabilityGuard.js",
|
|
27
|
+
"./CarriedNotes": "./dist/CarriedNotes.js",
|
|
27
28
|
"./Chat": "./dist/Chat.js",
|
|
28
29
|
"./Classified": "./dist/Classified.js",
|
|
29
30
|
"./Context": "./dist/Context.js",
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
"./JudgmentLog": "./dist/JudgmentLog.js",
|
|
58
59
|
"./Perimeter": "./dist/Perimeter.js",
|
|
59
60
|
"./NodePreflight": "./dist/NodePreflight.js",
|
|
61
|
+
"./Orientation": "./dist/Orientation.js",
|
|
60
62
|
"./Persistence": "./dist/Persistence.js",
|
|
61
63
|
"./Plan": "./dist/Plan.js",
|
|
62
64
|
"./PlanExecution": "./dist/PlanExecution.js",
|
|
@@ -72,6 +74,7 @@
|
|
|
72
74
|
"./ReviewCache": "./dist/ReviewCache.js",
|
|
73
75
|
"./Reviewer": "./dist/Reviewer.js",
|
|
74
76
|
"./SpecChecks": "./dist/SpecChecks.js",
|
|
77
|
+
"./Spans": "./dist/Spans.js",
|
|
75
78
|
"./Roster": "./dist/Roster.js",
|
|
76
79
|
"./RosterSeats": "./dist/RosterSeats.js",
|
|
77
80
|
"./Stories": "./dist/Stories.js",
|
|
@@ -110,7 +113,7 @@
|
|
|
110
113
|
"typescript"
|
|
111
114
|
],
|
|
112
115
|
"dependencies": {
|
|
113
|
-
"@llm4ts/core": "2.
|
|
116
|
+
"@llm4ts/core": "2.30.0"
|
|
114
117
|
},
|
|
115
118
|
"peerDependencies": {
|
|
116
119
|
"effect": "4.0.0"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// What a story's earlier tasks learned, carried into the next task's prompt.
|
|
2
|
+
// With one chat per task (ADR 0003) every task starts cold; without this a
|
|
3
|
+
// six-task story explores the repository six times. The coder ends each task
|
|
4
|
+
// with a short Findings section; code saves it beside the story and prepends
|
|
5
|
+
// the accumulated notes to the next task. Pure helpers here, the seam in Flow.
|
|
6
|
+
import type * as Effect from "effect/Effect"
|
|
7
|
+
import type { FlowError } from "./FlowError.ts"
|
|
8
|
+
|
|
9
|
+
export const notesHeading = "## Findings"
|
|
10
|
+
|
|
11
|
+
/** Appended to every task prompt that carries notes. */
|
|
12
|
+
export const findingsRequest = [
|
|
13
|
+
"",
|
|
14
|
+
`When the task is done, end your reply with a section headed \`${notesHeading}\` of at most`,
|
|
15
|
+
"ten lines: files you read or changed that matter for the rest of this story, conventions",
|
|
16
|
+
"you learned (where tests live, how a feature is registered, which kit component to use),",
|
|
17
|
+
"and commands that worked. Nothing follows it — unless you must stop with BLOCKED_ON:,",
|
|
18
|
+
"which stays the last line."
|
|
19
|
+
].join("\n")
|
|
20
|
+
|
|
21
|
+
const sectionChars = 1_500
|
|
22
|
+
const headingPattern = /^#{1,3}[ \t]*findings[ \t]*:?[ \t]*$/gimu
|
|
23
|
+
const blockedLine = /^BLOCKED_ON:.*$/u
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The text after the LAST Findings heading, without a trailing BLOCKED_ON
|
|
27
|
+
* line (that stays the reply's last line for `blockedOnIn`), capped;
|
|
28
|
+
* undefined when there is no heading or nothing under it.
|
|
29
|
+
*/
|
|
30
|
+
export const findingsIn = (reply: string): string | undefined => {
|
|
31
|
+
let last: RegExpExecArray | undefined
|
|
32
|
+
headingPattern.lastIndex = 0
|
|
33
|
+
for (let match = headingPattern.exec(reply); match !== null; match = headingPattern.exec(reply)) {
|
|
34
|
+
last = match
|
|
35
|
+
}
|
|
36
|
+
if (last === undefined) {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
const lines = reply
|
|
40
|
+
.slice(last.index + last[0].length)
|
|
41
|
+
.split(/\r?\n/u)
|
|
42
|
+
.map((line) => line.trimEnd())
|
|
43
|
+
while (lines.length > 0 && (lines.at(-1) ?? "").trim().length === 0) {
|
|
44
|
+
lines.pop()
|
|
45
|
+
}
|
|
46
|
+
if (
|
|
47
|
+
lines.length > 0 &&
|
|
48
|
+
blockedLine.test((lines.at(-1) ?? "").trim().replace(/^[`*_\s]+|[`*_\s]+$/gu, ""))
|
|
49
|
+
) {
|
|
50
|
+
lines.pop()
|
|
51
|
+
}
|
|
52
|
+
const text = lines.join("\n").trim()
|
|
53
|
+
return text.length === 0 ? undefined : text.slice(0, sectionChars)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const notesLimit = 6_000
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `notes` plus a titled section for `found`; when the result is over `limit`
|
|
60
|
+
* the oldest sections go first, the newest always stays.
|
|
61
|
+
*/
|
|
62
|
+
export const appendNote = (
|
|
63
|
+
notes: string | undefined,
|
|
64
|
+
title: string,
|
|
65
|
+
found: string,
|
|
66
|
+
limit = notesLimit
|
|
67
|
+
): string => {
|
|
68
|
+
// A finding's own headings are flattened: `### ` is this file's section
|
|
69
|
+
// marker, and eviction must never split a section in two.
|
|
70
|
+
const flat = found
|
|
71
|
+
.trim()
|
|
72
|
+
.split(/\r?\n/u)
|
|
73
|
+
.map((line) => line.replace(/^#{1,6}[ \t]*/u, ""))
|
|
74
|
+
.join("\n")
|
|
75
|
+
const sections = [
|
|
76
|
+
...(notes === undefined || notes.trim().length === 0 ? [] : notes.split(/\n\n(?=### )/u)),
|
|
77
|
+
`### ${title}\n${flat}`
|
|
78
|
+
]
|
|
79
|
+
while (sections.length > 1 && sections.join("\n\n").length > limit) {
|
|
80
|
+
sections.shift()
|
|
81
|
+
}
|
|
82
|
+
return sections.join("\n\n")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The prompt with the notes in front, or the prompt itself when there are none. */
|
|
86
|
+
export const withNotes = (prompt: string, notes: string | undefined): string =>
|
|
87
|
+
notes === undefined || notes.trim().length === 0
|
|
88
|
+
? prompt
|
|
89
|
+
: [
|
|
90
|
+
"What earlier tasks of this story learned — trust it before exploring:",
|
|
91
|
+
"",
|
|
92
|
+
notes.trim(),
|
|
93
|
+
"",
|
|
94
|
+
"---",
|
|
95
|
+
"",
|
|
96
|
+
prompt
|
|
97
|
+
].join("\n")
|
|
98
|
+
|
|
99
|
+
/** Where a story keeps its carried notes: read before a task, written after it. */
|
|
100
|
+
export interface CarriedNotes {
|
|
101
|
+
readonly read: Effect.Effect<string | undefined, FlowError>
|
|
102
|
+
readonly write: (notes: string) => Effect.Effect<void, FlowError>
|
|
103
|
+
}
|
package/src/Flow.ts
CHANGED
|
@@ -2,6 +2,13 @@ import * as Effect from "effect/Effect"
|
|
|
2
2
|
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
3
3
|
import { collect } from "@llm4ts/core/Streaming"
|
|
4
4
|
import { withToolActivity } from "./Activity.ts"
|
|
5
|
+
import {
|
|
6
|
+
appendNote,
|
|
7
|
+
findingsIn,
|
|
8
|
+
findingsRequest,
|
|
9
|
+
withNotes,
|
|
10
|
+
type CarriedNotes
|
|
11
|
+
} from "./CarriedNotes.ts"
|
|
5
12
|
import { makeChat, type Chat } from "./Chat.ts"
|
|
6
13
|
import type { FlowContextShape } from "./FlowContext.ts"
|
|
7
14
|
import { FlowAborted, FlowLlmError, type FlowError } from "./FlowError.ts"
|
|
@@ -97,6 +104,13 @@ export interface ImplementPlanOptions {
|
|
|
97
104
|
* or failure. The legacy "judgment" string remains an alias for { mode: "act" }.
|
|
98
105
|
*/
|
|
99
106
|
readonly satisfiedProbe?: "literal" | "judgment" | { readonly mode?: JudgmentMode }
|
|
107
|
+
/**
|
|
108
|
+
* Notes carried from one task to the next (meant for `chatPerTask`, where
|
|
109
|
+
* every task starts cold): the accumulated notes open each task's prompt,
|
|
110
|
+
* the task is asked to end with a Findings section, and what it reports is
|
|
111
|
+
* appended. Omit to carry nothing.
|
|
112
|
+
*/
|
|
113
|
+
readonly carry?: CarriedNotes
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
/** The judgment form of the empty-diff probe: one Truth question over the coder's reply. */
|
|
@@ -193,7 +207,22 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
193
207
|
// the top of this function: a task prompt only needs that task's own
|
|
194
208
|
// details. `planSoFar`, threaded through by implementTaskLoop, is the
|
|
195
209
|
// single source of truth for completion progress instead.
|
|
196
|
-
yield*
|
|
210
|
+
const notes = options.carry === undefined ? undefined : yield* options.carry.read
|
|
211
|
+
/** What the task's reply learned goes to the carried notes, whichever turn did the work. */
|
|
212
|
+
const keepFindings = (reply: string): Effect.Effect<void, FlowError> =>
|
|
213
|
+
Effect.gen(function* () {
|
|
214
|
+
const found = options.carry === undefined ? undefined : findingsIn(reply)
|
|
215
|
+
if (options.carry !== undefined && found !== undefined) {
|
|
216
|
+
yield* options.carry.write(appendNote(notes, task.title, found))
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
yield* keepFindings(
|
|
220
|
+
yield* coder.ask(
|
|
221
|
+
options.carry === undefined
|
|
222
|
+
? plan.taskPrompt(task)
|
|
223
|
+
: `${withNotes(plan.taskPrompt(task), notes)}\n${findingsRequest}`
|
|
224
|
+
)
|
|
225
|
+
)
|
|
197
226
|
const produced = yield* context.git.diffAll
|
|
198
227
|
if (produced.trim().length === 0) {
|
|
199
228
|
// An empty diff is ambiguous: the task may be genuinely satisfied
|
|
@@ -208,6 +237,7 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
208
237
|
"Otherwise, implement the task now."
|
|
209
238
|
].join("\n")
|
|
210
239
|
)
|
|
240
|
+
yield* keepFindings(confirmation)
|
|
211
241
|
const afterConfirmation = yield* context.git.diffAll
|
|
212
242
|
if (afterConfirmation.trim().length === 0) {
|
|
213
243
|
const judged =
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The repository as a planner and a coder should see it before they look:
|
|
2
|
+
// a deterministic digest of the tracked files — folders with their counts,
|
|
3
|
+
// small folders in full, the package scripts, where the tests live. No model
|
|
4
|
+
// call, computed once per epic, so every story stops rediscovering the
|
|
5
|
+
// layout with `ls`, `find` and `grep` round trips.
|
|
6
|
+
import { cap } from "./Context.ts"
|
|
7
|
+
|
|
8
|
+
export interface OrientationInput {
|
|
9
|
+
/** Tracked files, repo-relative, as `git ls-files` prints them. */
|
|
10
|
+
readonly files: ReadonlyArray<string>
|
|
11
|
+
/** The application's `package.json` text, when there is one. */
|
|
12
|
+
readonly packageJson?: string
|
|
13
|
+
/** Where the application lives: `.` for the repository root, else a subfolder. */
|
|
14
|
+
readonly appDir?: string
|
|
15
|
+
/** Characters the digest may take; `<= 0` leaves it out. */
|
|
16
|
+
readonly budget: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const defaultOrientationChars = 8_000
|
|
20
|
+
|
|
21
|
+
/** Folders never worth a coder's attention, wherever they sit: dependencies and state. */
|
|
22
|
+
const noiseAnywhere = new Set(["node_modules", ".git", ".llm4ts"])
|
|
23
|
+
/** Build output is noise at the top level only: `src/features/build/` is code. */
|
|
24
|
+
const noiseAtRoot = new Set(["dist", "build", "out", "coverage"])
|
|
25
|
+
const noiseFiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"])
|
|
26
|
+
|
|
27
|
+
/** Folders deeper than this are summarised by their count. */
|
|
28
|
+
const maxDepth = 3
|
|
29
|
+
/** A folder with this many files or fewer is listed in full. */
|
|
30
|
+
const smallFolder = 12
|
|
31
|
+
const testFile = /\.(test|spec)\.[cm]?[jt]sx?$/u
|
|
32
|
+
|
|
33
|
+
interface Folder {
|
|
34
|
+
readonly path: string
|
|
35
|
+
files: number
|
|
36
|
+
readonly children: Map<string, Folder>
|
|
37
|
+
readonly own: Array<string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const folderOf = (path: string): Folder => ({ path, files: 0, children: new Map(), own: [] })
|
|
41
|
+
|
|
42
|
+
const isNoise = (file: string): boolean => {
|
|
43
|
+
const parts = file.split("/")
|
|
44
|
+
return (
|
|
45
|
+
parts.some((part) => noiseAnywhere.has(part)) ||
|
|
46
|
+
(parts.length > 1 && noiseAtRoot.has(parts[0] ?? "")) ||
|
|
47
|
+
noiseFiles.has(parts.at(-1) ?? "")
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const treeOf = (files: ReadonlyArray<string>): Folder => {
|
|
52
|
+
const root = folderOf("")
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
const parts = file.split("/").filter((part) => part.length > 0)
|
|
55
|
+
let folder = root
|
|
56
|
+
folder.files += 1
|
|
57
|
+
for (const part of parts.slice(0, -1)) {
|
|
58
|
+
let child = folder.children.get(part)
|
|
59
|
+
if (child === undefined) {
|
|
60
|
+
child = folderOf(folder.path.length === 0 ? part : `${folder.path}/${part}`)
|
|
61
|
+
folder.children.set(part, child)
|
|
62
|
+
}
|
|
63
|
+
child.files += 1
|
|
64
|
+
folder = child
|
|
65
|
+
}
|
|
66
|
+
folder.own.push(file)
|
|
67
|
+
}
|
|
68
|
+
return root
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sortedChildren = (folder: Folder): ReadonlyArray<Folder> =>
|
|
72
|
+
[...folder.children.values()].sort((left, right) => left.path.localeCompare(right.path))
|
|
73
|
+
|
|
74
|
+
const folderLine = (folder: Folder, depth: number): string =>
|
|
75
|
+
`${" ".repeat(depth)}${folder.path}/ (${folder.files} ${folder.files === 1 ? "file" : "files"})`
|
|
76
|
+
|
|
77
|
+
/** A small folder in full: its own files, then every subfolder the same way. */
|
|
78
|
+
const expanded = (folder: Folder, depth: number): ReadonlyArray<string> => [
|
|
79
|
+
...[...folder.own].sort().map((file) => `${" ".repeat(depth)}${file}`),
|
|
80
|
+
...sortedChildren(folder).flatMap((child) => [
|
|
81
|
+
folderLine(child, depth),
|
|
82
|
+
...expanded(child, depth + 1)
|
|
83
|
+
])
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The layout: every folder with its count; a folder of `smallFolder` files or
|
|
88
|
+
* fewer listed in full, a larger one descended into up to `maxDepth`.
|
|
89
|
+
*/
|
|
90
|
+
const layoutLines = (folder: Folder, depth: number): ReadonlyArray<string> =>
|
|
91
|
+
sortedChildren(folder).flatMap((child) => {
|
|
92
|
+
if (child.files <= smallFolder) {
|
|
93
|
+
return [folderLine(child, depth), ...expanded(child, depth + 1)]
|
|
94
|
+
}
|
|
95
|
+
const own =
|
|
96
|
+
child.own.length <= smallFolder
|
|
97
|
+
? [...child.own].sort().map((file) => `${" ".repeat(depth + 1)}${file}`)
|
|
98
|
+
: []
|
|
99
|
+
return [
|
|
100
|
+
folderLine(child, depth),
|
|
101
|
+
...own,
|
|
102
|
+
...(depth + 1 < maxDepth ? layoutLines(child, depth + 1) : [])
|
|
103
|
+
]
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const scriptsLine = (packageJson: string | undefined): string | undefined => {
|
|
107
|
+
if (packageJson === undefined) {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
let parsed: unknown
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(packageJson)
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
if (typeof parsed !== "object" || parsed === null || !("scripts" in parsed)) {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
const scripts = parsed.scripts
|
|
120
|
+
if (typeof scripts !== "object" || scripts === null) {
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
const entries = Object.entries(scripts).flatMap(([name, command]) =>
|
|
124
|
+
typeof command === "string" ? [`${name}: ${cap(command, 80).text}`] : []
|
|
125
|
+
)
|
|
126
|
+
return entries.length === 0 ? undefined : `Scripts (package.json): ${entries.join("; ")}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const testsLine = (files: ReadonlyArray<string>): string | undefined => {
|
|
130
|
+
const counts = new Map<string, number>()
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
if (testFile.test(file) || file.includes("/__tests__/")) {
|
|
133
|
+
const folder = file.split("/").slice(0, -1).join("/") || "."
|
|
134
|
+
counts.set(folder, (counts.get(folder) ?? 0) + 1)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (counts.size === 0) {
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
const shown = [...counts.entries()]
|
|
141
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
|
142
|
+
.slice(0, 8)
|
|
143
|
+
.map(([folder, count]) => `${folder} (${count})`)
|
|
144
|
+
const more = counts.size - shown.length
|
|
145
|
+
return `Tests live beside the code in: ${shown.join(", ")}${more > 0 ? ` and ${more} more folder(s)` : ""}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The digest, or `undefined` when there is nothing to say (no tracked files)
|
|
150
|
+
* or no budget. Never longer than `budget`: the head (the layout) and the
|
|
151
|
+
* tail (scripts, tests) survive a cut, the middle of a long listing does not.
|
|
152
|
+
*/
|
|
153
|
+
export const orientationOf = (input: OrientationInput): string | undefined => {
|
|
154
|
+
if (input.budget <= 0) {
|
|
155
|
+
return undefined
|
|
156
|
+
}
|
|
157
|
+
const files = input.files.filter((file) => !isNoise(file))
|
|
158
|
+
if (files.length === 0) {
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
const root = treeOf(files)
|
|
162
|
+
const appDir = input.appDir ?? "."
|
|
163
|
+
const text = [
|
|
164
|
+
"## Repository orientation",
|
|
165
|
+
"Tracked files by folder (counts; small folders listed in full) — go straight to the right",
|
|
166
|
+
"place instead of listing directories or searching for where things live.",
|
|
167
|
+
...(appDir === "."
|
|
168
|
+
? []
|
|
169
|
+
: [`The application lives in ${appDir}/ — its package.json, sources and tests.`]),
|
|
170
|
+
"",
|
|
171
|
+
...[...root.own].sort(),
|
|
172
|
+
...layoutLines(root, 0),
|
|
173
|
+
...[scriptsLine(input.packageJson), testsLine(files)].flatMap((line) =>
|
|
174
|
+
line === undefined ? [] : ["", line]
|
|
175
|
+
)
|
|
176
|
+
].join("\n")
|
|
177
|
+
return cap(text, input.budget).text
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* LLM4TS_ORIENTATION_CHARS: how much of the repository digest the planner and
|
|
182
|
+
* every coder see (0 leaves it out); the default otherwise.
|
|
183
|
+
*/
|
|
184
|
+
export const orientationChars = (
|
|
185
|
+
environment: Readonly<Record<string, string | undefined>>
|
|
186
|
+
): { readonly orientationChars?: number } => {
|
|
187
|
+
const raw = environment.LLM4TS_ORIENTATION_CHARS?.trim()
|
|
188
|
+
const value = Number(raw ?? "")
|
|
189
|
+
return raw === undefined || raw.length === 0 || !Number.isInteger(value) || value < 0
|
|
190
|
+
? {}
|
|
191
|
+
: { orientationChars: value }
|
|
192
|
+
}
|
package/src/PlanExecution.ts
CHANGED
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
import * as Effect from "effect/Effect"
|
|
2
2
|
import { StageCompleted, StageFailed, StageStarted, type FlowEventsShape } from "./FlowEvents.ts"
|
|
3
|
+
import { withKindSpan, type SpanOptions } from "./Spans.ts"
|
|
3
4
|
import { describeFlowError, type FlowError } from "./FlowError.ts"
|
|
4
5
|
import type { Plan, Task } from "./Plan.ts"
|
|
5
6
|
import type { PlanStoreShape } from "./Persistence.ts"
|
|
6
7
|
|
|
7
8
|
const errorMessage = describeFlowError
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A named step of a flow: published as StageStarted/Completed/Failed for the
|
|
12
|
+
* terminal, the trace and the profile, and a span of `span.kind` (CHAIN by
|
|
13
|
+
* default) for OpenTelemetry (ADR 0026).
|
|
14
|
+
*/
|
|
9
15
|
export const stage = <A, E, R>(
|
|
10
16
|
events: FlowEventsShape,
|
|
11
17
|
name: string,
|
|
12
|
-
effect: Effect.Effect<A, E, R
|
|
18
|
+
effect: Effect.Effect<A, E, R>,
|
|
19
|
+
span: SpanOptions = { kind: "CHAIN" }
|
|
13
20
|
): Effect.Effect<A, E, R> =>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
withKindSpan(
|
|
22
|
+
name,
|
|
23
|
+
span,
|
|
24
|
+
events.publish(StageStarted.make({ stage: name })).pipe(
|
|
25
|
+
Effect.andThen(effect),
|
|
26
|
+
Effect.tap(() => events.publish(StageCompleted.make({ stage: name }))),
|
|
27
|
+
Effect.tapError((error) =>
|
|
28
|
+
events.publish(
|
|
29
|
+
StageFailed.make({
|
|
30
|
+
stage: name,
|
|
31
|
+
message: errorMessage(error)
|
|
32
|
+
})
|
|
33
|
+
)
|
|
23
34
|
)
|
|
24
35
|
)
|
|
25
36
|
)
|
package/src/RefineRound.ts
CHANGED
|
@@ -38,9 +38,21 @@ export const refineProposalJsonSchema: JsonSchema = {
|
|
|
38
38
|
dependsOn: strings,
|
|
39
39
|
owned: strings,
|
|
40
40
|
sharedReadOnly: strings,
|
|
41
|
+
readFirst: strings,
|
|
42
|
+
acceptance: strings,
|
|
41
43
|
provides: strings
|
|
42
44
|
},
|
|
43
|
-
required: [
|
|
45
|
+
required: [
|
|
46
|
+
"id",
|
|
47
|
+
"title",
|
|
48
|
+
"description",
|
|
49
|
+
"dependsOn",
|
|
50
|
+
"owned",
|
|
51
|
+
"sharedReadOnly",
|
|
52
|
+
"readFirst",
|
|
53
|
+
"acceptance",
|
|
54
|
+
"provides"
|
|
55
|
+
]
|
|
44
56
|
}
|
|
45
57
|
},
|
|
46
58
|
notPlanned: {
|
package/src/Retro.ts
CHANGED
|
@@ -213,6 +213,16 @@ const countBy = (items: ReadonlyArray<string>): string =>
|
|
|
213
213
|
.map(([key, count]) => `${key} ×${count}`)
|
|
214
214
|
.join(", ")
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* A transcript `--land` compacted: every call's input is empty and no reply
|
|
218
|
+
* is left (ADR 0025). Its shape is still worth reading; its words are gone.
|
|
219
|
+
*/
|
|
220
|
+
const isCompacted = (entries: ReadonlyArray<TranscriptEntry>): boolean =>
|
|
221
|
+
entries.length > 0 &&
|
|
222
|
+
entries.every((entry) =>
|
|
223
|
+
entry._tag === "Call" ? entry.input.length === 0 : entry._tag !== "Reply"
|
|
224
|
+
)
|
|
225
|
+
|
|
216
226
|
const head = (text: string, limit = transcriptHead): string => {
|
|
217
227
|
const flat = text.replace(/\s+/g, " ").trim()
|
|
218
228
|
return flat.length <= limit ? flat : `${flat.slice(0, limit)}…`
|
|
@@ -373,11 +383,17 @@ const storySection = (
|
|
|
373
383
|
`- failed tool results: ${failures.length}`
|
|
374
384
|
]
|
|
375
385
|
const firstFailure = failures[0]
|
|
376
|
-
if (
|
|
386
|
+
if (
|
|
387
|
+
firstFailure !== undefined &&
|
|
388
|
+
firstFailure._tag === "ToolResult" &&
|
|
389
|
+
firstFailure.output.length > 0
|
|
390
|
+
) {
|
|
377
391
|
transcriptLines.push(`- first failed tool result: ${head(firstFailure.output)}`)
|
|
378
392
|
}
|
|
379
393
|
if (lastCall !== undefined && lastCall._tag === "Call") {
|
|
380
|
-
transcriptLines.push(
|
|
394
|
+
transcriptLines.push(
|
|
395
|
+
`- last call: ${lastCall.role} — ${isCompacted(entries) ? "(compacted)" : head(lastCall.input)}`
|
|
396
|
+
)
|
|
381
397
|
}
|
|
382
398
|
if (lastReply !== undefined && lastReply._tag === "Reply") {
|
|
383
399
|
transcriptLines.push(`- last reply: ${head(lastReply.text)}`)
|
|
@@ -460,7 +476,14 @@ export const renderRetroDigest = (inputs: RetroInputs): string => {
|
|
|
460
476
|
"",
|
|
461
477
|
`- run outcome: ${trace.outcome ?? "unknown (the trace has no end line: the process died)"}`,
|
|
462
478
|
`- stories: ${inputs.plan.stories.length} planned, ${candidates.length} to look at (${candidates.map((c) => c.story.id).join(", ") || "none"})`,
|
|
463
|
-
`- transcripts: ${
|
|
479
|
+
`- transcripts: ${
|
|
480
|
+
inputs.transcripts === undefined
|
|
481
|
+
? "none (the run was started without --transcript)"
|
|
482
|
+
: [...inputs.transcripts.values()].some((entries) => entries.length > 0) &&
|
|
483
|
+
[...inputs.transcripts.values()].every(isCompacted)
|
|
484
|
+
? "present, compacted (calls, tools and timings; content removed on land)"
|
|
485
|
+
: "present"
|
|
486
|
+
}`,
|
|
464
487
|
...(retries.length === 0 ? [] : [`- retries across the run: ${countBy(retries)}`]),
|
|
465
488
|
...(runLines.length === 0
|
|
466
489
|
? []
|
package/src/Review.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
Timed,
|
|
22
22
|
type FlowEventsShape
|
|
23
23
|
} from "./FlowEvents.ts"
|
|
24
|
+
import { attr, withKindSpan } from "./Spans.ts"
|
|
24
25
|
import { Reviewer } from "./Reviewer.ts"
|
|
25
26
|
import { publishUsage } from "./Usage.ts"
|
|
26
27
|
import type { JudgmentShape } from "@llm4ts/core/judgment/Judgment"
|
|
@@ -275,20 +276,25 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
|
|
|
275
276
|
return ReviewResult.make({ issues: [], summary: "" })
|
|
276
277
|
}
|
|
277
278
|
const started = yield* Clock.currentTimeMillis
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
279
|
+
// A TOOL span (ADR 0026) and a Timed event: the command as configured and
|
|
280
|
+
// its exit code, never its output.
|
|
281
|
+
const result = yield* withKindSpan(
|
|
282
|
+
`gate ${command.join(" ")}`,
|
|
283
|
+
{ kind: "TOOL", attributes: { [attr.gateCommand]: command.join(" ") } },
|
|
284
|
+
guarded(
|
|
285
|
+
Capabilities.Exec(executable),
|
|
286
|
+
`lint: ${command.join(" ")}`,
|
|
287
|
+
events,
|
|
288
|
+
process.run(command, workDir, {}).pipe(
|
|
289
|
+
Effect.mapError((cause) =>
|
|
290
|
+
ProcessError.make({
|
|
291
|
+
message: command.join(" "),
|
|
292
|
+
detail: cause.message
|
|
293
|
+
})
|
|
294
|
+
)
|
|
288
295
|
)
|
|
289
|
-
)
|
|
296
|
+
).pipe(Effect.tap((ran) => Effect.annotateCurrentSpan(attr.gateExit, ran.exitCode)))
|
|
290
297
|
)
|
|
291
|
-
// The command as configured and its exit code: never its output.
|
|
292
298
|
yield* events.publish(
|
|
293
299
|
Timed.make({
|
|
294
300
|
kind: "gate",
|