@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.
Files changed (58) hide show
  1. package/dist/Approval.d.ts +1 -1
  2. package/dist/Approval.d.ts.map +1 -1
  3. package/dist/Artifacts.d.ts +2 -2
  4. package/dist/Artifacts.d.ts.map +1 -1
  5. package/dist/BenchReport.d.ts +2 -2
  6. package/dist/BenchReport.d.ts.map +1 -1
  7. package/dist/CostLedger.d.ts +2 -2
  8. package/dist/CostLedger.d.ts.map +1 -1
  9. package/dist/CoverageLedger.d.ts +180 -0
  10. package/dist/CoverageLedger.d.ts.map +1 -0
  11. package/dist/CoverageLedger.js +415 -0
  12. package/dist/CoverageLedger.js.map +1 -0
  13. package/dist/EpicBrief.d.ts +54 -0
  14. package/dist/EpicBrief.d.ts.map +1 -1
  15. package/dist/EpicBrief.js +92 -5
  16. package/dist/EpicBrief.js.map +1 -1
  17. package/dist/Equiv.d.ts +3 -3
  18. package/dist/Equiv.d.ts.map +1 -1
  19. package/dist/Flow.d.ts +1 -1
  20. package/dist/Flow.d.ts.map +1 -1
  21. package/dist/FlowError.d.ts +9 -1
  22. package/dist/FlowError.d.ts.map +1 -1
  23. package/dist/FlowError.js +11 -1
  24. package/dist/FlowError.js.map +1 -1
  25. package/dist/Judgment.d.ts +2 -2
  26. package/dist/Judgment.d.ts.map +1 -1
  27. package/dist/Landing.d.ts +9 -1
  28. package/dist/Landing.d.ts.map +1 -1
  29. package/dist/Landing.js +16 -5
  30. package/dist/Landing.js.map +1 -1
  31. package/dist/Persistence.d.ts +2 -2
  32. package/dist/Persistence.d.ts.map +1 -1
  33. package/dist/PlanExecution.d.ts +1 -1
  34. package/dist/PlanExecution.d.ts.map +1 -1
  35. package/dist/ProgramJudge.d.ts +1 -1
  36. package/dist/ProgramJudge.d.ts.map +1 -1
  37. package/dist/RefineRound.d.ts +94 -0
  38. package/dist/RefineRound.d.ts.map +1 -0
  39. package/dist/RefineRound.js +157 -0
  40. package/dist/RefineRound.js.map +1 -0
  41. package/dist/Replay.d.ts +2 -2
  42. package/dist/Replay.d.ts.map +1 -1
  43. package/dist/Review.d.ts +3 -3
  44. package/dist/Review.d.ts.map +1 -1
  45. package/dist/ReviewCache.d.ts +1 -1
  46. package/dist/ReviewCache.d.ts.map +1 -1
  47. package/dist/Roster.d.ts +1 -1
  48. package/dist/Roster.d.ts.map +1 -1
  49. package/dist/RosterSeats.d.ts +1 -1
  50. package/dist/RosterSeats.d.ts.map +1 -1
  51. package/dist/Stories.d.ts +1 -1
  52. package/dist/Stories.d.ts.map +1 -1
  53. package/package.json +4 -2
  54. package/src/CoverageLedger.ts +549 -0
  55. package/src/EpicBrief.ts +123 -9
  56. package/src/FlowError.ts +12 -1
  57. package/src/Landing.ts +24 -6
  58. 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 (!justified(conflict)) problems.push(conflict)
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 plan.stories) {
101
+ for (const story of tracked) {
93
102
  const state = yield* loadVersioned(
94
103
  files,
95
- join(options.stateDir, `stories/${story.id}.json`),
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 stories = plan.stories.map((story) => `- ${story.id}: ${story.provides.join("; ")}`)
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 plan.stories) {
232
+ for (const story of tracked) {
215
233
  const state = yield* loadVersioned(
216
234
  files,
217
- join(options.stateDir, `stories/${story.id}.json`),
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
+ )