@llm4ts/flow 2.28.1 → 2.29.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/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/Stories.d.ts +5 -4
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +46 -11
- 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/Transcript.d.ts.map +1 -1
- package/dist/Transcript.js +3 -2
- package/dist/Transcript.js.map +1 -1
- package/package.json +4 -2
- package/src/CarriedNotes.ts +103 -0
- package/src/Flow.ts +31 -1
- package/src/Orientation.ts +192 -0
- package/src/RefineRound.ts +13 -1
- package/src/Retro.ts +26 -3
- package/src/Stories.ts +52 -11
- package/src/StoryPlan.ts +78 -2
- package/src/Transcript.ts +3 -2
|
@@ -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/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/Stories.ts
CHANGED
|
@@ -314,10 +314,11 @@ const defaultContextChars = 40_000
|
|
|
314
314
|
const fileChars = 8_000
|
|
315
315
|
|
|
316
316
|
/**
|
|
317
|
-
* The code a story starts from, for its coder's system prompt: the
|
|
318
|
-
* read-only files it builds on, then the
|
|
319
|
-
* `budget` runs out; the rest by path only.
|
|
320
|
-
* `ls`/`find`/`cat` round trips the coder
|
|
317
|
+
* The code a story starts from, for its coder's system prompt: the planner's
|
|
318
|
+
* read-first anchors, then the shared read-only files it builds on, then the
|
|
319
|
+
* files it owns, each capped, until `budget` runs out; the rest by path only.
|
|
320
|
+
* Reading them up front saves the `ls`/`find`/`cat` round trips the coder
|
|
321
|
+
* would otherwise spend on them.
|
|
321
322
|
*/
|
|
322
323
|
export const startingCodeOf = (
|
|
323
324
|
story: Story,
|
|
@@ -330,42 +331,66 @@ export const startingCodeOf = (
|
|
|
330
331
|
if (budget <= 0) {
|
|
331
332
|
return undefined
|
|
332
333
|
}
|
|
334
|
+
const anchors = yield* git.listFiles(story.readFirst)
|
|
333
335
|
const shared = yield* git.listFiles(story.sharedReadOnly)
|
|
334
336
|
const owned = yield* git.listFiles(story.owned)
|
|
335
|
-
if (shared.length + owned.length === 0) {
|
|
337
|
+
if (anchors.length + shared.length + owned.length === 0) {
|
|
336
338
|
return undefined
|
|
337
339
|
}
|
|
338
340
|
let left = budget
|
|
339
341
|
const leftOut: Array<string> = []
|
|
340
|
-
const
|
|
342
|
+
const shown = new Set<string>()
|
|
343
|
+
// `limit`: how far this section may draw the budget down. The anchors
|
|
344
|
+
// stop at half, so a broad anchor (a whole feature folder) cannot starve
|
|
345
|
+
// the story's own files; the later sections may use everything left.
|
|
346
|
+
const section = (title: string, paths: ReadonlyArray<string>, limit = 0) =>
|
|
341
347
|
Effect.gen(function* () {
|
|
342
348
|
const parts: Array<string> = []
|
|
343
349
|
for (const path of paths) {
|
|
350
|
+
if (shown.has(path)) {
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
344
353
|
const text = yield* Effect.orElseSucceed(
|
|
345
354
|
files.read(join(worktree, path)),
|
|
346
355
|
() => undefined
|
|
347
356
|
)
|
|
348
|
-
if (text === undefined || left <=
|
|
357
|
+
if (text === undefined || left <= limit) {
|
|
349
358
|
leftOut.push(path)
|
|
350
359
|
continue
|
|
351
360
|
}
|
|
352
|
-
|
|
353
|
-
left
|
|
354
|
-
|
|
361
|
+
shown.add(path)
|
|
362
|
+
const piece = cap(text, Math.min(fileChars, left - limit)).text
|
|
363
|
+
left -= piece.length
|
|
364
|
+
parts.push(`### ${path}\n\`\`\`\n${piece}\n\`\`\``)
|
|
355
365
|
}
|
|
356
366
|
return parts.length === 0 ? [] : [title, ...parts]
|
|
357
367
|
})
|
|
358
368
|
return [
|
|
359
369
|
"## The code you start from",
|
|
360
370
|
"Read this before exploring: it is the current content of the files below.",
|
|
371
|
+
...(yield* section(
|
|
372
|
+
"Read first — what the planner says to imitate or build on:",
|
|
373
|
+
anchors,
|
|
374
|
+
Math.floor(budget / 2)
|
|
375
|
+
)),
|
|
361
376
|
...(yield* section("Shared, read-only — use these as they are:", shared)),
|
|
362
377
|
...(yield* section("Yours — the story's owned files so far:", owned)),
|
|
363
378
|
...(leftOut.length === 0 ? [] : [`Not shown (over the budget): ${leftOut.join(", ")}`])
|
|
364
379
|
].join("\n\n")
|
|
365
380
|
})
|
|
366
381
|
|
|
382
|
+
const numbered = (items: ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
383
|
+
items.map((item, index) => `${index + 1}. ${item}`)
|
|
384
|
+
|
|
367
385
|
export const storyPrompt = (story: Story): string =>
|
|
368
|
-
[
|
|
386
|
+
[
|
|
387
|
+
`Story: ${story.title}`,
|
|
388
|
+
"",
|
|
389
|
+
story.description.trim(),
|
|
390
|
+
...(story.acceptance.length === 0
|
|
391
|
+
? []
|
|
392
|
+
: ["", "Done when (each must hold, observably):", ...numbered(story.acceptance)])
|
|
393
|
+
].join("\n")
|
|
369
394
|
|
|
370
395
|
export const storyTaskPlanInstructions = (story: Story): string =>
|
|
371
396
|
[
|
|
@@ -373,6 +398,14 @@ export const storyTaskPlanInstructions = (story: Story): string =>
|
|
|
373
398
|
"into an ordered list of small, independently verifiable tasks, each described by its",
|
|
374
399
|
"observable outcome. Every task must stay inside the story's owned paths:",
|
|
375
400
|
bullets(story.owned),
|
|
401
|
+
...(story.acceptance.length === 0
|
|
402
|
+
? []
|
|
403
|
+
: [
|
|
404
|
+
"The story is done when every one of these holds:",
|
|
405
|
+
...numbered(story.acceptance),
|
|
406
|
+
"End every task's description with `Satisfies: <n>` naming the criteria it serves, and",
|
|
407
|
+
"make sure every criterion is served by at least one task."
|
|
408
|
+
]),
|
|
376
409
|
"Never plan a task that creates or changes anything outside them — registering or wiring",
|
|
377
410
|
"the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
|
|
378
411
|
`Use exactly this epicId: "${story.id}".`,
|
|
@@ -681,6 +714,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
681
714
|
/** What reviewers and the judge found, round by round, for people to read. */
|
|
682
715
|
const findingsPath = (story: Story): string =>
|
|
683
716
|
join(options.stateDir, `stories/${story.id}.findings.md`)
|
|
717
|
+
/** What the coder's tasks learned, carried into the next task of the same story. */
|
|
718
|
+
const notesPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.notes.md`)
|
|
684
719
|
const findingLines = (result: ReviewResult): ReadonlyArray<string> =>
|
|
685
720
|
result.issues.map((issue) => {
|
|
686
721
|
const where =
|
|
@@ -824,7 +859,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
824
859
|
}
|
|
825
860
|
// The task checkpoint belongs to the old branch: left in place, the
|
|
826
861
|
// fresh branch would inherit "every task complete" and skip the coder.
|
|
862
|
+
// The notes its tasks carried describe the old definition: gone too.
|
|
827
863
|
yield* files.remove(planPath(story))
|
|
864
|
+
yield* files.remove(notesPath(story))
|
|
828
865
|
stored = undefined
|
|
829
866
|
}
|
|
830
867
|
if (stored !== undefined && stored.status === "merged") {
|
|
@@ -1127,6 +1164,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1127
1164
|
),
|
|
1128
1165
|
system: taskSystem,
|
|
1129
1166
|
chatPerTask: true,
|
|
1167
|
+
carry: {
|
|
1168
|
+
read: files.read(notesPath(story)),
|
|
1169
|
+
write: (notes) => files.writeAtomic(notesPath(story), notes)
|
|
1170
|
+
},
|
|
1130
1171
|
checkoutBranch: false,
|
|
1131
1172
|
lint: gates,
|
|
1132
1173
|
// A story's final state is judged and gated downstream (judge round,
|
package/src/StoryPlan.ts
CHANGED
|
@@ -30,6 +30,20 @@ export class Story extends Schema.Class<Story>("Story")({
|
|
|
30
30
|
provides: Schema.Array(Schema.String).pipe(
|
|
31
31
|
Schema.withConstructorDefault(Effect.succeed([])),
|
|
32
32
|
Schema.withDecodingDefaultKey(Effect.succeed([]))
|
|
33
|
+
),
|
|
34
|
+
/**
|
|
35
|
+
* Paths the planner says to read before touching anything: the exemplar
|
|
36
|
+
* feature to imitate, the contract this story extends, the kit component to
|
|
37
|
+
* reuse. Pruned to paths that exist; their contents open the starting code.
|
|
38
|
+
*/
|
|
39
|
+
readFirst: Schema.Array(Schema.String).pipe(
|
|
40
|
+
Schema.withConstructorDefault(Effect.succeed([])),
|
|
41
|
+
Schema.withDecodingDefaultKey(Effect.succeed([]))
|
|
42
|
+
),
|
|
43
|
+
/** Observable outcomes that make the story done; the coder plans against them, the judge scores against them. */
|
|
44
|
+
acceptance: Schema.Array(Schema.String).pipe(
|
|
45
|
+
Schema.withConstructorDefault(Effect.succeed([])),
|
|
46
|
+
Schema.withDecodingDefaultKey(Effect.succeed([]))
|
|
33
47
|
)
|
|
34
48
|
}) {}
|
|
35
49
|
|
|
@@ -120,6 +134,51 @@ export const storyPlanViolations = (plan: StoryPlan): ReadonlyArray<string> => {
|
|
|
120
134
|
return violations
|
|
121
135
|
}
|
|
122
136
|
|
|
137
|
+
/**
|
|
138
|
+
* An anchor the repository can answer: relative, inside the checkout, and
|
|
139
|
+
* narrower than the whole tree. `../x`, `/abs/x`, `.`, `` and `a/../b` are
|
|
140
|
+
* not — `git ls-files` is fatal on the first two, and the last ones would
|
|
141
|
+
* name every file.
|
|
142
|
+
*/
|
|
143
|
+
export const isSafeAnchor = (anchor: string): boolean => {
|
|
144
|
+
const path = normalizePath(anchor)
|
|
145
|
+
return (
|
|
146
|
+
path.length > 0 &&
|
|
147
|
+
!path.startsWith("/") &&
|
|
148
|
+
!/^[A-Za-z]:/u.test(path) &&
|
|
149
|
+
path !== "." &&
|
|
150
|
+
!path.split("/").includes("..")
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The plan with every `readFirst` anchor that no tracked file lies under
|
|
156
|
+
* removed — unsafe anchors (`isSafeAnchor`) first, without a lookup — and
|
|
157
|
+
* the list of what was dropped. A planner names paths from a layout digest
|
|
158
|
+
* and sometimes guesses; a guess must not reach a coder as something to read.
|
|
159
|
+
*/
|
|
160
|
+
export const pruneReadFirst = (
|
|
161
|
+
plan: StoryPlan,
|
|
162
|
+
known: ReadonlySet<string>
|
|
163
|
+
): {
|
|
164
|
+
readonly plan: StoryPlan
|
|
165
|
+
readonly dropped: ReadonlyArray<{ readonly story: string; readonly path: string }>
|
|
166
|
+
} => {
|
|
167
|
+
const files = [...known].map(normalizePath)
|
|
168
|
+
const dropped: Array<{ readonly story: string; readonly path: string }> = []
|
|
169
|
+
const stories = plan.stories.map((story) => {
|
|
170
|
+
const kept = story.readFirst.filter((anchor) => {
|
|
171
|
+
const exists = isSafeAnchor(anchor) && files.some((file) => pathWithin(file, anchor))
|
|
172
|
+
if (!exists) {
|
|
173
|
+
dropped.push({ story: story.id, path: anchor })
|
|
174
|
+
}
|
|
175
|
+
return exists
|
|
176
|
+
})
|
|
177
|
+
return kept.length === story.readFirst.length ? story : new Story({ ...story, readFirst: kept })
|
|
178
|
+
})
|
|
179
|
+
return { plan: dropped.length === 0 ? plan : new StoryPlan({ ...plan, stories }), dropped }
|
|
180
|
+
}
|
|
181
|
+
|
|
123
182
|
const cycles = (plan: StoryPlan): ReadonlyArray<ReadonlyArray<string>> => {
|
|
124
183
|
const found: Array<ReadonlyArray<string>> = []
|
|
125
184
|
const state = new Map<string, "visiting" | "done">()
|
|
@@ -289,7 +348,11 @@ export const pathsNamedIn = (plan: StoryPlan, text: string): ReadonlyArray<strin
|
|
|
289
348
|
return [...new Set(found)]
|
|
290
349
|
}
|
|
291
350
|
|
|
292
|
-
/**
|
|
351
|
+
/**
|
|
352
|
+
* Stable over the story entry's content — a changed entry means a fresh
|
|
353
|
+
* branch. The 2.29 fields enter only when set, so a plan written earlier
|
|
354
|
+
* keeps every hash and an upgrade restarts no story.
|
|
355
|
+
*/
|
|
293
356
|
export const storyHash = (story: Story): string =>
|
|
294
357
|
stableHash(
|
|
295
358
|
JSON.stringify({
|
|
@@ -299,7 +362,11 @@ export const storyHash = (story: Story): string =>
|
|
|
299
362
|
dependsOn: [...story.dependsOn],
|
|
300
363
|
owned: [...story.owned].map(normalizePath),
|
|
301
364
|
sharedReadOnly: [...story.sharedReadOnly].map(normalizePath),
|
|
302
|
-
provides: [...story.provides]
|
|
365
|
+
provides: [...story.provides],
|
|
366
|
+
...(story.readFirst.length === 0
|
|
367
|
+
? {}
|
|
368
|
+
: { readFirst: [...story.readFirst].map(normalizePath) }),
|
|
369
|
+
...(story.acceptance.length === 0 ? {} : { acceptance: [...story.acceptance] })
|
|
303
370
|
})
|
|
304
371
|
)
|
|
305
372
|
|
|
@@ -367,9 +434,18 @@ export const renderStoryPlan = Effect.fn("@llm4ts/flow/StoryPlan.render")(functi
|
|
|
367
434
|
"",
|
|
368
435
|
story.description.trim(),
|
|
369
436
|
"",
|
|
437
|
+
...(story.acceptance.length === 0
|
|
438
|
+
? []
|
|
439
|
+
: [
|
|
440
|
+
"Done when:",
|
|
441
|
+
"",
|
|
442
|
+
...story.acceptance.map((criterion, index) => `${index + 1}. ${criterion}`),
|
|
443
|
+
""
|
|
444
|
+
]),
|
|
370
445
|
`- depends on: ${list(story.dependsOn)}`,
|
|
371
446
|
`- owned: ${list(story.owned)}`,
|
|
372
447
|
`- shared read-only: ${list(story.sharedReadOnly)}`,
|
|
448
|
+
`- read first: ${list(story.readFirst)}`,
|
|
373
449
|
`- provides: ${list(story.provides)}`,
|
|
374
450
|
""
|
|
375
451
|
)
|
package/src/Transcript.ts
CHANGED
|
@@ -10,8 +10,9 @@ import { redactText } from "@llm4ts/core/observability/Redaction"
|
|
|
10
10
|
import { roleOr } from "./Timing.ts"
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* What the model was told and what it answered, per seat call —
|
|
14
|
-
*
|
|
13
|
+
* What the model was told and what it answered, per seat call — a record
|
|
14
|
+
* (`llm4ts run --transcript`; on by default in epic-stories, where
|
|
15
|
+
* `LLM4TS_TRANSCRIPT=off` turns it off) that `llm4ts watch` tails. It lives in
|
|
15
16
|
* its own files, never in the trace, so the trace and `llm4ts profile` stay
|
|
16
17
|
* content-free. Secrets are redacted and every field is capped.
|
|
17
18
|
*/
|