@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/src/Spans.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The vocabulary every llm4ts span speaks (ADR 0026): OpenTelemetry GenAI
|
|
2
|
+
// attributes first, OpenInference's span kind and token keys beside them so
|
|
3
|
+
// Phoenix of any version renders the LLM views, and llm4ts's own keys for
|
|
4
|
+
// what no convention names (role, story, epic, estimated usage). Pure: the
|
|
5
|
+
// exporter is the runner's, the seams only call `withKindSpan`.
|
|
6
|
+
import * as Context from "effect/Context"
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as Option from "effect/Option"
|
|
9
|
+
import type * as Tracer from "effect/Tracer"
|
|
10
|
+
import type { TokenUsage } from "@llm4ts/core/Models"
|
|
11
|
+
import { isEstimatedModel } from "./EstimatedUsage.ts"
|
|
12
|
+
|
|
13
|
+
export type SpanKind = "CHAIN" | "AGENT" | "LLM" | "TOOL" | "EVALUATOR"
|
|
14
|
+
|
|
15
|
+
export const kindAttribute = "openinference.span.kind"
|
|
16
|
+
|
|
17
|
+
export const attr = {
|
|
18
|
+
role: "llm4ts.role",
|
|
19
|
+
executor: "llm4ts.executor",
|
|
20
|
+
story: "llm4ts.story",
|
|
21
|
+
epic: "llm4ts.epic",
|
|
22
|
+
flow: "llm4ts.flow",
|
|
23
|
+
run: "llm4ts.run",
|
|
24
|
+
session: "session.id",
|
|
25
|
+
toolCategory: "llm4ts.tool.category",
|
|
26
|
+
estimated: "llm4ts.usage.estimated",
|
|
27
|
+
costUsd: "llm4ts.cost.usd",
|
|
28
|
+
costSource: "llm4ts.cost.source",
|
|
29
|
+
gateCommand: "llm4ts.gate.command",
|
|
30
|
+
gateExit: "llm4ts.gate.exit_code",
|
|
31
|
+
model: "gen_ai.request.model",
|
|
32
|
+
provider: "gen_ai.provider.name",
|
|
33
|
+
operation: "gen_ai.operation.name",
|
|
34
|
+
inputTokens: "gen_ai.usage.input_tokens",
|
|
35
|
+
outputTokens: "gen_ai.usage.output_tokens",
|
|
36
|
+
oiModel: "llm.model_name",
|
|
37
|
+
oiPrompt: "llm.token_count.prompt",
|
|
38
|
+
oiCompletion: "llm.token_count.completion",
|
|
39
|
+
oiTotal: "llm.token_count.total",
|
|
40
|
+
input: "input.value",
|
|
41
|
+
output: "output.value",
|
|
42
|
+
inputMessages: "gen_ai.input.messages",
|
|
43
|
+
outputMessages: "gen_ai.output.messages"
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
/** How much content leaves the machine: nothing, prompts and tools, or the system prompt too. */
|
|
47
|
+
export type OtelContent = "off" | "on" | "full"
|
|
48
|
+
|
|
49
|
+
/** LLM4TS_OTEL_CONTENT: `on` or `full`; anything else is off. */
|
|
50
|
+
export const otelContent = (
|
|
51
|
+
environment: Readonly<Record<string, string | undefined>>
|
|
52
|
+
): OtelContent => {
|
|
53
|
+
const value = environment.LLM4TS_OTEL_CONTENT?.trim().toLowerCase()
|
|
54
|
+
return value === "on" ? "on" : value === "full" ? "full" : "off"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SpanOptions {
|
|
58
|
+
readonly kind: SpanKind
|
|
59
|
+
/** Start a new trace here (a story), linked to the span it was started from. */
|
|
60
|
+
readonly root?: boolean
|
|
61
|
+
readonly attributes?: Readonly<Record<string, unknown>>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The nearest span `withKindSpan` opened. Effect names a span for every
|
|
66
|
+
* `Effect.fn`, so the fiber's current span is usually an internal one; kind
|
|
67
|
+
* spans chain to each other instead, and the runner leaves the internal ones
|
|
68
|
+
* unsampled, so an exported trace shows the flow, not the call stack.
|
|
69
|
+
*/
|
|
70
|
+
const CurrentKindSpan = Context.Reference<Option.Option<Tracer.Span>>(
|
|
71
|
+
"@llm4ts/flow/Spans/CurrentKindSpan",
|
|
72
|
+
{ defaultValue: () => Option.none() }
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
/** The kind span the current code runs under, when there is one. */
|
|
76
|
+
export const currentKindSpan: Effect.Effect<Option.Option<Tracer.Span>> = CurrentKindSpan
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A span with the OpenInference kind set, parented to the nearest kind span,
|
|
80
|
+
* always sampled, ended with the effect's exit. With no kind span above it
|
|
81
|
+
* (the run; a story asked to root) it starts a trace of its own, linked to
|
|
82
|
+
* the kind span it was started from when there is one. The wrapped effect
|
|
83
|
+
* keeps the caller's scope: only the span's lifetime is managed here.
|
|
84
|
+
*/
|
|
85
|
+
export const withKindSpan = <A, E, R>(
|
|
86
|
+
name: string,
|
|
87
|
+
options: SpanOptions,
|
|
88
|
+
effect: Effect.Effect<A, E, R>
|
|
89
|
+
): Effect.Effect<A, E, R> =>
|
|
90
|
+
Effect.flatMap(CurrentKindSpan, (parent) =>
|
|
91
|
+
Effect.useSpan(
|
|
92
|
+
name,
|
|
93
|
+
{
|
|
94
|
+
kind: options.kind === "LLM" ? "client" : "internal",
|
|
95
|
+
sampled: true,
|
|
96
|
+
...(options.root === true || Option.isNone(parent)
|
|
97
|
+
? {
|
|
98
|
+
root: true,
|
|
99
|
+
...(Option.isSome(parent) ? { links: [{ span: parent.value, attributes: {} }] } : {})
|
|
100
|
+
}
|
|
101
|
+
: { parent: parent.value }),
|
|
102
|
+
attributes: { [kindAttribute]: options.kind, ...(options.attributes ?? {}) }
|
|
103
|
+
},
|
|
104
|
+
(span) =>
|
|
105
|
+
Effect.withParentSpan(
|
|
106
|
+
Effect.provideService(effect, CurrentKindSpan, Option.some(span)),
|
|
107
|
+
span
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
/** The model name a backend would price: the estimate label stripped. */
|
|
113
|
+
export const modelNameOf = (model: string | undefined): string | undefined =>
|
|
114
|
+
model === undefined
|
|
115
|
+
? undefined
|
|
116
|
+
: isEstimatedModel(model)
|
|
117
|
+
? model.replace(/^estimated:/u, "")
|
|
118
|
+
: model
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Token and cost attributes for one model call; `{}` when nothing is known.
|
|
122
|
+
* A cost travels only when the backend reported it on measured usage: the
|
|
123
|
+
* backends price estimates themselves from the model name.
|
|
124
|
+
*/
|
|
125
|
+
export const usageAttributes = (
|
|
126
|
+
model: string | undefined,
|
|
127
|
+
usage: TokenUsage | undefined,
|
|
128
|
+
estimated: boolean
|
|
129
|
+
): Record<string, unknown> => {
|
|
130
|
+
const name = modelNameOf(model)
|
|
131
|
+
if (name === undefined && usage === undefined) {
|
|
132
|
+
return {}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
...(name === undefined ? {} : { [attr.model]: name, [attr.oiModel]: name }),
|
|
136
|
+
...(usage === undefined
|
|
137
|
+
? {}
|
|
138
|
+
: {
|
|
139
|
+
[attr.inputTokens]: usage.prompt,
|
|
140
|
+
[attr.outputTokens]: usage.completion,
|
|
141
|
+
[attr.oiPrompt]: usage.prompt,
|
|
142
|
+
[attr.oiCompletion]: usage.completion,
|
|
143
|
+
[attr.oiTotal]: usage.total,
|
|
144
|
+
...(usage.costUsd === undefined || estimated
|
|
145
|
+
? {}
|
|
146
|
+
: { [attr.costUsd]: usage.costUsd, [attr.costSource]: "reported" })
|
|
147
|
+
}),
|
|
148
|
+
[attr.estimated]: estimated
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/Stories.ts
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
type StoryRelation
|
|
75
75
|
} from "./StoryPlan.ts"
|
|
76
76
|
import { isOutageMessage } from "./TransientRetry.ts"
|
|
77
|
+
import { attr, withKindSpan } from "./Spans.ts"
|
|
77
78
|
|
|
78
79
|
const join = (root: string, path: string): string =>
|
|
79
80
|
`${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
|
|
@@ -314,10 +315,11 @@ const defaultContextChars = 40_000
|
|
|
314
315
|
const fileChars = 8_000
|
|
315
316
|
|
|
316
317
|
/**
|
|
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
|
|
318
|
+
* The code a story starts from, for its coder's system prompt: the planner's
|
|
319
|
+
* read-first anchors, then the shared read-only files it builds on, then the
|
|
320
|
+
* files it owns, each capped, until `budget` runs out; the rest by path only.
|
|
321
|
+
* Reading them up front saves the `ls`/`find`/`cat` round trips the coder
|
|
322
|
+
* would otherwise spend on them.
|
|
321
323
|
*/
|
|
322
324
|
export const startingCodeOf = (
|
|
323
325
|
story: Story,
|
|
@@ -330,42 +332,66 @@ export const startingCodeOf = (
|
|
|
330
332
|
if (budget <= 0) {
|
|
331
333
|
return undefined
|
|
332
334
|
}
|
|
335
|
+
const anchors = yield* git.listFiles(story.readFirst)
|
|
333
336
|
const shared = yield* git.listFiles(story.sharedReadOnly)
|
|
334
337
|
const owned = yield* git.listFiles(story.owned)
|
|
335
|
-
if (shared.length + owned.length === 0) {
|
|
338
|
+
if (anchors.length + shared.length + owned.length === 0) {
|
|
336
339
|
return undefined
|
|
337
340
|
}
|
|
338
341
|
let left = budget
|
|
339
342
|
const leftOut: Array<string> = []
|
|
340
|
-
const
|
|
343
|
+
const shown = new Set<string>()
|
|
344
|
+
// `limit`: how far this section may draw the budget down. The anchors
|
|
345
|
+
// stop at half, so a broad anchor (a whole feature folder) cannot starve
|
|
346
|
+
// the story's own files; the later sections may use everything left.
|
|
347
|
+
const section = (title: string, paths: ReadonlyArray<string>, limit = 0) =>
|
|
341
348
|
Effect.gen(function* () {
|
|
342
349
|
const parts: Array<string> = []
|
|
343
350
|
for (const path of paths) {
|
|
351
|
+
if (shown.has(path)) {
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
344
354
|
const text = yield* Effect.orElseSucceed(
|
|
345
355
|
files.read(join(worktree, path)),
|
|
346
356
|
() => undefined
|
|
347
357
|
)
|
|
348
|
-
if (text === undefined || left <=
|
|
358
|
+
if (text === undefined || left <= limit) {
|
|
349
359
|
leftOut.push(path)
|
|
350
360
|
continue
|
|
351
361
|
}
|
|
352
|
-
|
|
353
|
-
left
|
|
354
|
-
|
|
362
|
+
shown.add(path)
|
|
363
|
+
const piece = cap(text, Math.min(fileChars, left - limit)).text
|
|
364
|
+
left -= piece.length
|
|
365
|
+
parts.push(`### ${path}\n\`\`\`\n${piece}\n\`\`\``)
|
|
355
366
|
}
|
|
356
367
|
return parts.length === 0 ? [] : [title, ...parts]
|
|
357
368
|
})
|
|
358
369
|
return [
|
|
359
370
|
"## The code you start from",
|
|
360
371
|
"Read this before exploring: it is the current content of the files below.",
|
|
372
|
+
...(yield* section(
|
|
373
|
+
"Read first — what the planner says to imitate or build on:",
|
|
374
|
+
anchors,
|
|
375
|
+
Math.floor(budget / 2)
|
|
376
|
+
)),
|
|
361
377
|
...(yield* section("Shared, read-only — use these as they are:", shared)),
|
|
362
378
|
...(yield* section("Yours — the story's owned files so far:", owned)),
|
|
363
379
|
...(leftOut.length === 0 ? [] : [`Not shown (over the budget): ${leftOut.join(", ")}`])
|
|
364
380
|
].join("\n\n")
|
|
365
381
|
})
|
|
366
382
|
|
|
383
|
+
const numbered = (items: ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
384
|
+
items.map((item, index) => `${index + 1}. ${item}`)
|
|
385
|
+
|
|
367
386
|
export const storyPrompt = (story: Story): string =>
|
|
368
|
-
[
|
|
387
|
+
[
|
|
388
|
+
`Story: ${story.title}`,
|
|
389
|
+
"",
|
|
390
|
+
story.description.trim(),
|
|
391
|
+
...(story.acceptance.length === 0
|
|
392
|
+
? []
|
|
393
|
+
: ["", "Done when (each must hold, observably):", ...numbered(story.acceptance)])
|
|
394
|
+
].join("\n")
|
|
369
395
|
|
|
370
396
|
export const storyTaskPlanInstructions = (story: Story): string =>
|
|
371
397
|
[
|
|
@@ -373,6 +399,14 @@ export const storyTaskPlanInstructions = (story: Story): string =>
|
|
|
373
399
|
"into an ordered list of small, independently verifiable tasks, each described by its",
|
|
374
400
|
"observable outcome. Every task must stay inside the story's owned paths:",
|
|
375
401
|
bullets(story.owned),
|
|
402
|
+
...(story.acceptance.length === 0
|
|
403
|
+
? []
|
|
404
|
+
: [
|
|
405
|
+
"The story is done when every one of these holds:",
|
|
406
|
+
...numbered(story.acceptance),
|
|
407
|
+
"End every task's description with `Satisfies: <n>` naming the criteria it serves, and",
|
|
408
|
+
"make sure every criterion is served by at least one task."
|
|
409
|
+
]),
|
|
376
410
|
"Never plan a task that creates or changes anything outside them — registering or wiring",
|
|
377
411
|
"the story in elsewhere (the app's composition point, a shared kit file) is another story's job.",
|
|
378
412
|
`Use exactly this epicId: "${story.id}".`,
|
|
@@ -681,6 +715,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
681
715
|
/** What reviewers and the judge found, round by round, for people to read. */
|
|
682
716
|
const findingsPath = (story: Story): string =>
|
|
683
717
|
join(options.stateDir, `stories/${story.id}.findings.md`)
|
|
718
|
+
/** What the coder's tasks learned, carried into the next task of the same story. */
|
|
719
|
+
const notesPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.notes.md`)
|
|
684
720
|
const findingLines = (result: ReviewResult): ReadonlyArray<string> =>
|
|
685
721
|
result.issues.map((issue) => {
|
|
686
722
|
const where =
|
|
@@ -824,7 +860,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
824
860
|
}
|
|
825
861
|
// The task checkpoint belongs to the old branch: left in place, the
|
|
826
862
|
// fresh branch would inherit "every task complete" and skip the coder.
|
|
863
|
+
// The notes its tasks carried describe the old definition: gone too.
|
|
827
864
|
yield* files.remove(planPath(story))
|
|
865
|
+
yield* files.remove(notesPath(story))
|
|
828
866
|
stored = undefined
|
|
829
867
|
}
|
|
830
868
|
if (stored !== undefined && stored.status === "merged") {
|
|
@@ -1127,6 +1165,10 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1127
1165
|
),
|
|
1128
1166
|
system: taskSystem,
|
|
1129
1167
|
chatPerTask: true,
|
|
1168
|
+
carry: {
|
|
1169
|
+
read: files.read(notesPath(story)),
|
|
1170
|
+
write: (notes) => files.writeAtomic(notesPath(story), notes)
|
|
1171
|
+
},
|
|
1130
1172
|
checkoutBranch: false,
|
|
1131
1173
|
lint: gates,
|
|
1132
1174
|
// A story's final state is judged and gated downstream (judge round,
|
|
@@ -1202,40 +1244,68 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1202
1244
|
// rerun that finds the same diff gets the same answer without a
|
|
1203
1245
|
// model call, and the last verdict is on disk to read. Only the
|
|
1204
1246
|
// first round reuses it; a later round follows a revision and asks.
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1247
|
+
// The round is an EVALUATOR span (ADR 0026): the verdict's scores and
|
|
1248
|
+
// findings land on it, so a trace shows why a story was sent back.
|
|
1249
|
+
const judged = yield* withKindSpan(
|
|
1250
|
+
`story ${story.id}: judge ${round}`,
|
|
1251
|
+
{ kind: "EVALUATOR" },
|
|
1252
|
+
Effect.gen(function* () {
|
|
1253
|
+
const outcome =
|
|
1254
|
+
empty && subject.trim().length === 0
|
|
1255
|
+
? // Nothing to judge is decided here, not asked of a model.
|
|
1256
|
+
{
|
|
1257
|
+
value: ReviewResult.make({
|
|
1258
|
+
issues: [
|
|
1259
|
+
ReviewIssue.make({
|
|
1260
|
+
severity: "Critical",
|
|
1261
|
+
title: `nothing exists yet under ${story.owned.join(", ")}`,
|
|
1262
|
+
description: "The story's owned paths are empty on the epic branch."
|
|
1263
|
+
})
|
|
1264
|
+
],
|
|
1265
|
+
summary: `judge:${story.id}`
|
|
1266
|
+
}),
|
|
1267
|
+
reused: false
|
|
1268
|
+
}
|
|
1269
|
+
: yield* cachedValue(
|
|
1270
|
+
files,
|
|
1271
|
+
judgePath(story),
|
|
1272
|
+
Verdict,
|
|
1273
|
+
fingerprintOf([
|
|
1274
|
+
empty ? "code" : "diff",
|
|
1275
|
+
subject,
|
|
1276
|
+
story.id,
|
|
1277
|
+
story.title,
|
|
1278
|
+
story.description,
|
|
1279
|
+
story.provides.join("\n")
|
|
1280
|
+
]),
|
|
1281
|
+
withTimedRole(
|
|
1282
|
+
"judge",
|
|
1283
|
+
judge(story, subject, watchedSeats, empty ? "code" : "diff")
|
|
1284
|
+
),
|
|
1285
|
+
{ reuse: round === 1 }
|
|
1286
|
+
)
|
|
1287
|
+
const scored = outcome.value instanceof StoryVerdict ? outcome.value.dimensions : []
|
|
1288
|
+
yield* Effect.annotateCurrentSpan({
|
|
1289
|
+
"llm4ts.judge.cleared": outcome.value.isClean,
|
|
1290
|
+
"llm4ts.judge.reused": outcome.reused,
|
|
1291
|
+
...Object.fromEntries(scored.map((d) => [`llm4ts.judge.${d.id}`, d.score]))
|
|
1292
|
+
})
|
|
1293
|
+
const now = yield* Clock.currentTimeNanos
|
|
1294
|
+
yield* Effect.option(Effect.currentSpan).pipe(
|
|
1295
|
+
Effect.map((span) => {
|
|
1296
|
+
if (span._tag === "Some") {
|
|
1297
|
+
for (const issue of outcome.value.issues) {
|
|
1298
|
+
span.value.event("judge finding", now, {
|
|
1299
|
+
title: issue.title,
|
|
1300
|
+
severity: issue.severity
|
|
1215
1301
|
})
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
judgePath(story),
|
|
1224
|
-
Verdict,
|
|
1225
|
-
fingerprintOf([
|
|
1226
|
-
empty ? "code" : "diff",
|
|
1227
|
-
subject,
|
|
1228
|
-
story.id,
|
|
1229
|
-
story.title,
|
|
1230
|
-
story.description,
|
|
1231
|
-
story.provides.join("\n")
|
|
1232
|
-
]),
|
|
1233
|
-
withTimedRole(
|
|
1234
|
-
"judge",
|
|
1235
|
-
judge(story, subject, watchedSeats, empty ? "code" : "diff")
|
|
1236
|
-
),
|
|
1237
|
-
{ reuse: round === 1 }
|
|
1238
|
-
)
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
})
|
|
1305
|
+
)
|
|
1306
|
+
return outcome
|
|
1307
|
+
})
|
|
1308
|
+
)
|
|
1239
1309
|
const verdict = judged.value
|
|
1240
1310
|
if (judged.reused) {
|
|
1241
1311
|
yield* laneOf(story).publish(
|
|
@@ -1349,8 +1419,33 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1349
1419
|
})
|
|
1350
1420
|
|
|
1351
1421
|
/** A story's run as a completion — failures become outcomes, never fiber deaths. */
|
|
1422
|
+
// One trace per story (ADR 0026): the story span roots it, linked to the
|
|
1423
|
+
// run's span, and every span under it — tasks, model calls, tools, gates —
|
|
1424
|
+
// carries the story, the epic and the run's session.
|
|
1425
|
+
const storySpanAttributes = (story: Story): Readonly<Record<string, unknown>> => ({
|
|
1426
|
+
[attr.story]: story.id,
|
|
1427
|
+
[attr.epic]: plan.epicId,
|
|
1428
|
+
...(context.trace === undefined
|
|
1429
|
+
? {}
|
|
1430
|
+
: { [attr.session]: context.trace.runId, [attr.run]: context.trace.runId })
|
|
1431
|
+
})
|
|
1432
|
+
const annotated = <A, E, R>(
|
|
1433
|
+
story: Story,
|
|
1434
|
+
effect: Effect.Effect<A, E, R>
|
|
1435
|
+
): Effect.Effect<A, E, R> =>
|
|
1436
|
+
Object.entries(storySpanAttributes(story)).reduce(
|
|
1437
|
+
(acc, [key, value]) => Effect.annotateSpans(key, value)(acc),
|
|
1438
|
+
effect
|
|
1439
|
+
)
|
|
1352
1440
|
const attempt = (story: Story): Effect.Effect<Completion> =>
|
|
1353
|
-
|
|
1441
|
+
annotated(
|
|
1442
|
+
story,
|
|
1443
|
+
stage(laneOf(story), `story ${story.id}`, runStory(story), {
|
|
1444
|
+
kind: "AGENT",
|
|
1445
|
+
root: true,
|
|
1446
|
+
attributes: storySpanAttributes(story)
|
|
1447
|
+
})
|
|
1448
|
+
).pipe(
|
|
1354
1449
|
Effect.map((outcome): Completion => ({ story, outcome })),
|
|
1355
1450
|
Effect.catch((error) =>
|
|
1356
1451
|
Effect.gen(function* () {
|
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
|
)
|