@llm4ts/flow 2.28.0 → 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/FlowEvents.d.ts +5 -0
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +9 -2
- package/dist/FlowEvents.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/Roster.d.ts +20 -0
- package/dist/Roster.d.ts.map +1 -1
- package/dist/Roster.js +34 -1
- package/dist/Roster.js.map +1 -1
- package/dist/RosterSeats.d.ts +5 -1
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/RosterSeats.js +10 -15
- package/dist/RosterSeats.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/FlowEvents.ts +11 -2
- package/src/Orientation.ts +192 -0
- package/src/RefineRound.ts +13 -1
- package/src/Retro.ts +26 -3
- package/src/Roster.ts +79 -1
- package/src/RosterSeats.ts +14 -18
- package/src/Stories.ts +52 -11
- package/src/StoryPlan.ts +78 -2
- package/src/Transcript.ts +3 -2
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/Roster.ts
CHANGED
|
@@ -391,6 +391,8 @@ export interface Lease {
|
|
|
391
391
|
readonly role: Role
|
|
392
392
|
/** Frees the slot; idempotent, and also run when the lease's scope closes. */
|
|
393
393
|
readonly release: Effect.Effect<void>
|
|
394
|
+
/** Took no slot: the context's own coder executor serves the call (see `LeaseOptions.borrow`). */
|
|
395
|
+
readonly borrowed: boolean
|
|
394
396
|
}
|
|
395
397
|
|
|
396
398
|
export interface LeaseOptions {
|
|
@@ -400,6 +402,18 @@ export interface LeaseOptions {
|
|
|
400
402
|
readonly prefer?: string
|
|
401
403
|
/** Who is asking, for the events (a story's worktree). */
|
|
402
404
|
readonly label?: string
|
|
405
|
+
/**
|
|
406
|
+
* The executor holding this context's coder, used without a slot (its
|
|
407
|
+
* coder is idle while the context makes this call) when independence
|
|
408
|
+
* cannot be had: no executor outside `avoid` can ever take the role, or
|
|
409
|
+
* every one in the round that could has all its slots held by coders. A
|
|
410
|
+
* coder's slot frees only when its story ends, and that story may itself
|
|
411
|
+
* be waiting on a call like this one — waiting for it would saturate the
|
|
412
|
+
* roster. An executor that is out of the round is still borrowed in the
|
|
413
|
+
* first case (the call fails on it, as it would on anyone), never in the
|
|
414
|
+
* second (a story in the round will free a slot).
|
|
415
|
+
*/
|
|
416
|
+
readonly borrow?: ExecutorSpec
|
|
403
417
|
}
|
|
404
418
|
|
|
405
419
|
export interface ExecutorStatus {
|
|
@@ -423,6 +437,12 @@ export interface RosterShape {
|
|
|
423
437
|
) => Effect.Effect<Lease | undefined, never, Scope.Scope>
|
|
424
438
|
/** Whether an executor outside `avoid` has the role and is not out for the run. */
|
|
425
439
|
readonly canEverServe: (role: Role, avoid?: ReadonlyArray<string>) => Effect.Effect<boolean>
|
|
440
|
+
/**
|
|
441
|
+
* Whether every executor in the round that takes `role`, outside `avoid`,
|
|
442
|
+
* has all its slots held by coders — and there is at least one. A lease
|
|
443
|
+
* for `role` would then wait for a story to end, not for a call.
|
|
444
|
+
*/
|
|
445
|
+
readonly coderHeld: (role: Role, avoid?: ReadonlyArray<string>) => Effect.Effect<boolean>
|
|
426
446
|
/** Free slots for `role` now, across executors in the round. */
|
|
427
447
|
readonly available: (role: Role) => Effect.Effect<number>
|
|
428
448
|
/** Configured slots for `role`. */
|
|
@@ -567,7 +587,29 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
567
587
|
)
|
|
568
588
|
)
|
|
569
589
|
yield* Effect.addFinalizer(() => free)
|
|
570
|
-
return { executor: spec, role, release: free }
|
|
590
|
+
return { executor: spec, role, release: free, borrowed: false }
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
/** A lease over the context's own coder executor: no slot taken, none freed. */
|
|
594
|
+
const borrowedLease = (
|
|
595
|
+
spec: ExecutorSpec,
|
|
596
|
+
role: Role,
|
|
597
|
+
label: string | undefined,
|
|
598
|
+
because: "nobody" | "held"
|
|
599
|
+
): Effect.Effect<Lease, never, Scope.Scope> =>
|
|
600
|
+
Effect.gen(function* () {
|
|
601
|
+
const labelled = label === undefined ? {} : { label }
|
|
602
|
+
yield* events.publish(
|
|
603
|
+
ExecutorLeased.make({ executor: spec.id, role, ...labelled, borrowed: true, because })
|
|
604
|
+
)
|
|
605
|
+
const done = yield* Ref.make(false)
|
|
606
|
+
const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
|
|
607
|
+
was
|
|
608
|
+
? Effect.void
|
|
609
|
+
: events.publish(ExecutorReleased.make({ executor: spec.id, role, ...labelled }))
|
|
610
|
+
)
|
|
611
|
+
yield* Effect.addFinalizer(() => free)
|
|
612
|
+
return { executor: spec, role, release: free, borrowed: true }
|
|
571
613
|
})
|
|
572
614
|
|
|
573
615
|
/** One attempt: expire, pick, take the slot — all under the lock. */
|
|
@@ -616,6 +658,19 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
616
658
|
)
|
|
617
659
|
)
|
|
618
660
|
|
|
661
|
+
const coderHeld = (role: Role, avoid: ReadonlyArray<string> = []): Effect.Effect<boolean> =>
|
|
662
|
+
lock.withPermit(
|
|
663
|
+
Effect.sync(() => {
|
|
664
|
+
const inRound = eligible(role, avoid)
|
|
665
|
+
return (
|
|
666
|
+
inRound.length > 0 &&
|
|
667
|
+
inRound.every(
|
|
668
|
+
(spec) => (usage.get(spec.id) ?? { busy: 0, busyCoders: 0 }).busyCoders >= slotsOf(spec)
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
})
|
|
672
|
+
)
|
|
673
|
+
|
|
619
674
|
/** Probes every `health` exclusion whose next probe is due; a live one comes back. */
|
|
620
675
|
const probeDue: Effect.Effect<void> = Effect.gen(function* () {
|
|
621
676
|
const probe = options.probe
|
|
@@ -710,6 +765,17 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
710
765
|
): Effect.Effect<Lease, RosterExhausted, Scope.Scope> =>
|
|
711
766
|
Effect.gen(function* () {
|
|
712
767
|
const avoid = leaseOptions.avoid ?? []
|
|
768
|
+
const borrow =
|
|
769
|
+
leaseOptions.borrow !== undefined && hasRole(leaseOptions.borrow, role)
|
|
770
|
+
? leaseOptions.borrow
|
|
771
|
+
: undefined
|
|
772
|
+
/** Borrowing when the round is held by coders needs an executor in the round. */
|
|
773
|
+
const borrowInRound = (): Effect.Effect<ExecutorSpec | undefined> =>
|
|
774
|
+
lock.withPermit(
|
|
775
|
+
Effect.sync(() =>
|
|
776
|
+
borrow !== undefined && !exclusions.has(borrow.id) ? borrow : undefined
|
|
777
|
+
)
|
|
778
|
+
)
|
|
713
779
|
let announced = false
|
|
714
780
|
const asked = yield* Clock.currentTimeMillis
|
|
715
781
|
// A lease that had to wait says how long, for the story it is for.
|
|
@@ -732,6 +798,10 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
732
798
|
return yield* leaseOf(spec, role, leaseOptions.label)
|
|
733
799
|
}
|
|
734
800
|
if (!(yield* canEverServe(role, avoid))) {
|
|
801
|
+
if (borrow !== undefined) {
|
|
802
|
+
yield* waited
|
|
803
|
+
return yield* borrowedLease(borrow, role, leaseOptions.label, "nobody")
|
|
804
|
+
}
|
|
735
805
|
const reasons = yield* lock.withPermit(
|
|
736
806
|
Effect.sync(() =>
|
|
737
807
|
executors
|
|
@@ -747,6 +817,13 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
747
817
|
reasons: reasons.length === 0 ? ["no executor in the roster takes this role"] : reasons
|
|
748
818
|
})
|
|
749
819
|
}
|
|
820
|
+
if (yield* coderHeld(role, avoid)) {
|
|
821
|
+
const own = yield* borrowInRound()
|
|
822
|
+
if (own !== undefined) {
|
|
823
|
+
yield* waited
|
|
824
|
+
return yield* borrowedLease(own, role, leaseOptions.label, "held")
|
|
825
|
+
}
|
|
826
|
+
}
|
|
750
827
|
if (!announced) {
|
|
751
828
|
announced = true
|
|
752
829
|
yield* say(
|
|
@@ -876,6 +953,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
876
953
|
lease,
|
|
877
954
|
tryLease,
|
|
878
955
|
canEverServe,
|
|
956
|
+
coderHeld,
|
|
879
957
|
available,
|
|
880
958
|
slots,
|
|
881
959
|
report,
|
package/src/RosterSeats.ts
CHANGED
|
@@ -12,10 +12,9 @@ import { ConfigError, type LlmError } from "@llm4ts/core/Errors"
|
|
|
12
12
|
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
13
13
|
import { Message, type ConnectorCapabilities, type LlmChunk } from "@llm4ts/core/Models"
|
|
14
14
|
import { describeFlowError, type FlowError } from "./FlowError.ts"
|
|
15
|
-
import { ExecutorHandedOver,
|
|
15
|
+
import { ExecutorHandedOver, type FlowEventsShape } from "./FlowEvents.ts"
|
|
16
16
|
import {
|
|
17
17
|
describeExclusion,
|
|
18
|
-
hasRole,
|
|
19
18
|
type ExecutorSpec,
|
|
20
19
|
type Lease,
|
|
21
20
|
type Role,
|
|
@@ -102,7 +101,11 @@ export interface RosterSeatOptions {
|
|
|
102
101
|
readonly events: FlowEventsShape
|
|
103
102
|
/** Executors this seat must not use: the context's coder, for independence. */
|
|
104
103
|
readonly avoid?: Effect.Effect<ReadonlyArray<string>>
|
|
105
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* The context's coder executor, used without a slot when independence
|
|
106
|
+
* cannot be had: nobody outside `avoid` can ever serve the role, or every
|
|
107
|
+
* executor that could is held by a coder (`LeaseOptions.borrow`).
|
|
108
|
+
*/
|
|
106
109
|
readonly borrow?: Effect.Effect<ExecutorSpec | undefined>
|
|
107
110
|
/** Who is asking, for the events. */
|
|
108
111
|
readonly label?: string
|
|
@@ -127,22 +130,15 @@ export const rosterSeat = (
|
|
|
127
130
|
): Effect.Effect<ExecutorSpec, LlmError, Scope.Scope> =>
|
|
128
131
|
Effect.gen(function* () {
|
|
129
132
|
const avoid = [...(options.avoid === undefined ? [] : yield* options.avoid), ...alsoAvoid]
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
yield* options.events.publish(
|
|
134
|
-
ExecutorLeased.make({
|
|
135
|
-
executor: borrowed.id,
|
|
136
|
-
role,
|
|
137
|
-
...(options.label === undefined ? {} : { label: options.label }),
|
|
138
|
-
borrowed: true
|
|
139
|
-
})
|
|
140
|
-
)
|
|
141
|
-
return borrowed
|
|
142
|
-
}
|
|
143
|
-
}
|
|
133
|
+
// The roster decides who serves: an independent executor, or, when
|
|
134
|
+
// none can ever serve or every one is held by a coder, the context's own.
|
|
135
|
+
const borrow = options.borrow === undefined ? undefined : yield* options.borrow
|
|
144
136
|
const lease = yield* roster
|
|
145
|
-
.lease(role, {
|
|
137
|
+
.lease(role, {
|
|
138
|
+
avoid,
|
|
139
|
+
...(options.label === undefined ? {} : { label: options.label }),
|
|
140
|
+
...(borrow === undefined ? {} : { borrow })
|
|
141
|
+
})
|
|
146
142
|
.pipe(Effect.mapError(asLlmError))
|
|
147
143
|
return lease.executor
|
|
148
144
|
})
|
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
|
*/
|