@llm4ts/flow 2.45.0 → 2.47.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/Approval.d.ts +2 -0
- package/dist/Approval.d.ts.map +1 -1
- package/dist/Approval.js +4 -0
- package/dist/Approval.js.map +1 -1
- package/dist/AutonomyContract.d.ts +2 -2
- package/dist/AutonomyContract.d.ts.map +1 -1
- package/dist/AutonomyContract.js +9 -3
- package/dist/AutonomyContract.js.map +1 -1
- package/dist/CostReport.d.ts +27 -0
- package/dist/CostReport.d.ts.map +1 -1
- package/dist/CostReport.js +103 -2
- package/dist/CostReport.js.map +1 -1
- package/dist/FlowEvents.d.ts +25 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +25 -1
- package/dist/FlowEvents.js.map +1 -1
- package/dist/FlowRecorder.d.ts +8 -0
- package/dist/FlowRecorder.d.ts.map +1 -1
- package/dist/FlowRecorder.js +10 -3
- package/dist/FlowRecorder.js.map +1 -1
- package/dist/GateTriage.d.ts +26 -0
- package/dist/GateTriage.d.ts.map +1 -1
- package/dist/GateTriage.js +98 -5
- package/dist/GateTriage.js.map +1 -1
- package/dist/GitTool.d.ts +9 -5
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +44 -18
- package/dist/GitTool.js.map +1 -1
- package/dist/OperatorSchemas.d.ts +256 -0
- package/dist/OperatorSchemas.d.ts.map +1 -0
- package/dist/OperatorSchemas.js +204 -0
- package/dist/OperatorSchemas.js.map +1 -0
- package/dist/Retro.d.ts +2 -0
- package/dist/Retro.d.ts.map +1 -1
- package/dist/Retro.js +6 -0
- package/dist/Retro.js.map +1 -1
- package/dist/Review.d.ts +2 -0
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +17 -9
- package/dist/Review.js.map +1 -1
- package/dist/RunState.d.ts +26 -0
- package/dist/RunState.d.ts.map +1 -0
- package/dist/RunState.js +59 -0
- package/dist/RunState.js.map +1 -0
- package/dist/Stories.d.ts +30 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +54 -1
- package/dist/Stories.js.map +1 -1
- package/dist/StoryPlan.d.ts +6 -0
- package/dist/StoryPlan.d.ts.map +1 -1
- package/dist/StoryPlan.js +7 -0
- package/dist/StoryPlan.js.map +1 -1
- package/dist/TestEvidence.d.ts +55 -0
- package/dist/TestEvidence.d.ts.map +1 -0
- package/dist/TestEvidence.js +90 -0
- package/dist/TestEvidence.js.map +1 -0
- package/package.json +5 -2
- package/src/Approval.ts +6 -0
- package/src/AutonomyContract.ts +10 -3
- package/src/CostReport.ts +152 -2
- package/src/FlowEvents.ts +25 -1
- package/src/FlowRecorder.ts +19 -3
- package/src/GateTriage.ts +113 -7
- package/src/GitTool.ts +46 -18
- package/src/OperatorSchemas.ts +221 -0
- package/src/Retro.ts +11 -0
- package/src/Review.ts +25 -11
- package/src/RunState.ts +72 -0
- package/src/Stories.ts +113 -3
- package/src/StoryPlan.ts +7 -0
- package/src/TestEvidence.ts +137 -0
package/src/Stories.ts
CHANGED
|
@@ -148,6 +148,50 @@ export class StoryState extends Schema.Class<StoryState>("StoryState")({
|
|
|
148
148
|
executor: Schema.optionalKey(Schema.String)
|
|
149
149
|
}) {}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* What a story's red phase left (ADR 0042): the test paths it froze, the
|
|
153
|
+
* commit that holds them, and what it parked on the way. Written once; its
|
|
154
|
+
* presence is what tells a resumed story the red phase is done.
|
|
155
|
+
*/
|
|
156
|
+
export const RedTestsVersion = 1
|
|
157
|
+
|
|
158
|
+
export class RedTests extends Schema.Class<RedTests>("RedTests")({
|
|
159
|
+
frozen: Schema.Array(Schema.String),
|
|
160
|
+
commit: Schema.String,
|
|
161
|
+
/** Criteria the red phase could not prove red, each with why: parked for the operator. */
|
|
162
|
+
parked: Schema.Array(Schema.String)
|
|
163
|
+
}) {}
|
|
164
|
+
|
|
165
|
+
/** What a red phase answers: the test paths to freeze and the criteria it parked. */
|
|
166
|
+
export interface RedPhase {
|
|
167
|
+
readonly frozen: ReadonlyArray<string>
|
|
168
|
+
readonly parked: ReadonlyArray<string>
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Where a story's red phase is recorded, beside its task plan. */
|
|
172
|
+
export const redTestsPath = (stateDir: string, storyId: string): string =>
|
|
173
|
+
join(stateDir, `stories/${storyId}.red.json`)
|
|
174
|
+
|
|
175
|
+
/** A change to a test the red phase froze: a gate failure, whatever the change. */
|
|
176
|
+
export const frozenTestsGate = (
|
|
177
|
+
changed: ReadonlyArray<string>,
|
|
178
|
+
frozen: ReadonlyArray<string>
|
|
179
|
+
): ReviewResult => {
|
|
180
|
+
const touched = changed.filter((path) => frozen.includes(path))
|
|
181
|
+
return ReviewResult.make({
|
|
182
|
+
issues: touched.map((path) =>
|
|
183
|
+
ReviewIssue.make({
|
|
184
|
+
severity: "Critical",
|
|
185
|
+
title: `frozen red test changed: ${path}`,
|
|
186
|
+
description:
|
|
187
|
+
"The red phase froze this test: make it pass by changing production code, and put the test back as it was.",
|
|
188
|
+
file: path
|
|
189
|
+
})
|
|
190
|
+
),
|
|
191
|
+
summary: touched.length === 0 ? "" : "frozen red tests"
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
151
195
|
export const EpicReportVersion = 1
|
|
152
196
|
|
|
153
197
|
/** `waiting`: on hold behind a failed predecessor, runs once that is fixed. */
|
|
@@ -724,6 +768,19 @@ export interface StoriesOptions {
|
|
|
724
768
|
readonly contextChars?: number
|
|
725
769
|
/** Extra system context per story (house rules, shared read-only excerpts). */
|
|
726
770
|
readonly system?: (story: Story) => Effect.Effect<string, FlowError>
|
|
771
|
+
/**
|
|
772
|
+
* Test-first (ADR 0042): runs once per story, in its worktree, before its
|
|
773
|
+
* tasks are planned — writes the red tests and proves them red. The
|
|
774
|
+
* executor commits what it wrote and freezes the paths it names: a later
|
|
775
|
+
* change to any of them is a gate failure. Omit for no red phase.
|
|
776
|
+
*/
|
|
777
|
+
readonly redPhase?: (
|
|
778
|
+
story: Story,
|
|
779
|
+
seats: StorySeats,
|
|
780
|
+
workDir: string,
|
|
781
|
+
system: string,
|
|
782
|
+
events: FlowEventsShape
|
|
783
|
+
) => Effect.Effect<RedPhase, FlowError>
|
|
727
784
|
/** How a story's task plan is produced. Default: the story's own coder plans it. */
|
|
728
785
|
readonly planTasks?: (
|
|
729
786
|
seats: StorySeats,
|
|
@@ -1145,6 +1202,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1145
1202
|
// The notes its tasks carried describe the old definition: gone too.
|
|
1146
1203
|
yield* files.remove(planPath(story))
|
|
1147
1204
|
yield* files.remove(notesPath(story))
|
|
1205
|
+
yield* files.remove(redTestsPath(options.stateDir, story.id))
|
|
1148
1206
|
stored = undefined
|
|
1149
1207
|
}
|
|
1150
1208
|
if (stored !== undefined && stored.status === "merged") {
|
|
@@ -1394,6 +1452,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1394
1452
|
)
|
|
1395
1453
|
return ReviewResult.make({ issues, summary: issues.length === 0 ? "" : "oracle guard" })
|
|
1396
1454
|
})
|
|
1455
|
+
// The red phase's tests, once written (ADR 0042): frozen for every later change.
|
|
1456
|
+
let redTests = yield* loadVersioned(
|
|
1457
|
+
files,
|
|
1458
|
+
redTestsPath(options.stateDir, story.id),
|
|
1459
|
+
RedTestsVersion,
|
|
1460
|
+
RedTests
|
|
1461
|
+
)
|
|
1397
1462
|
/**
|
|
1398
1463
|
* The gates in a worktree of the story: the target's, the story's
|
|
1399
1464
|
* perimeter, the oracle guard and — for a task run aside — the paths the
|
|
@@ -1420,12 +1485,28 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1420
1485
|
],
|
|
1421
1486
|
task.owns
|
|
1422
1487
|
)
|
|
1488
|
+
const frozen =
|
|
1489
|
+
redTests === undefined
|
|
1490
|
+
? ReviewResult.make({ issues: [] })
|
|
1491
|
+
: frozenTestsGate(
|
|
1492
|
+
[
|
|
1493
|
+
...(yield* where.changedFilesVsBase(redTests.commit)),
|
|
1494
|
+
...(yield* where.uncommittedFiles)
|
|
1495
|
+
],
|
|
1496
|
+
redTests.frozen
|
|
1497
|
+
)
|
|
1423
1498
|
return combined(
|
|
1424
1499
|
combined(
|
|
1425
|
-
combined(
|
|
1426
|
-
|
|
1500
|
+
combined(
|
|
1501
|
+
combined(
|
|
1502
|
+
target,
|
|
1503
|
+
perimeterGate([...changed.sharedReadOnly, ...changed.outside], story)
|
|
1504
|
+
),
|
|
1505
|
+
yield* oracleGate(target, where)
|
|
1506
|
+
),
|
|
1507
|
+
owned
|
|
1427
1508
|
),
|
|
1428
|
-
|
|
1509
|
+
frozen
|
|
1429
1510
|
)
|
|
1430
1511
|
})
|
|
1431
1512
|
const gates = gatesAt(state.worktree, git, storyGateLogDir(options.stateDir, story.id))
|
|
@@ -1599,6 +1680,35 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1599
1680
|
)
|
|
1600
1681
|
})
|
|
1601
1682
|
|
|
1683
|
+
// Test-first (ADR 0042): the red tests before any task is planned, once.
|
|
1684
|
+
// A story whose task plan exists already ran it, or predates it.
|
|
1685
|
+
if (
|
|
1686
|
+
options.redPhase !== undefined &&
|
|
1687
|
+
redTests === undefined &&
|
|
1688
|
+
(yield* files.read(planPath(story))) === undefined
|
|
1689
|
+
) {
|
|
1690
|
+
const red = yield* stage(
|
|
1691
|
+
laneOf(story),
|
|
1692
|
+
`story ${story.id}: red tests`,
|
|
1693
|
+
options.redPhase(story, watchedSeats, state.worktree, system, laneOf(story))
|
|
1694
|
+
)
|
|
1695
|
+
if ((yield* git.uncommittedFiles).length > 0) {
|
|
1696
|
+
yield* git.commitAll(`${story.id}: red tests`)
|
|
1697
|
+
}
|
|
1698
|
+
redTests = RedTests.make({
|
|
1699
|
+
frozen: [...red.frozen],
|
|
1700
|
+
commit: yield* git.checkpoint,
|
|
1701
|
+
parked: [...red.parked]
|
|
1702
|
+
})
|
|
1703
|
+
yield* saveVersioned(
|
|
1704
|
+
files,
|
|
1705
|
+
redTestsPath(options.stateDir, story.id),
|
|
1706
|
+
RedTestsVersion,
|
|
1707
|
+
RedTests,
|
|
1708
|
+
redTests
|
|
1709
|
+
)
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1602
1712
|
const planTasks = options.planTasks ?? defaultPlanTasks
|
|
1603
1713
|
// Taken after catch-up and setup: the code as the first task finds it.
|
|
1604
1714
|
const startingCode = yield* startingCodeOf(
|
package/src/StoryPlan.ts
CHANGED
|
@@ -45,6 +45,12 @@ export class Story extends Schema.Class<Story>("Story")({
|
|
|
45
45
|
Schema.withConstructorDefault(Effect.succeed([])),
|
|
46
46
|
Schema.withDecodingDefaultKey(Effect.succeed([]))
|
|
47
47
|
),
|
|
48
|
+
/**
|
|
49
|
+
* Outcomes cut from this iteration (ADR 0042, epic-mvp): planned, shown,
|
|
50
|
+
* never built now. `acceptance` is the MVP; a story with only `later`
|
|
51
|
+
* outcomes is parked whole. Absent outside epic-mvp.
|
|
52
|
+
*/
|
|
53
|
+
later: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
48
54
|
/**
|
|
49
55
|
* The story may delete, skip or rewrite tests (ADR 0027): the oracle guard
|
|
50
56
|
* stays silent for it. Absent or false, a deleted test file, an added skip
|
|
@@ -376,6 +382,7 @@ export const storyHash = (story: Story): string =>
|
|
|
376
382
|
? {}
|
|
377
383
|
: { readFirst: [...story.readFirst].map(normalizePath) }),
|
|
378
384
|
...(story.acceptance.length === 0 ? {} : { acceptance: [...story.acceptance] }),
|
|
385
|
+
...(story.later === undefined || story.later.length === 0 ? {} : { later: [...story.later] }),
|
|
379
386
|
...(story.testsChange ? { testsChange: true } : {})
|
|
380
387
|
})
|
|
381
388
|
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Per-criterion test evidence (ADR 0042): every MVP acceptance criterion is
|
|
2
|
+
// encoded by tests whose names carry its tag, `[<story-id>#<n>]`, and the
|
|
3
|
+
// criterion is green only when the test run says so — from a JUnit XML
|
|
4
|
+
// report, or from one filtered test command per criterion. Pass/fail counts
|
|
5
|
+
// alone (GateTriage, OracleGuard) cannot say WHICH criterion holds.
|
|
6
|
+
import * as Schema from "effect/Schema"
|
|
7
|
+
import type { OracleRules } from "./OracleGuard.ts"
|
|
8
|
+
import { defaultOracleRules } from "./OracleGuard.ts"
|
|
9
|
+
|
|
10
|
+
/** The tag tests of a criterion carry in their name: `[story-id#2]` for its second criterion. */
|
|
11
|
+
export const criterionTag = (storyId: string, index: number): string => `[${storyId}#${index}]`
|
|
12
|
+
|
|
13
|
+
/** The tags of a story's MVP criteria, in order. */
|
|
14
|
+
export const criterionTags = (
|
|
15
|
+
storyId: string,
|
|
16
|
+
acceptance: ReadonlyArray<string>
|
|
17
|
+
): ReadonlyArray<string> => acceptance.map((_, index) => criterionTag(storyId, index + 1))
|
|
18
|
+
|
|
19
|
+
export const TestCaseStatus = Schema.Literals(["passed", "failed", "skipped"])
|
|
20
|
+
export type TestCaseStatus = typeof TestCaseStatus.Type
|
|
21
|
+
|
|
22
|
+
export interface TestCase {
|
|
23
|
+
readonly name: string
|
|
24
|
+
readonly classname: string
|
|
25
|
+
readonly file: string | undefined
|
|
26
|
+
readonly status: TestCaseStatus
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const attribute = (attributes: string, name: string): string | undefined => {
|
|
30
|
+
const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"`, "u").exec(attributes)
|
|
31
|
+
return match?.[1] === undefined ? undefined : unescapeXml(match[1])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const unescapeXml = (text: string): string =>
|
|
35
|
+
text
|
|
36
|
+
.replace(/</g, "<")
|
|
37
|
+
.replace(/>/g, ">")
|
|
38
|
+
.replace(/"/g, '"')
|
|
39
|
+
.replace(/'/g, "'")
|
|
40
|
+
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
|
|
41
|
+
.replace(/&/g, "&")
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The test cases of a JUnit XML report — the format vitest, jest, pytest,
|
|
45
|
+
* Surefire/Gradle and cargo-nextest all write. A case with a `<failure>` or
|
|
46
|
+
* `<error>` failed, one with `<skipped>` was skipped, any other passed.
|
|
47
|
+
*/
|
|
48
|
+
export const parseJUnit = (xml: string): ReadonlyArray<TestCase> => {
|
|
49
|
+
const cases: Array<TestCase> = []
|
|
50
|
+
const pattern = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/gu
|
|
51
|
+
for (const match of xml.matchAll(pattern)) {
|
|
52
|
+
const attributes = match[1] ?? ""
|
|
53
|
+
const body = match[2] ?? ""
|
|
54
|
+
const status: TestCaseStatus = /<(?:failure|error)\b/u.test(body)
|
|
55
|
+
? "failed"
|
|
56
|
+
: /<skipped\b/u.test(body)
|
|
57
|
+
? "skipped"
|
|
58
|
+
: "passed"
|
|
59
|
+
cases.push({
|
|
60
|
+
name: attribute(attributes, "name") ?? "",
|
|
61
|
+
classname: attribute(attributes, "classname") ?? "",
|
|
62
|
+
file: attribute(attributes, "file"),
|
|
63
|
+
status
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
return cases
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `missing`: no test names the criterion. `red`: one does and not all of
|
|
71
|
+
* them pass. `green`: some do and every one passes.
|
|
72
|
+
*/
|
|
73
|
+
export const CriterionStatus = Schema.Literals(["missing", "red", "green"])
|
|
74
|
+
export type CriterionStatus = typeof CriterionStatus.Type
|
|
75
|
+
|
|
76
|
+
const named = (testCase: TestCase, tag: string): boolean =>
|
|
77
|
+
testCase.name.includes(tag) || testCase.classname.includes(tag)
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A criterion's status from a report. A tag the report does not show but
|
|
81
|
+
* the test sources carry (`written`) is red: a suite that cannot even load
|
|
82
|
+
* — the module under test does not exist yet — reports no case.
|
|
83
|
+
*/
|
|
84
|
+
export const statusFromReport = (
|
|
85
|
+
tag: string,
|
|
86
|
+
cases: ReadonlyArray<TestCase>,
|
|
87
|
+
written: boolean
|
|
88
|
+
): CriterionStatus => {
|
|
89
|
+
const mine = cases.filter((testCase) => named(testCase, tag))
|
|
90
|
+
if (mine.length === 0) {
|
|
91
|
+
return written ? "red" : "missing"
|
|
92
|
+
}
|
|
93
|
+
return mine.every((testCase) => testCase.status === "passed") ? "green" : "red"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Where the evidence comes from: the JUnit report the test gate writes
|
|
98
|
+
* (`LLM4TS_TEST_REPORT`, relative to the app directory), or a command run
|
|
99
|
+
* once per criterion with `{tag}` replaced (`LLM4TS_TEST_ONE`), green when
|
|
100
|
+
* it exits 0.
|
|
101
|
+
*/
|
|
102
|
+
export type EvidenceSource =
|
|
103
|
+
| { readonly _tag: "Report"; readonly path: string }
|
|
104
|
+
| { readonly _tag: "Command"; readonly argv: ReadonlyArray<string> }
|
|
105
|
+
|
|
106
|
+
export const evidenceSourceFromEnvironment = (
|
|
107
|
+
environment: Readonly<Record<string, string | undefined>>
|
|
108
|
+
): EvidenceSource | undefined => {
|
|
109
|
+
const report = environment.LLM4TS_TEST_REPORT?.trim()
|
|
110
|
+
if (report !== undefined && report.length > 0) {
|
|
111
|
+
return { _tag: "Report", path: report }
|
|
112
|
+
}
|
|
113
|
+
const command = environment.LLM4TS_TEST_ONE?.trim()
|
|
114
|
+
if (command !== undefined && (command.includes("{tag}") || command.includes("{tagRegex}"))) {
|
|
115
|
+
return { _tag: "Command", argv: command.split(/\s+/u) }
|
|
116
|
+
}
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const escapeRegex = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The per-criterion command with its tag in place: `{tag}` verbatim,
|
|
124
|
+
* `{tagRegex}` escaped for runners whose name filter is a regex (vitest `-t`,
|
|
125
|
+
* jest `-t`, pytest `-k` takes neither — use `{tag}` there).
|
|
126
|
+
*/
|
|
127
|
+
export const commandFor = (argv: ReadonlyArray<string>, tag: string): ReadonlyArray<string> =>
|
|
128
|
+
argv.map((part) => part.split("{tagRegex}").join(escapeRegex(tag)).split("{tag}").join(tag))
|
|
129
|
+
|
|
130
|
+
/** The changed paths that are test files by the oracle's rules: what a red phase freezes. */
|
|
131
|
+
export const testPaths = (
|
|
132
|
+
paths: ReadonlyArray<string>,
|
|
133
|
+
rules: OracleRules = defaultOracleRules
|
|
134
|
+
): ReadonlyArray<string> => {
|
|
135
|
+
const pattern = new RegExp(rules.testFiles, "u")
|
|
136
|
+
return paths.filter((path) => pattern.test(path))
|
|
137
|
+
}
|