@llm4ts/flow 2.17.0 → 2.19.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/CostLedger.d.ts +2 -2
- package/dist/CostLedger.d.ts.map +1 -1
- package/dist/CoverageLedger.d.ts +180 -0
- package/dist/CoverageLedger.d.ts.map +1 -0
- package/dist/CoverageLedger.js +415 -0
- package/dist/CoverageLedger.js.map +1 -0
- package/dist/EpicBrief.d.ts +54 -0
- package/dist/EpicBrief.d.ts.map +1 -1
- package/dist/EpicBrief.js +92 -5
- package/dist/EpicBrief.js.map +1 -1
- 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 +9 -1
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +11 -1
- package/dist/FlowError.js.map +1 -1
- package/dist/Judgment.d.ts +2 -2
- package/dist/Judgment.d.ts.map +1 -1
- package/dist/Landing.d.ts +9 -1
- package/dist/Landing.d.ts.map +1 -1
- package/dist/Landing.js +16 -5
- package/dist/Landing.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/RefineRound.d.ts +94 -0
- package/dist/RefineRound.d.ts.map +1 -0
- package/dist/RefineRound.js +157 -0
- package/dist/RefineRound.js.map +1 -0
- package/dist/Replay.d.ts +2 -2
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Review.d.ts +3 -3
- 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/Roster.d.ts +1 -1
- package/dist/Roster.d.ts.map +1 -1
- package/dist/RosterSeats.d.ts +1 -1
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/Stories.d.ts +1 -1
- package/dist/Stories.d.ts.map +1 -1
- package/package.json +4 -2
- package/src/CoverageLedger.ts +549 -0
- package/src/EpicBrief.ts +123 -9
- package/src/FlowError.ts +12 -1
- package/src/Landing.ts +24 -6
- package/src/RefineRound.ts +227 -0
package/src/EpicBrief.ts
CHANGED
|
@@ -473,6 +473,24 @@ export const BriefProblem = Schema.Union([
|
|
|
473
473
|
scenario: Schema.String,
|
|
474
474
|
disposition: Schema.String
|
|
475
475
|
}),
|
|
476
|
+
Schema.Struct({
|
|
477
|
+
kind: Schema.Literal("AlreadyOwned"),
|
|
478
|
+
program: Schema.String,
|
|
479
|
+
scenario: Schema.String,
|
|
480
|
+
/** The approved epic that already has the scenario in scope. */
|
|
481
|
+
epic: Schema.String
|
|
482
|
+
}),
|
|
483
|
+
Schema.Struct({
|
|
484
|
+
kind: Schema.Literal("ContradictsBrief"),
|
|
485
|
+
program: Schema.String,
|
|
486
|
+
scenario: Schema.String,
|
|
487
|
+
/** The approved epic that decided otherwise. */
|
|
488
|
+
epic: Schema.String,
|
|
489
|
+
/** This brief's disposition, in words. */
|
|
490
|
+
here: Schema.String,
|
|
491
|
+
/** The other epic's disposition, in words. */
|
|
492
|
+
disposition: Schema.String
|
|
493
|
+
}),
|
|
476
494
|
Schema.Struct({
|
|
477
495
|
kind: Schema.Literal("ApprovedWithOpenPoints"),
|
|
478
496
|
numbers: Schema.Array(Schema.Int)
|
|
@@ -498,11 +516,77 @@ export const renderBriefProblem = (problem: BriefProblem): string => {
|
|
|
498
516
|
return `${problem.program}${cite}${problem.scenario} has no disposition: put it in scope, or drop, mark provided or defer it`
|
|
499
517
|
case "RefineConflict":
|
|
500
518
|
return `${problem.program}${cite}${problem.scenario} is in scope, but modernize-refine marked it ${problem.disposition}; answer \`keep: <why>\` to keep it in scope, or move it out`
|
|
519
|
+
case "AlreadyOwned":
|
|
520
|
+
return `${problem.program}${cite}${problem.scenario} is in scope here and in epic ${problem.epic}; answer \`keep: <why>\` to share it, or move it out`
|
|
521
|
+
case "ContradictsBrief":
|
|
522
|
+
return `${problem.program}${cite}${problem.scenario} is ${problem.here} here, but epic ${problem.epic} has it ${problem.disposition}; answer \`keep: <why>\` to keep this brief's decision, or follow the other`
|
|
501
523
|
case "ApprovedWithOpenPoints":
|
|
502
524
|
return `the brief is approved with unanswered open points: ${problem.numbers.join(", ")}`
|
|
503
525
|
}
|
|
504
526
|
}
|
|
505
527
|
|
|
528
|
+
/** How a brief disposes of a scenario. */
|
|
529
|
+
export type BriefDisposition = "in-scope" | "dropped" | "provided" | "deferred"
|
|
530
|
+
|
|
531
|
+
export const dispositionWords: Readonly<Record<BriefDisposition, string>> = {
|
|
532
|
+
"in-scope": "in scope",
|
|
533
|
+
dropped: "dropped",
|
|
534
|
+
provided: "provided",
|
|
535
|
+
deferred: "deferred"
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** What an approved brief of another epic decided about a scenario (the coverage ledger). */
|
|
539
|
+
export interface InheritedDecision {
|
|
540
|
+
readonly program: string
|
|
541
|
+
readonly scenario: string
|
|
542
|
+
readonly epic: string
|
|
543
|
+
readonly disposition: BriefDisposition
|
|
544
|
+
readonly note: string
|
|
545
|
+
/** The epics this decision was kept against on purpose (`keep: <why>` in that brief). */
|
|
546
|
+
readonly keptAgainst?: ReadonlyArray<string>
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The one rule for two briefs on one scenario, read from the side of the
|
|
551
|
+
* brief that has `here`. A deferred scenario is free on either side; two
|
|
552
|
+
* briefs that agree are fine; both in scope is ownership shared or disputed;
|
|
553
|
+
* anything else is a contradiction. The ledger and the per-brief check both
|
|
554
|
+
* use it, so they cannot disagree about what a conflict is.
|
|
555
|
+
*/
|
|
556
|
+
export const crossProblem = (
|
|
557
|
+
program: string,
|
|
558
|
+
scenario: string,
|
|
559
|
+
here: BriefDisposition,
|
|
560
|
+
other: { readonly epic: string; readonly disposition: BriefDisposition }
|
|
561
|
+
): BriefProblem | undefined => {
|
|
562
|
+
if (here === "deferred" || other.disposition === "deferred") return undefined
|
|
563
|
+
if (here === other.disposition) {
|
|
564
|
+
return here === "in-scope"
|
|
565
|
+
? { kind: "AlreadyOwned", program, scenario, epic: other.epic }
|
|
566
|
+
: undefined
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
kind: "ContradictsBrief",
|
|
570
|
+
program,
|
|
571
|
+
scenario,
|
|
572
|
+
epic: other.epic,
|
|
573
|
+
here: dispositionWords[here],
|
|
574
|
+
disposition: dispositionWords[other.disposition]
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Whether the brief overrides a check on purpose: the check's own `[check]`
|
|
580
|
+
* question, answered `keep: <why>`. Any other answer, or an answer to
|
|
581
|
+
* another question, overrides nothing.
|
|
582
|
+
*/
|
|
583
|
+
export const hasOverride = (brief: EpicBrief, problem: BriefProblem): boolean =>
|
|
584
|
+
brief.openPoints.some(
|
|
585
|
+
(point) =>
|
|
586
|
+
point.question === `${checkMark} ${renderBriefProblem(problem)}` &&
|
|
587
|
+
(point.answer ?? "").trim().toLowerCase().startsWith("keep")
|
|
588
|
+
)
|
|
589
|
+
|
|
506
590
|
/** The pointers of the provided entries: what the caller verifies before checking. */
|
|
507
591
|
export const providedPointers = (brief: EpicBrief): ReadonlyArray<string> => [
|
|
508
592
|
...new Set(brief.provided.map((entry) => entry.note).filter((note) => note.length > 0))
|
|
@@ -515,6 +599,14 @@ export interface BriefCheckInputs {
|
|
|
515
599
|
readonly pack: PackIndex
|
|
516
600
|
/** The provided pointers that were found in the target (or its pack). */
|
|
517
601
|
readonly pointers: ReadonlySet<string>
|
|
602
|
+
/**
|
|
603
|
+
* What approved briefs of other epics decided (the coverage ledger). Their
|
|
604
|
+
* decisions are inherited: they count towards completeness, and deciding
|
|
605
|
+
* otherwise here is a problem. Absent: the brief is checked on its own.
|
|
606
|
+
*/
|
|
607
|
+
readonly others?: ReadonlyArray<InheritedDecision>
|
|
608
|
+
/** This brief's epic, as the other briefs name it. Default: the brief's own id. */
|
|
609
|
+
readonly epicId?: string
|
|
518
610
|
}
|
|
519
611
|
|
|
520
612
|
/** Every problem of a brief against the pack, all at once; empty means acceptable. */
|
|
@@ -593,6 +685,11 @@ export const checkEpicBrief = (
|
|
|
593
685
|
problems.push({ kind: "DuplicateDisposition", program, scenario, lists: where })
|
|
594
686
|
}
|
|
595
687
|
}
|
|
688
|
+
const inherited = new Map<string, Array<InheritedDecision>>()
|
|
689
|
+
for (const decision of inputs.others ?? []) {
|
|
690
|
+
const key = slot(decision.program, decision.scenario)
|
|
691
|
+
inherited.set(key, [...(inherited.get(key) ?? []), decision])
|
|
692
|
+
}
|
|
596
693
|
// Complete means every program the brief touches: the ones it lists as
|
|
597
694
|
// considered, and any it cites or disposes of without listing.
|
|
598
695
|
const touched = [
|
|
@@ -610,20 +707,13 @@ export const checkEpicBrief = (
|
|
|
610
707
|
continue
|
|
611
708
|
}
|
|
612
709
|
for (const scenario of known.scenarios) {
|
|
613
|
-
if (!lists.has(slot(known.name, scenario))) {
|
|
710
|
+
if (!lists.has(slot(known.name, scenario)) && !inherited.has(slot(known.name, scenario))) {
|
|
614
711
|
problems.push({ kind: "Unaccounted", program: known.name, scenario })
|
|
615
712
|
}
|
|
616
713
|
}
|
|
617
714
|
}
|
|
618
715
|
|
|
619
716
|
// A scenario refine took out of the pack's scope can come back only on purpose.
|
|
620
|
-
// The override is explicit: the check's own question, answered `keep: <why>`.
|
|
621
|
-
const justified = (conflict: BriefProblem): boolean =>
|
|
622
|
-
brief.openPoints.some(
|
|
623
|
-
(point) =>
|
|
624
|
-
point.question === `${checkMark} ${renderBriefProblem(conflict)}` &&
|
|
625
|
-
(point.answer ?? "").trim().toLowerCase().startsWith("keep")
|
|
626
|
-
)
|
|
627
717
|
for (const item of brief.scope) {
|
|
628
718
|
for (const citation of item.citations) {
|
|
629
719
|
const refined = inputs.pack.refine.find(
|
|
@@ -639,11 +729,35 @@ export const checkEpicBrief = (
|
|
|
639
729
|
scenario: citation.scenario,
|
|
640
730
|
disposition: refined.disposition
|
|
641
731
|
}
|
|
642
|
-
if (!
|
|
732
|
+
if (!hasOverride(brief, conflict)) problems.push(conflict)
|
|
643
733
|
}
|
|
644
734
|
}
|
|
645
735
|
}
|
|
646
736
|
|
|
737
|
+
// Approved briefs of other epics stand; `crossProblem` says when two briefs
|
|
738
|
+
// clash. A clash one side kept on purpose is settled: shared ownership when
|
|
739
|
+
// either keeps it, a contradiction when exactly one does.
|
|
740
|
+
const self = inputs.epicId ?? brief.epicId
|
|
741
|
+
const byWords = new Map<string, BriefDisposition>([
|
|
742
|
+
["in scope", "in-scope"],
|
|
743
|
+
["dropped", "dropped"],
|
|
744
|
+
["provided", "provided"],
|
|
745
|
+
["deferred", "deferred"]
|
|
746
|
+
])
|
|
747
|
+
for (const [key, where] of lists) {
|
|
748
|
+
const [program = "", scenario = ""] = key.split("\u0000")
|
|
749
|
+
const here = byWords.get(where[0] ?? "")
|
|
750
|
+
if (here === undefined) continue
|
|
751
|
+
for (const other of inherited.get(key) ?? []) {
|
|
752
|
+
const problem = crossProblem(program, scenario, here, other)
|
|
753
|
+
if (problem === undefined) continue
|
|
754
|
+
const theyKept = (other.keptAgainst ?? []).includes(self)
|
|
755
|
+
const iKept = hasOverride(brief, problem)
|
|
756
|
+
const settled = problem.kind === "AlreadyOwned" ? theyKept || iKept : theyKept !== iKept
|
|
757
|
+
if (!settled) problems.push(problem)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
647
761
|
const pending = unanswered(brief)
|
|
648
762
|
if (brief.status === "approved" && pending.length > 0) {
|
|
649
763
|
problems.push({ kind: "ApprovedWithOpenPoints", numbers: pending.map((point) => point.number) })
|
package/src/FlowError.ts
CHANGED
|
@@ -237,6 +237,16 @@ export class LandingFailed extends Schema.TaggedError<LandingFailed>()("LandingF
|
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
/** A refine round cannot start, or the epic cannot take one; the reason says what to do next (ADR 0021). */
|
|
241
|
+
export class RefineRefused extends Schema.TaggedError<RefineRefused>()("RefineRefused", {
|
|
242
|
+
epicId: Schema.String,
|
|
243
|
+
reason: Schema.String
|
|
244
|
+
}) {
|
|
245
|
+
get message(): string {
|
|
246
|
+
return `epic '${this.epicId}' cannot be refined: ${this.reason}`
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
240
250
|
/** The start of every `RosterExhausted` message, so it is recognisable once wrapped. */
|
|
241
251
|
export const rosterExhaustedPrefix = "roster exhausted: no executor can ever take the role"
|
|
242
252
|
|
|
@@ -366,6 +376,7 @@ export const FlowError = Schema.Union([
|
|
|
366
376
|
ContractConflict,
|
|
367
377
|
ExtractPackMissing,
|
|
368
378
|
EpicBriefInvalid,
|
|
369
|
-
EpicBriefNotApproved
|
|
379
|
+
EpicBriefNotApproved,
|
|
380
|
+
RefineRefused
|
|
370
381
|
])
|
|
371
382
|
export type FlowError = typeof FlowError.Type
|
package/src/Landing.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { statusPaths } from "./GitTool.ts"
|
|
|
15
15
|
import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
|
|
16
16
|
import type { ReviewResult } from "./Review.ts"
|
|
17
17
|
import { StoryState, StoryStateVersion } from "./Stories.ts"
|
|
18
|
-
import type { StoryPlan } from "./StoryPlan.ts"
|
|
18
|
+
import type { Story, StoryPlan } from "./StoryPlan.ts"
|
|
19
19
|
|
|
20
20
|
const join = (root: string, path: string): string =>
|
|
21
21
|
`${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
|
|
@@ -49,6 +49,11 @@ export interface LandOptions {
|
|
|
49
49
|
readonly maxRounds?: number
|
|
50
50
|
/** Keep the story worktrees and branches after landing. Default false: they go. */
|
|
51
51
|
readonly keepWorktrees?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* The epic's refine rounds (ADR 0021), each with its own plan and state
|
|
54
|
+
* folder: they must be merged too, and their worktrees go with the plan's.
|
|
55
|
+
*/
|
|
56
|
+
readonly rounds?: ReadonlyArray<{ readonly plan: StoryPlan; readonly stateDir: string }>
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
export interface LandReport {
|
|
@@ -88,11 +93,15 @@ export const landEpic = Effect.fn("@llm4ts/flow/Landing.land")(function* (
|
|
|
88
93
|
const failed = (reason: string) => LandingFailed.make({ epicBranch, target, reason })
|
|
89
94
|
|
|
90
95
|
// 1. Only a finished epic lands.
|
|
96
|
+
// The plan's stories, then each round's, every one against its own state folder.
|
|
97
|
+
const tracked = [{ plan, stateDir: options.stateDir }, ...(options.rounds ?? [])].flatMap(
|
|
98
|
+
(part) => part.plan.stories.map((story) => ({ id: story.id, stateDir: part.stateDir }))
|
|
99
|
+
)
|
|
91
100
|
const unmerged: Array<string> = []
|
|
92
|
-
for (const story of
|
|
101
|
+
for (const story of tracked) {
|
|
93
102
|
const state = yield* loadVersioned(
|
|
94
103
|
files,
|
|
95
|
-
join(
|
|
104
|
+
join(story.stateDir, `stories/${story.id}.json`),
|
|
96
105
|
StoryStateVersion,
|
|
97
106
|
StoryState
|
|
98
107
|
)
|
|
@@ -133,7 +142,16 @@ export const landEpic = Effect.fn("@llm4ts/flow/Landing.land")(function* (
|
|
|
133
142
|
events,
|
|
134
143
|
agent: "coder"
|
|
135
144
|
})
|
|
136
|
-
const
|
|
145
|
+
const provided = (story: Story): string => `- ${story.id}: ${story.provides.join("; ")}`
|
|
146
|
+
// A round changed what the plan's stories built (it may have removed some of
|
|
147
|
+
// it): the coder must not resolve a conflict back to the older intent.
|
|
148
|
+
const stories = [
|
|
149
|
+
...plan.stories.map(provided),
|
|
150
|
+
...(options.rounds ?? []).flatMap((round, index) => [
|
|
151
|
+
`and later changed on feedback (refine round ${index + 1}, which wins over the above):`,
|
|
152
|
+
...round.plan.stories.map(provided)
|
|
153
|
+
])
|
|
154
|
+
]
|
|
137
155
|
let rounds = 0
|
|
138
156
|
while (true) {
|
|
139
157
|
const withMarkers: Array<string> = []
|
|
@@ -211,10 +229,10 @@ export const landEpic = Effect.fn("@llm4ts/flow/Landing.land")(function* (
|
|
|
211
229
|
let deletedBranches = 0
|
|
212
230
|
if (options.keepWorktrees !== true) {
|
|
213
231
|
const kept: Array<string> = []
|
|
214
|
-
for (const story of
|
|
232
|
+
for (const story of tracked) {
|
|
215
233
|
const state = yield* loadVersioned(
|
|
216
234
|
files,
|
|
217
|
-
join(
|
|
235
|
+
join(story.stateDir, `stories/${story.id}.json`),
|
|
218
236
|
StoryStateVersion,
|
|
219
237
|
StoryState
|
|
220
238
|
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Refine rounds (ADR 0021): follow-up stories on a finished epic, planned
|
|
2
|
+
// from feedback. A round is its own story plan on the epic's branch, so the
|
|
3
|
+
// executor and the ownership rule are the ones a plan has. Pure: reading the
|
|
4
|
+
// round folders and asking the planner belong to the flow.
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
import * as Schema from "effect/Schema"
|
|
7
|
+
import type { JsonSchema } from "@llm4ts/core/Models"
|
|
8
|
+
import { Story, StoryPlan } from "./StoryPlan.ts"
|
|
9
|
+
|
|
10
|
+
/** A feedback item the planner left out, with the reason or the question to answer. */
|
|
11
|
+
export class NotPlanned extends Schema.Class<NotPlanned>("NotPlanned")({
|
|
12
|
+
item: Schema.String,
|
|
13
|
+
reason: Schema.String
|
|
14
|
+
}) {}
|
|
15
|
+
|
|
16
|
+
/** What the planner returns for a round: the stories it can place, and the rest. */
|
|
17
|
+
export class RefineProposal extends Schema.Class<RefineProposal>("RefineProposal")({
|
|
18
|
+
stories: Schema.Array(Story),
|
|
19
|
+
notPlanned: Schema.Array(NotPlanned).pipe(
|
|
20
|
+
Schema.withConstructorDefault(Effect.succeed([])),
|
|
21
|
+
Schema.withDecodingDefaultKey(Effect.succeed([]))
|
|
22
|
+
)
|
|
23
|
+
}) {}
|
|
24
|
+
|
|
25
|
+
const strings: JsonSchema = { type: "array", items: { type: "string" } }
|
|
26
|
+
|
|
27
|
+
export const refineProposalJsonSchema: JsonSchema = {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
stories: {
|
|
31
|
+
type: "array",
|
|
32
|
+
items: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
id: { type: "string" },
|
|
36
|
+
title: { type: "string" },
|
|
37
|
+
description: { type: "string" },
|
|
38
|
+
dependsOn: strings,
|
|
39
|
+
owned: strings,
|
|
40
|
+
sharedReadOnly: strings,
|
|
41
|
+
provides: strings
|
|
42
|
+
},
|
|
43
|
+
required: ["id", "title", "description", "dependsOn", "owned", "sharedReadOnly", "provides"]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
notPlanned: {
|
|
47
|
+
type: "array",
|
|
48
|
+
items: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: { item: { type: "string" }, reason: { type: "string" } },
|
|
51
|
+
required: ["item", "reason"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
required: ["stories", "notPlanned"]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Round stories are `r<n>-…`: unique across the epic, whatever the planner called them. */
|
|
59
|
+
export const roundPrefix = (round: number): string => `r${round}-`
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The round's story plan from a proposal. Ids get the round's prefix (once),
|
|
63
|
+
* dependencies follow, and a dependency on a story merged before the round
|
|
64
|
+
* (`earlier`) is dropped: a round only starts once those are in the branch.
|
|
65
|
+
*/
|
|
66
|
+
export const assembleRound = (inputs: {
|
|
67
|
+
readonly epicId: string
|
|
68
|
+
readonly round: number
|
|
69
|
+
readonly feedback: string
|
|
70
|
+
readonly proposal: RefineProposal
|
|
71
|
+
/** Ids of every story of the epic's plan and of the earlier rounds. */
|
|
72
|
+
readonly earlier: ReadonlyArray<string>
|
|
73
|
+
}): StoryPlan => {
|
|
74
|
+
const prefix = roundPrefix(inputs.round)
|
|
75
|
+
const prefixed = (id: string): string => (id.startsWith(prefix) ? id : `${prefix}${id}`)
|
|
76
|
+
const own = new Set(inputs.proposal.stories.map((story) => story.id))
|
|
77
|
+
const earlier = new Set(inputs.earlier)
|
|
78
|
+
return StoryPlan.make({
|
|
79
|
+
epicId: inputs.epicId,
|
|
80
|
+
epic: inputs.feedback,
|
|
81
|
+
stories: inputs.proposal.stories.map((story) =>
|
|
82
|
+
Story.make({
|
|
83
|
+
...story,
|
|
84
|
+
id: prefixed(story.id),
|
|
85
|
+
dependsOn: story.dependsOn
|
|
86
|
+
.filter((dependency) => own.has(dependency) || !earlier.has(dependency))
|
|
87
|
+
.map(prefixed)
|
|
88
|
+
})
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What to do about a round whose plan cannot be read: a plan alone is never deleted. */
|
|
94
|
+
export const roundRepair = (round: number): string =>
|
|
95
|
+
`fix the plan, or delete the folder rounds/${round} and every later round`
|
|
96
|
+
|
|
97
|
+
/** A round as the run decision sees it. */
|
|
98
|
+
export interface RoundProgress {
|
|
99
|
+
readonly round: number
|
|
100
|
+
readonly stories: number
|
|
101
|
+
readonly merged: number
|
|
102
|
+
/** Why its plan cannot be read, naming the file. */
|
|
103
|
+
readonly unreadable?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type RunAction =
|
|
107
|
+
| { readonly _tag: "RunPlan" }
|
|
108
|
+
| { readonly _tag: "RunRound"; readonly round: number }
|
|
109
|
+
| { readonly _tag: "PlanRound"; readonly round: number }
|
|
110
|
+
| { readonly _tag: "Land" }
|
|
111
|
+
| { readonly _tag: "Refused"; readonly reason: string }
|
|
112
|
+
| { readonly _tag: "Usage"; readonly message: string }
|
|
113
|
+
|
|
114
|
+
export interface RunInputs {
|
|
115
|
+
/** The epic has a story plan. */
|
|
116
|
+
readonly planned: boolean
|
|
117
|
+
readonly stories: number
|
|
118
|
+
readonly merged: number
|
|
119
|
+
/** The branch the epic landed on, once it has. */
|
|
120
|
+
readonly landed: string | undefined
|
|
121
|
+
readonly rounds: ReadonlyArray<RoundProgress>
|
|
122
|
+
/** `--refine` was given. */
|
|
123
|
+
readonly refine: boolean
|
|
124
|
+
/** The text given with `--refine`. */
|
|
125
|
+
readonly feedback: string
|
|
126
|
+
/** `--land` was given. */
|
|
127
|
+
readonly land: boolean
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* What a run of the epic does: its plan's stories, the open round, a new
|
|
132
|
+
* round, or the landing. A round starts only when everything before it is
|
|
133
|
+
* merged, and an open round is finished before another is planned.
|
|
134
|
+
*/
|
|
135
|
+
export const runAction = (inputs: RunInputs): RunAction => {
|
|
136
|
+
if (inputs.land) {
|
|
137
|
+
// Landing with feedback in hand would drop the feedback for good.
|
|
138
|
+
return inputs.refine && inputs.feedback.trim().length > 0
|
|
139
|
+
? {
|
|
140
|
+
_tag: "Usage",
|
|
141
|
+
message:
|
|
142
|
+
"--refine with feedback and --land do not go together: run the round first, then land"
|
|
143
|
+
}
|
|
144
|
+
: { _tag: "Land" }
|
|
145
|
+
}
|
|
146
|
+
const planOpen = inputs.merged < inputs.stories
|
|
147
|
+
const unreadable = inputs.rounds.find((round) => round.unreadable !== undefined)
|
|
148
|
+
const open = inputs.rounds.find((round) => round.merged < round.stories)
|
|
149
|
+
const broken = (round: RoundProgress): RunAction => ({
|
|
150
|
+
_tag: "Refused",
|
|
151
|
+
reason: `round ${round.round}'s plan cannot be read (${round.unreadable ?? ""}); ${roundRepair(round.round)}`
|
|
152
|
+
})
|
|
153
|
+
if (!inputs.refine) {
|
|
154
|
+
if (planOpen) {
|
|
155
|
+
return { _tag: "RunPlan" }
|
|
156
|
+
}
|
|
157
|
+
if (unreadable !== undefined) {
|
|
158
|
+
return broken(unreadable)
|
|
159
|
+
}
|
|
160
|
+
return open === undefined ? { _tag: "RunPlan" } : { _tag: "RunRound", round: open.round }
|
|
161
|
+
}
|
|
162
|
+
if (!inputs.planned) {
|
|
163
|
+
return { _tag: "Refused", reason: "it has no story plan yet: run the epic first" }
|
|
164
|
+
}
|
|
165
|
+
if (inputs.landed !== undefined) {
|
|
166
|
+
return {
|
|
167
|
+
_tag: "Refused",
|
|
168
|
+
reason: `it has landed on ${inputs.landed}; give the changes as a new epic`
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (unreadable !== undefined) {
|
|
172
|
+
return broken(unreadable)
|
|
173
|
+
}
|
|
174
|
+
if (planOpen) {
|
|
175
|
+
return {
|
|
176
|
+
_tag: "Refused",
|
|
177
|
+
reason: `${inputs.merged} of ${inputs.stories} stories merged: rerun the epic until every story merges`
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const feedback = inputs.feedback.trim()
|
|
181
|
+
if (open !== undefined) {
|
|
182
|
+
return feedback.length === 0
|
|
183
|
+
? { _tag: "RunRound", round: open.round }
|
|
184
|
+
: {
|
|
185
|
+
_tag: "Refused",
|
|
186
|
+
reason: `round ${open.round} is open (${open.merged} of ${open.stories} stories merged): rerun without --refine text to finish it, or edit its plan`
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (feedback.length === 0) {
|
|
190
|
+
return { _tag: "Usage", message: "--refine needs the feedback to plan a round from" }
|
|
191
|
+
}
|
|
192
|
+
return { _tag: "PlanRound", round: inputs.rounds.length + 1 }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const oneLine = (text: string): string => text.replace(/\s+/g, " ").trim()
|
|
196
|
+
|
|
197
|
+
/** `not-planned.md`: one line per item left out, for the person and for the next round's planner. */
|
|
198
|
+
export const renderNotPlanned = (round: number, items: ReadonlyArray<NotPlanned>): string =>
|
|
199
|
+
[
|
|
200
|
+
`# Not planned in round ${round}`,
|
|
201
|
+
"",
|
|
202
|
+
...(items.length === 0
|
|
203
|
+
? ["Every feedback item was planned."]
|
|
204
|
+
: items.map((entry) => `- **${oneLine(entry.item)}** — ${oneLine(entry.reason)}`)),
|
|
205
|
+
""
|
|
206
|
+
].join("\n")
|
|
207
|
+
|
|
208
|
+
export const countNotPlanned = (markdown: string): number =>
|
|
209
|
+
markdown.split("\n").filter((line) => line.startsWith("- **")).length
|
|
210
|
+
|
|
211
|
+
/** An earlier story as a round's planner is shown it: what was built, and where. */
|
|
212
|
+
export interface EarlierStory {
|
|
213
|
+
readonly id: string
|
|
214
|
+
readonly title: string
|
|
215
|
+
readonly owned: ReadonlyArray<string>
|
|
216
|
+
readonly provides: ReadonlyArray<string>
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const earlierStories = (plans: ReadonlyArray<StoryPlan>): ReadonlyArray<EarlierStory> =>
|
|
220
|
+
plans.flatMap((plan) =>
|
|
221
|
+
plan.stories.map((story) => ({
|
|
222
|
+
id: story.id,
|
|
223
|
+
title: story.title,
|
|
224
|
+
owned: story.owned,
|
|
225
|
+
provides: story.provides
|
|
226
|
+
}))
|
|
227
|
+
)
|