@llm4ts/flow 2.0.0 → 2.2.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 +1 -1
- package/dist/Approval.d.ts.map +1 -1
- package/dist/Artifacts.d.ts +2 -2
- package/dist/Artifacts.d.ts.map +1 -1
- package/dist/BenchReport.d.ts +2 -2
- package/dist/BenchReport.d.ts.map +1 -1
- package/dist/BoardSync.d.ts +12 -5
- package/dist/BoardSync.d.ts.map +1 -1
- package/dist/BoardSync.js +27 -6
- package/dist/BoardSync.js.map +1 -1
- package/dist/CostLedger.d.ts +2 -2
- package/dist/CostLedger.d.ts.map +1 -1
- package/dist/Decisions.d.ts +187 -0
- package/dist/Decisions.d.ts.map +1 -0
- package/dist/Decisions.js +677 -0
- package/dist/Decisions.js.map +1 -0
- package/dist/Domains.d.ts +125 -0
- package/dist/Domains.d.ts.map +1 -0
- package/dist/Domains.js +528 -0
- package/dist/Domains.js.map +1 -0
- package/dist/Equiv.d.ts +3 -3
- package/dist/Equiv.d.ts.map +1 -1
- package/dist/Flow.d.ts +1 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/FlowError.d.ts +25 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +33 -1
- package/dist/FlowError.js.map +1 -1
- package/dist/Pack.d.ts +16 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +53 -1
- package/dist/Pack.js.map +1 -1
- package/dist/PageSpec.d.ts +21 -7
- package/dist/PageSpec.d.ts.map +1 -1
- package/dist/PageSpec.js +83 -14
- package/dist/PageSpec.js.map +1 -1
- package/dist/Persistence.d.ts +2 -2
- package/dist/Persistence.d.ts.map +1 -1
- package/dist/PlanExecution.d.ts +1 -1
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/ProgramJudge.d.ts +1 -1
- package/dist/ProgramJudge.d.ts.map +1 -1
- package/dist/Replay.d.ts +2 -2
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Review.d.ts +2 -2
- package/dist/Review.d.ts.map +1 -1
- package/dist/ReviewCache.d.ts +1 -1
- package/dist/ReviewCache.d.ts.map +1 -1
- package/dist/SpecChecks.d.ts +8 -0
- package/dist/SpecChecks.d.ts.map +1 -1
- package/dist/SpecChecks.js +7 -1
- package/dist/SpecChecks.js.map +1 -1
- package/dist/Stories.d.ts +5 -4
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +20 -10
- package/dist/Stories.js.map +1 -1
- package/dist/StoryPlan.d.ts +2 -1
- package/dist/StoryPlan.d.ts.map +1 -1
- package/dist/StoryPlan.js +1 -1
- package/dist/StoryPlan.js.map +1 -1
- package/package.json +4 -2
- package/src/BoardSync.ts +35 -6
- package/src/Decisions.ts +844 -0
- package/src/Domains.ts +650 -0
- package/src/FlowError.ts +41 -1
- package/src/Pack.ts +79 -1
- package/src/PageSpec.ts +132 -8
- package/src/SpecChecks.ts +15 -1
- package/src/Stories.ts +20 -10
- package/src/StoryPlan.ts +3 -2
package/src/Pack.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as Effect from "effect/Effect"
|
|
2
2
|
import * as Schema from "effect/Schema"
|
|
3
3
|
import { Dimension } from "@llm4ts/core/eval/Eval"
|
|
4
|
+
import type { ConsolidateRules } from "./Domains.ts"
|
|
4
5
|
import { PlanParseError } from "./FlowError.ts"
|
|
5
6
|
import { parseReviewer, type Reviewer } from "./Reviewer.ts"
|
|
6
7
|
import { CoverageRule } from "./SpecChecks.ts"
|
|
@@ -41,12 +42,25 @@ export interface Pack {
|
|
|
41
42
|
// paths), `<NAME>` substituted with the program name. The seam that makes
|
|
42
43
|
// per-program judging possible.
|
|
43
44
|
readonly programFiles: string | undefined
|
|
45
|
+
/**
|
|
46
|
+
* Regex template locating a DOMAIN FEATURE's target files (ADR 0012
|
|
47
|
+
* addendum): `<NAME>` is the feature id, `<PAGES>` an alternation of its
|
|
48
|
+
* page names. A feature's scope is this template plus every page's own
|
|
49
|
+
* `program-files` scope.
|
|
50
|
+
*/
|
|
51
|
+
readonly featureFiles: string | undefined
|
|
44
52
|
/**
|
|
45
53
|
* The schema every program spec must embed, validated deterministically
|
|
46
54
|
* by the extraction gate: `pagespec` (a ```json pagespec block decodable
|
|
47
55
|
* as `PageSpec`) is the only one today; absent means prose-only specs.
|
|
48
56
|
*/
|
|
49
57
|
readonly specSchema: string | undefined
|
|
58
|
+
/**
|
|
59
|
+
* The `## Consolidate` section (ADR 0015): which survey edge kinds put two
|
|
60
|
+
* units in one domain feature (`cluster:`) and which only attach a shared
|
|
61
|
+
* fragment as context (`context:`). Absent: every program is its own feature.
|
|
62
|
+
*/
|
|
63
|
+
readonly consolidate: ConsolidateRules | undefined
|
|
50
64
|
readonly dir: string
|
|
51
65
|
readonly gate: (name: string) => ReadonlyArray<string> | undefined
|
|
52
66
|
readonly prompt: (name: string) => string | undefined
|
|
@@ -54,6 +68,8 @@ export interface Pack {
|
|
|
54
68
|
// template with `<NAME>` substituted, or a case-insensitive "path contains
|
|
55
69
|
// the program name" fallback.
|
|
56
70
|
readonly filesFor: (program: string) => RegExp
|
|
71
|
+
/** The regex for a feature's files: its template (when set) or any of its pages' scopes. */
|
|
72
|
+
readonly filesForFeature: (feature: string, pages: ReadonlyArray<string>) => RegExp
|
|
57
73
|
}
|
|
58
74
|
|
|
59
75
|
interface ParsedManifest {
|
|
@@ -152,6 +168,12 @@ const dimensions = (body: string | undefined): ReadonlyArray<Dimension> =>
|
|
|
152
168
|
: [Dimension.make({ name, rubric, maxScore })]
|
|
153
169
|
})
|
|
154
170
|
|
|
171
|
+
const commaList = (value: string | undefined): ReadonlyArray<string> =>
|
|
172
|
+
(value ?? "")
|
|
173
|
+
.split(",")
|
|
174
|
+
.map((item) => item.trim())
|
|
175
|
+
.filter((item) => item.length > 0)
|
|
176
|
+
|
|
155
177
|
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
156
178
|
|
|
157
179
|
const isValidRegExp = (source: string): boolean => {
|
|
@@ -219,12 +241,48 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
219
241
|
message: `pack manifest 'programFiles:' is not a valid regex template: ${programFiles}`
|
|
220
242
|
})
|
|
221
243
|
}
|
|
244
|
+
const featureFiles = fields["feature-files"]
|
|
245
|
+
if (
|
|
246
|
+
featureFiles !== undefined &&
|
|
247
|
+
!isValidRegExp(featureFiles.replaceAll("<NAME>", "PROBE").replaceAll("<PAGES>", "PROBE"))
|
|
248
|
+
) {
|
|
249
|
+
return yield* PlanParseError.make({
|
|
250
|
+
message: `pack manifest 'feature-files:' is not a valid regex template: ${featureFiles}`
|
|
251
|
+
})
|
|
252
|
+
}
|
|
222
253
|
const specSchema = fields["spec-schema"]
|
|
223
254
|
if (specSchema !== undefined && specSchema !== "pagespec") {
|
|
224
255
|
return yield* PlanParseError.make({
|
|
225
256
|
message: `pack manifest 'spec-schema:' must be 'pagespec' when set, got: ${specSchema}`
|
|
226
257
|
})
|
|
227
258
|
}
|
|
259
|
+
const consolidateValues = namedItems(section(manifest.sections, "Consolidate"))
|
|
260
|
+
const consolidate: ConsolidateRules | undefined =
|
|
261
|
+
section(manifest.sections, "Consolidate") === undefined
|
|
262
|
+
? undefined
|
|
263
|
+
: {
|
|
264
|
+
cluster: commaList(consolidateValues.cluster),
|
|
265
|
+
context: commaList(consolidateValues.context)
|
|
266
|
+
}
|
|
267
|
+
if (consolidate !== undefined) {
|
|
268
|
+
const surveyNames = new Set(rules(manifest.sections, "## Survey: ").map((rule) => rule.name))
|
|
269
|
+
const unknown = [...consolidate.cluster, ...consolidate.context].filter(
|
|
270
|
+
(kind) => !surveyNames.has(kind) && !kind.startsWith("llm-") && !kind.endsWith("*")
|
|
271
|
+
)
|
|
272
|
+
if (unknown.length > 0) {
|
|
273
|
+
return yield* PlanParseError.make({
|
|
274
|
+
message:
|
|
275
|
+
`pack manifest '## Consolidate' names edge kinds no '## Survey:' rule produces: ${unknown.join(", ")} ` +
|
|
276
|
+
`(known: ${[...surveyNames].join(", ") || "none"}; 'llm-*' matches refined edges)`
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
const both = consolidate.cluster.filter((kind) => consolidate.context.includes(kind))
|
|
280
|
+
if (both.length > 0) {
|
|
281
|
+
return yield* PlanParseError.make({
|
|
282
|
+
message: `pack manifest '## Consolidate' lists ${both.join(", ")} as both cluster and context`
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|
|
228
286
|
const exclude = fields.exclude
|
|
229
287
|
if (exclude !== undefined && !isValidRegExp(exclude)) {
|
|
230
288
|
return yield* PlanParseError.make({
|
|
@@ -258,7 +316,9 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
258
316
|
lenses,
|
|
259
317
|
lessons: lessons === undefined || lessons.length === 0 ? undefined : lessons,
|
|
260
318
|
programFiles,
|
|
319
|
+
featureFiles,
|
|
261
320
|
specSchema,
|
|
321
|
+
consolidate,
|
|
262
322
|
dir: directory,
|
|
263
323
|
gate: (name) => gates[name],
|
|
264
324
|
prompt: (name) => prompts[name],
|
|
@@ -267,7 +327,25 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
267
327
|
filesFor: (program) =>
|
|
268
328
|
programFiles === undefined
|
|
269
329
|
? new RegExp(escapeRegExp(program), "i")
|
|
270
|
-
: new RegExp(`^(?:${programFiles.replaceAll("<NAME>", program)})$`)
|
|
330
|
+
: new RegExp(`^(?:${programFiles.replaceAll("<NAME>", program)})$`),
|
|
331
|
+
filesForFeature: (feature, pages) => {
|
|
332
|
+
const pageScopes =
|
|
333
|
+
programFiles === undefined
|
|
334
|
+
? pages.map((page) => escapeRegExp(page))
|
|
335
|
+
: pages.map((page) => programFiles.replaceAll("<NAME>", page))
|
|
336
|
+
const own =
|
|
337
|
+
featureFiles === undefined
|
|
338
|
+
? []
|
|
339
|
+
: [
|
|
340
|
+
featureFiles
|
|
341
|
+
.replaceAll("<NAME>", feature)
|
|
342
|
+
.replaceAll("<PAGES>", pages.map(escapeRegExp).join("|") || "PROBE")
|
|
343
|
+
]
|
|
344
|
+
const alternatives = [...own, ...pageScopes]
|
|
345
|
+
return programFiles === undefined && featureFiles === undefined
|
|
346
|
+
? new RegExp(alternatives.join("|"), "i")
|
|
347
|
+
: new RegExp(`^(?:${alternatives.join("|")})$`)
|
|
348
|
+
}
|
|
271
349
|
}
|
|
272
350
|
return pack
|
|
273
351
|
})
|
package/src/PageSpec.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as Effect from "effect/Effect"
|
|
2
2
|
import * as Schema from "effect/Schema"
|
|
3
|
-
import { PlanParseError } from "./FlowError.ts"
|
|
3
|
+
import { ContractConflict, PlanParseError } from "./FlowError.ts"
|
|
4
|
+
|
|
5
|
+
export { ContractConflict } from "./FlowError.ts"
|
|
4
6
|
|
|
5
7
|
// The Page Spec is the per-page contract of the J2EE→SPA conversion scenario
|
|
6
8
|
// (ADR 0012): extraction embeds it in the spec markdown as a ```json pagespec
|
|
@@ -251,9 +253,20 @@ const propertyLines = (
|
|
|
251
253
|
* future B4F implement. Emitted by code, not by a model: the contract must
|
|
252
254
|
* be a projection of the reviewed page spec, never an invention.
|
|
253
255
|
*/
|
|
254
|
-
|
|
256
|
+
interface ContractInfo {
|
|
257
|
+
readonly title: string
|
|
258
|
+
readonly description: string
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The YAML writer shared by the per-page and the per-feature contracts. */
|
|
262
|
+
const renderOpenApi = (
|
|
263
|
+
info: ContractInfo,
|
|
264
|
+
apiCalls: ReadonlyArray<PageApiCall>,
|
|
265
|
+
dtos: ReadonlyArray<PageDto>,
|
|
266
|
+
origins?: ReadonlyMap<string, ReadonlyArray<string>>
|
|
267
|
+
): string => {
|
|
255
268
|
const byPath = new Map<string, Array<PageApiCall>>()
|
|
256
|
-
for (const call of
|
|
269
|
+
for (const call of apiCalls) {
|
|
257
270
|
const bucket = byPath.get(call.path) ?? []
|
|
258
271
|
bucket.push(call)
|
|
259
272
|
byPath.set(call.path, bucket)
|
|
@@ -262,8 +275,8 @@ export const openApiFor = (spec: PageSpec): string => {
|
|
|
262
275
|
const lines: Array<string> = [
|
|
263
276
|
"openapi: 3.0.3",
|
|
264
277
|
"info:",
|
|
265
|
-
` title: ${yamlText(
|
|
266
|
-
` description: ${yamlText(
|
|
278
|
+
` title: ${yamlText(info.title)}`,
|
|
279
|
+
` description: ${yamlText(info.description)}`,
|
|
267
280
|
" version: 0.1.0",
|
|
268
281
|
"paths:"
|
|
269
282
|
]
|
|
@@ -286,8 +299,12 @@ export const openApiFor = (spec: PageSpec): string => {
|
|
|
286
299
|
const call = variants[0]!
|
|
287
300
|
lines.push(` ${method}:`)
|
|
288
301
|
lines.push(` operationId: ${call.operation}`)
|
|
302
|
+
const origin = origins?.get(`${call.method.toUpperCase()} ${call.path}`)
|
|
289
303
|
const notes = [
|
|
290
304
|
...(call.esbService === undefined ? [] : [`backed by ESB service ${call.esbService}`]),
|
|
305
|
+
...(origin === undefined
|
|
306
|
+
? []
|
|
307
|
+
: [`declared by page${origin.length > 1 ? "s" : ""} ${origin.join(", ")}`]),
|
|
291
308
|
...(variants.length > 1
|
|
292
309
|
? [
|
|
293
310
|
`also serves: ${variants
|
|
@@ -342,14 +359,14 @@ export const openApiFor = (spec: PageSpec): string => {
|
|
|
342
359
|
}
|
|
343
360
|
lines.push("components:")
|
|
344
361
|
lines.push(" schemas:")
|
|
345
|
-
const schemaCalls = [...
|
|
362
|
+
const schemaCalls = [...apiCalls].sort((a, b) => a.operation.localeCompare(b.operation))
|
|
346
363
|
let wroteSchema = false
|
|
347
364
|
// Every DTO a response names becomes a component the calls reference —
|
|
348
365
|
// one schema per domain entity, shared by every endpoint returning it.
|
|
349
366
|
const referencedDtos = new Set(
|
|
350
|
-
|
|
367
|
+
apiCalls.flatMap((call) => (call.responseDto === undefined ? [] : [call.responseDto]))
|
|
351
368
|
)
|
|
352
|
-
for (const dto of [...
|
|
369
|
+
for (const dto of [...dtos].sort((a, b) => a.domainName.localeCompare(b.domainName))) {
|
|
353
370
|
if (!referencedDtos.has(dto.domainName)) {
|
|
354
371
|
continue
|
|
355
372
|
}
|
|
@@ -388,6 +405,113 @@ export const openApiFor = (spec: PageSpec): string => {
|
|
|
388
405
|
return lines.join("\n") + "\n"
|
|
389
406
|
}
|
|
390
407
|
|
|
408
|
+
export const openApiFor = (spec: PageSpec): string =>
|
|
409
|
+
renderOpenApi(
|
|
410
|
+
{
|
|
411
|
+
title: `${spec.title} service contract`,
|
|
412
|
+
description: `Anti-corruption contract for page ${spec.page} (${spec.route})`
|
|
413
|
+
},
|
|
414
|
+
spec.apiCalls,
|
|
415
|
+
spec.dtos
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
const sameFields = (
|
|
419
|
+
left: ReadonlyArray<FieldMapping>,
|
|
420
|
+
right: ReadonlyArray<FieldMapping>
|
|
421
|
+
): boolean =>
|
|
422
|
+
left.length === right.length &&
|
|
423
|
+
left.every(
|
|
424
|
+
(field, index) =>
|
|
425
|
+
field.legacyName === right[index]?.legacyName &&
|
|
426
|
+
field.domainName === right[index]?.domainName &&
|
|
427
|
+
field.type === right[index]?.type
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
const sameCall = (left: PageApiCall, right: PageApiCall): boolean =>
|
|
431
|
+
left.operation === right.operation &&
|
|
432
|
+
left.responseDto === right.responseDto &&
|
|
433
|
+
left.responseShape === right.responseShape &&
|
|
434
|
+
sameFields(left.request, right.request) &&
|
|
435
|
+
sameFields(left.response, right.response)
|
|
436
|
+
|
|
437
|
+
export interface FeatureContract {
|
|
438
|
+
readonly yaml: string
|
|
439
|
+
/** `<method> <path>` of every operation and the pages that declare it. */
|
|
440
|
+
readonly operations: ReadonlyArray<{
|
|
441
|
+
readonly key: string
|
|
442
|
+
readonly pages: ReadonlyArray<string>
|
|
443
|
+
}>
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* ONE contract for a domain feature (ADR 0012 addendum): the union of its
|
|
448
|
+
* pages' API sections by method + path, DTOs by domain name. Two pages that
|
|
449
|
+
* declare the same method + path (or the same operation name) with different
|
|
450
|
+
* shapes, or the same DTO with different fields, are a `ContractConflict`
|
|
451
|
+
* listing every disagreement — never a silent merge. The page each
|
|
452
|
+
* operation came from is recorded in its description.
|
|
453
|
+
*/
|
|
454
|
+
export const openApiForFeature = (
|
|
455
|
+
feature: { readonly id: string; readonly name: string },
|
|
456
|
+
specs: ReadonlyArray<PageSpec>
|
|
457
|
+
): Effect.Effect<FeatureContract, ContractConflict> => {
|
|
458
|
+
const conflicts: Array<string> = []
|
|
459
|
+
const calls = new Map<string, { call: PageApiCall; pages: Array<string> }>()
|
|
460
|
+
const byOperation = new Map<string, string>()
|
|
461
|
+
for (const spec of specs) {
|
|
462
|
+
for (const call of spec.apiCalls) {
|
|
463
|
+
const key = `${call.method.toUpperCase()} ${call.path}`
|
|
464
|
+
const existing = calls.get(key)
|
|
465
|
+
if (existing === undefined) {
|
|
466
|
+
const owner = byOperation.get(call.operation)
|
|
467
|
+
if (owner !== undefined && owner !== key) {
|
|
468
|
+
conflicts.push(
|
|
469
|
+
`operation '${call.operation}' is declared on ${owner} and on ${key} (${spec.page})`
|
|
470
|
+
)
|
|
471
|
+
continue
|
|
472
|
+
}
|
|
473
|
+
byOperation.set(call.operation, key)
|
|
474
|
+
calls.set(key, { call, pages: [spec.page] })
|
|
475
|
+
} else if (sameCall(existing.call, call)) {
|
|
476
|
+
existing.pages.push(spec.page)
|
|
477
|
+
} else {
|
|
478
|
+
conflicts.push(
|
|
479
|
+
`${key} differs between ${existing.pages.join(", ")} (${existing.call.operation}) and ${spec.page} (${call.operation})`
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const dtos = new Map<string, { dto: PageDto; page: string }>()
|
|
485
|
+
for (const spec of specs) {
|
|
486
|
+
for (const dto of spec.dtos) {
|
|
487
|
+
const existing = dtos.get(dto.domainName)
|
|
488
|
+
if (existing === undefined) {
|
|
489
|
+
dtos.set(dto.domainName, { dto, page: spec.page })
|
|
490
|
+
} else if (!sameFields(existing.dto.fields, dto.fields)) {
|
|
491
|
+
conflicts.push(
|
|
492
|
+
`DTO '${dto.domainName}' has different fields in ${existing.page} and ${spec.page}`
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (conflicts.length > 0) {
|
|
498
|
+
return Effect.fail(ContractConflict.make({ feature: feature.id, conflicts }))
|
|
499
|
+
}
|
|
500
|
+
const operations = [...calls.entries()].map(([key, { pages }]) => ({ key, pages }))
|
|
501
|
+
const yaml = renderOpenApi(
|
|
502
|
+
{
|
|
503
|
+
title: `${feature.name} service contract`,
|
|
504
|
+
description:
|
|
505
|
+
`Anti-corruption contract for domain feature ${feature.id} — pages ` +
|
|
506
|
+
specs.map((spec) => spec.page).join(", ")
|
|
507
|
+
},
|
|
508
|
+
[...calls.values()].map(({ call }) => call),
|
|
509
|
+
[...dtos.values()].map(({ dto }) => dto),
|
|
510
|
+
new Map(operations.map(({ key, pages }) => [key, pages]))
|
|
511
|
+
)
|
|
512
|
+
return Effect.succeed({ yaml, operations })
|
|
513
|
+
}
|
|
514
|
+
|
|
391
515
|
/** Human-readable summary — the review surface next to the JSON contract. */
|
|
392
516
|
export const renderPageSpec = (spec: PageSpec): string => {
|
|
393
517
|
const lines: Array<string> = [
|
package/src/SpecChecks.ts
CHANGED
|
@@ -100,12 +100,20 @@ export interface CoverageOptions {
|
|
|
100
100
|
* rather than failing this wave's gate. Absent: every unit gates.
|
|
101
101
|
*/
|
|
102
102
|
readonly inScope?: (path: string) => boolean
|
|
103
|
+
/**
|
|
104
|
+
* Units a decisions overlay waives (ADR 0015): the legacy has them, a
|
|
105
|
+
* human decided not to carry them, so they never gate and are reported
|
|
106
|
+
* as waived rather than uncovered.
|
|
107
|
+
*/
|
|
108
|
+
readonly waived?: ReadonlySet<string>
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
export interface CoverageReport {
|
|
106
112
|
readonly result: ReviewResult
|
|
107
113
|
/** Uncovered units the scope excluded from the gate, as `rule: unit`. */
|
|
108
114
|
readonly outOfScope: ReadonlyArray<string>
|
|
115
|
+
/** Uncovered units a decision waived, as `rule: unit`. */
|
|
116
|
+
readonly waived: ReadonlyArray<string>
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
const uncoveredIssue = (rule: string, unit: string): ReviewIssue =>
|
|
@@ -126,10 +134,15 @@ export const coverageReport = Effect.fn("@llm4ts/flow/SpecChecks.coverageReport"
|
|
|
126
134
|
const captured = yield* capturedUnits(workspace, rules)
|
|
127
135
|
const issues: Array<ReviewIssue> = []
|
|
128
136
|
const outOfScope: Array<string> = []
|
|
137
|
+
const waived: Array<string> = []
|
|
129
138
|
for (const entry of captured) {
|
|
130
139
|
if (traceability.includes(entry.unit)) {
|
|
131
140
|
continue
|
|
132
141
|
}
|
|
142
|
+
if (options.waived?.has(entry.unit) === true) {
|
|
143
|
+
waived.push(`${entry.rule}: ${entry.unit}`)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
133
146
|
if (options.inScope === undefined || entry.paths.some(options.inScope)) {
|
|
134
147
|
issues.push(uncoveredIssue(entry.rule, entry.unit))
|
|
135
148
|
} else {
|
|
@@ -141,7 +154,8 @@ export const coverageReport = Effect.fn("@llm4ts/flow/SpecChecks.coverageReport"
|
|
|
141
154
|
issues,
|
|
142
155
|
summary: issues.length === 0 ? "coverage complete" : `${issues.length} unit(s) uncovered`
|
|
143
156
|
}),
|
|
144
|
-
outOfScope
|
|
157
|
+
outOfScope,
|
|
158
|
+
waived
|
|
145
159
|
}
|
|
146
160
|
})
|
|
147
161
|
|
package/src/Stories.ts
CHANGED
|
@@ -73,7 +73,8 @@ export class StoryState extends Schema.Class<StoryState>("StoryState")({
|
|
|
73
73
|
|
|
74
74
|
export const EpicReportVersion = 1
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
/** `waiting`: on hold behind a failed predecessor, runs once that is fixed. */
|
|
77
|
+
export const OutcomeStatus = Schema.Literals(["done", "failed", "waiting"])
|
|
77
78
|
export type OutcomeStatus = typeof OutcomeStatus.Type
|
|
78
79
|
|
|
79
80
|
export class StoryOutcome extends Schema.Class<StoryOutcome>("StoryOutcome")({
|
|
@@ -81,7 +82,7 @@ export class StoryOutcome extends Schema.Class<StoryOutcome>("StoryOutcome")({
|
|
|
81
82
|
title: Schema.String,
|
|
82
83
|
status: OutcomeStatus,
|
|
83
84
|
branch: Schema.optionalKey(Schema.String),
|
|
84
|
-
/** Failure or
|
|
85
|
+
/** Failure reason, or what a waiting story waits for. */
|
|
85
86
|
reason: Schema.optionalKey(Schema.String),
|
|
86
87
|
judge: Schema.optionalKey(Schema.String),
|
|
87
88
|
/** ESTIMATES, never measurements (ADR 0012). */
|
|
@@ -111,7 +112,7 @@ export const renderEpicReport = (report: EpicReport): string => {
|
|
|
111
112
|
"> the CLI seats report no usage. They are not measurements.",
|
|
112
113
|
"",
|
|
113
114
|
`- Epic branch: \`${report.epicBranch}\``,
|
|
114
|
-
`- Stories: ${report.stories.length} (done ${report.count("done")}, failed ${report.count("failed")},
|
|
115
|
+
`- Stories: ${report.stories.length} (done ${report.count("done")}, failed ${report.count("failed")}, waiting ${report.count("waiting")})`,
|
|
115
116
|
"",
|
|
116
117
|
"| Story | Status | Branch | Est. tokens | Est. cost | Note |",
|
|
117
118
|
"| --- | --- | --- | --- | --- | --- |"
|
|
@@ -393,6 +394,9 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
393
394
|
if (yield* context.git.branchExists(stored.branch)) {
|
|
394
395
|
yield* context.git.deleteBranch(stored.branch)
|
|
395
396
|
}
|
|
397
|
+
// The task checkpoint belongs to the old branch: left in place, the
|
|
398
|
+
// fresh branch would inherit "every task complete" and skip the coder.
|
|
399
|
+
yield* files.remove(planPath(story))
|
|
396
400
|
stored = undefined
|
|
397
401
|
}
|
|
398
402
|
if (stored !== undefined && stored.status === "merged") {
|
|
@@ -477,6 +481,11 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
477
481
|
const judge = options.judge
|
|
478
482
|
for (let round = 1; round <= judgeRounds; round += 1) {
|
|
479
483
|
const diff = yield* storyContext.git.diffVsBase(epicBranch)
|
|
484
|
+
if (diff.trim().length === 0) {
|
|
485
|
+
// Nothing to judge is a deterministic failure, not a model call:
|
|
486
|
+
// a model asked to score an empty diff scores the prompt instead.
|
|
487
|
+
return yield* failed(story, "the story branch has no changes against the epic branch")
|
|
488
|
+
}
|
|
480
489
|
const verdict = yield* judge(story, diff)
|
|
481
490
|
if (verdict.isClean) {
|
|
482
491
|
judgeNote = `judge cleared (round ${round})`
|
|
@@ -602,7 +611,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
602
611
|
return {
|
|
603
612
|
done: byStatus("done"),
|
|
604
613
|
failed: byStatus("failed"),
|
|
605
|
-
|
|
614
|
+
waiting: byStatus("waiting"),
|
|
606
615
|
running: yield* Ref.get(running)
|
|
607
616
|
}
|
|
608
617
|
})
|
|
@@ -610,7 +619,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
610
619
|
const record = (outcome: StoryOutcome): Effect.Effect<void> =>
|
|
611
620
|
Ref.update(outcomes, (current) => [...current, outcome])
|
|
612
621
|
|
|
613
|
-
|
|
622
|
+
/** Dependents of a failed story go on hold: they run once it is fixed and rerun. */
|
|
623
|
+
const holdDependents = (story: Story): Effect.Effect<void, FlowError> =>
|
|
614
624
|
Effect.gen(function* () {
|
|
615
625
|
const current = yield* progress
|
|
616
626
|
for (const id of dependentsOf(plan, story.id)) {
|
|
@@ -619,13 +629,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
619
629
|
dependent === undefined ||
|
|
620
630
|
current.done.has(id) ||
|
|
621
631
|
current.failed.has(id) ||
|
|
622
|
-
current.
|
|
632
|
+
current.waiting.has(id)
|
|
623
633
|
) {
|
|
624
634
|
continue
|
|
625
635
|
}
|
|
626
|
-
const reason = `
|
|
627
|
-
yield* record(StoryOutcome.make({ id, title: dependent.title, status: "
|
|
628
|
-
yield* board.
|
|
636
|
+
const reason = `waiting for ${story.id}`
|
|
637
|
+
yield* record(StoryOutcome.make({ id, title: dependent.title, status: "waiting", reason }))
|
|
638
|
+
yield* board.wait(id, reason)
|
|
629
639
|
}
|
|
630
640
|
})
|
|
631
641
|
|
|
@@ -673,7 +683,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
673
683
|
reason: outcome.reason ?? "failed"
|
|
674
684
|
})
|
|
675
685
|
}
|
|
676
|
-
yield*
|
|
686
|
+
yield* holdDependents(completion.story)
|
|
677
687
|
}
|
|
678
688
|
}
|
|
679
689
|
})
|
package/src/StoryPlan.ts
CHANGED
|
@@ -182,7 +182,8 @@ export const topologicalWaves = (plan: StoryPlan): ReadonlyArray<ReadonlyArray<s
|
|
|
182
182
|
export interface StoryProgress {
|
|
183
183
|
readonly done: ReadonlySet<string>
|
|
184
184
|
readonly failed: ReadonlySet<string>
|
|
185
|
-
|
|
185
|
+
/** On hold behind a failed predecessor. */
|
|
186
|
+
readonly waiting: ReadonlySet<string>
|
|
186
187
|
readonly running: ReadonlySet<string>
|
|
187
188
|
}
|
|
188
189
|
|
|
@@ -192,7 +193,7 @@ export const readyStories = (plan: StoryPlan, progress: StoryProgress): Readonly
|
|
|
192
193
|
(story) =>
|
|
193
194
|
!progress.done.has(story.id) &&
|
|
194
195
|
!progress.failed.has(story.id) &&
|
|
195
|
-
!progress.
|
|
196
|
+
!progress.waiting.has(story.id) &&
|
|
196
197
|
!progress.running.has(story.id) &&
|
|
197
198
|
story.dependsOn.every((dependency) => progress.done.has(dependency))
|
|
198
199
|
)
|