@llm4ts/flow 2.30.0 → 2.31.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 (74) hide show
  1. package/dist/AutonomyContract.d.ts +12 -0
  2. package/dist/AutonomyContract.d.ts.map +1 -0
  3. package/dist/AutonomyContract.js +51 -0
  4. package/dist/AutonomyContract.js.map +1 -0
  5. package/dist/CarriedNotes.d.ts +10 -0
  6. package/dist/CarriedNotes.d.ts.map +1 -1
  7. package/dist/CarriedNotes.js +27 -2
  8. package/dist/CarriedNotes.js.map +1 -1
  9. package/dist/Evidence.d.ts +12 -0
  10. package/dist/Evidence.d.ts.map +1 -0
  11. package/dist/Evidence.js +30 -0
  12. package/dist/Evidence.js.map +1 -0
  13. package/dist/Flow.d.ts +35 -3
  14. package/dist/Flow.d.ts.map +1 -1
  15. package/dist/Flow.js +58 -4
  16. package/dist/Flow.js.map +1 -1
  17. package/dist/FlowEvents.d.ts +14 -1
  18. package/dist/FlowEvents.d.ts.map +1 -1
  19. package/dist/FlowEvents.js +13 -0
  20. package/dist/FlowEvents.js.map +1 -1
  21. package/dist/GateTriage.d.ts +81 -0
  22. package/dist/GateTriage.d.ts.map +1 -0
  23. package/dist/GateTriage.js +161 -0
  24. package/dist/GateTriage.js.map +1 -0
  25. package/dist/Gates.d.ts +35 -0
  26. package/dist/Gates.d.ts.map +1 -0
  27. package/dist/Gates.js +63 -0
  28. package/dist/Gates.js.map +1 -0
  29. package/dist/OracleGuard.d.ts +53 -0
  30. package/dist/OracleGuard.d.ts.map +1 -0
  31. package/dist/OracleGuard.js +165 -0
  32. package/dist/OracleGuard.js.map +1 -0
  33. package/dist/Pack.d.ts +7 -0
  34. package/dist/Pack.d.ts.map +1 -1
  35. package/dist/Pack.js +9 -0
  36. package/dist/Pack.js.map +1 -1
  37. package/dist/Retro.d.ts.map +1 -1
  38. package/dist/Retro.js +5 -0
  39. package/dist/Retro.js.map +1 -1
  40. package/dist/Review.d.ts +68 -2
  41. package/dist/Review.d.ts.map +1 -1
  42. package/dist/Review.js +158 -27
  43. package/dist/Review.js.map +1 -1
  44. package/dist/Roster.d.ts +2 -0
  45. package/dist/Roster.d.ts.map +1 -1
  46. package/dist/Roster.js +3 -1
  47. package/dist/Roster.js.map +1 -1
  48. package/dist/RosterSeats.d.ts +2 -0
  49. package/dist/RosterSeats.d.ts.map +1 -1
  50. package/dist/RosterSeats.js.map +1 -1
  51. package/dist/Stories.d.ts +36 -2
  52. package/dist/Stories.d.ts.map +1 -1
  53. package/dist/Stories.js +159 -12
  54. package/dist/Stories.js.map +1 -1
  55. package/dist/StoryPlan.d.ts +6 -0
  56. package/dist/StoryPlan.d.ts.map +1 -1
  57. package/dist/StoryPlan.js +9 -2
  58. package/dist/StoryPlan.js.map +1 -1
  59. package/package.json +7 -2
  60. package/src/AutonomyContract.ts +61 -0
  61. package/src/CarriedNotes.ts +37 -2
  62. package/src/Evidence.ts +43 -0
  63. package/src/Flow.ts +109 -3
  64. package/src/FlowEvents.ts +13 -0
  65. package/src/GateTriage.ts +250 -0
  66. package/src/Gates.ts +104 -0
  67. package/src/OracleGuard.ts +205 -0
  68. package/src/Pack.ts +16 -0
  69. package/src/Retro.ts +7 -0
  70. package/src/Review.ts +261 -34
  71. package/src/Roster.ts +3 -1
  72. package/src/RosterSeats.ts +2 -0
  73. package/src/Stories.ts +289 -16
  74. package/src/StoryPlan.ts +11 -1
package/src/Stories.ts CHANGED
@@ -20,6 +20,16 @@ import type { LlmError } from "@llm4ts/core/Errors"
20
20
  import { BoardItem, type BoardSyncShape } from "./BoardSync.ts"
21
21
  import { makeChat } from "./Chat.ts"
22
22
  import { implementPlanFlow } from "./Flow.ts"
23
+ import {
24
+ GateBaseline,
25
+ baselineKey,
26
+ ensureBaseline as ensureStoredBaseline,
27
+ failingLinesOf,
28
+ storyGateLogDir,
29
+ triageGates,
30
+ writeBaseline,
31
+ type GateLogDir
32
+ } from "./Gates.ts"
23
33
  import type { ContextOptions, FlowContextShape } from "./FlowContext.ts"
24
34
  import {
25
35
  describeFlowError,
@@ -31,6 +41,7 @@ import {
31
41
  type FlowError
32
42
  } from "./FlowError.ts"
33
43
  import {
44
+ EvidenceChecked,
34
45
  Info,
35
46
  JudgedDimension,
36
47
  StoryJudged,
@@ -57,8 +68,23 @@ import {
57
68
  import { Plan, Task } from "./Plan.ts"
58
69
  import { stage } from "./PlanExecution.ts"
59
70
  import { planFrom } from "./Planner.ts"
60
- import { ReviewIssue, ReviewResult } from "./Review.ts"
71
+ import {
72
+ ReviewIssue,
73
+ ReviewResult,
74
+ applyTriage,
75
+ type FixPromptOptions,
76
+ type GateTriageOptions
77
+ } from "./Review.ts"
78
+ import {
79
+ type OracleRules,
80
+ checkOracle,
81
+ defaultOracleRules,
82
+ parseUnifiedDiff
83
+ } from "./OracleGuard.ts"
61
84
  import { cachedValue, fingerprintOf } from "./ReviewCache.ts"
85
+ import { withContract, type ContractProfile } from "./AutonomyContract.ts"
86
+ import { fabricatedStatusIssues, unverifiedClaims } from "./Evidence.ts"
87
+ import type { Trailer } from "./CarriedNotes.ts"
62
88
  import type { Reviewer } from "./Reviewer.ts"
63
89
  import {
64
90
  dependentsOf,
@@ -133,7 +159,9 @@ export class StoryOutcome extends Schema.Class<StoryOutcome>("StoryOutcome")({
133
159
  executor: Schema.optionalKey(Schema.String),
134
160
  /** ESTIMATES, never measurements (ADR 0012). */
135
161
  estimatedTokens: Schema.optionalKey(Schema.Int),
136
- estimatedCostUsd: Schema.optionalKey(Schema.Number)
162
+ estimatedCostUsd: Schema.optionalKey(Schema.Number),
163
+ /** Gate failures already red on the story's base, not charged to it (ADR 0027). */
164
+ inherited: Schema.optionalKey(Schema.Array(Schema.String))
137
165
  }) {}
138
166
 
139
167
  export class EpicReport extends Schema.Class<EpicReport>("EpicReport")({
@@ -184,6 +212,24 @@ export const renderEpicReport = (report: EpicReport): string => {
184
212
  if (cost.length > 0) {
185
213
  lines.push(`- Estimated cost: ${money(cost.reduce((sum, value) => sum + value, 0))}`)
186
214
  }
215
+ const inherited = new Map<string, Array<string>>()
216
+ for (const story of report.stories) {
217
+ for (const line of story.inherited ?? []) {
218
+ inherited.set(line, [...(inherited.get(line) ?? []), story.id])
219
+ }
220
+ }
221
+ if (inherited.size > 0) {
222
+ lines.push(
223
+ "",
224
+ "## Inherited gate failures",
225
+ "",
226
+ "Red on the base before the story ran; not charged to it. A cleanup story may own them.",
227
+ ""
228
+ )
229
+ for (const [line, stories] of inherited) {
230
+ lines.push(`- ${line} (stories: ${stories.join(", ")})`)
231
+ }
232
+ }
187
233
  const coded = report.stories.filter((story) => story.executor !== undefined)
188
234
  if (coded.length > 0) {
189
235
  // A story is counted for the executor that finished it; a handover chain
@@ -591,9 +637,43 @@ export interface StoriesOptions {
591
637
  * gate's timing names its story; a gate may ignore it.
592
638
  */
593
639
  readonly gates: (
640
+ workDir: string,
641
+ events?: FlowEventsShape,
642
+ /** Where to write one log per gate command for this run; absent writes none. */
643
+ log?: GateLogDir
644
+ ) => Effect.Effect<ReviewResult, FlowError>
645
+ /**
646
+ * The gate commands as configured and the application directory (ADR 0027):
647
+ * with them, the gates' result on each epic head is recorded as a baseline
648
+ * and a story is charged only with the failing lines it added. Without
649
+ * them every red line blocks, as before.
650
+ */
651
+ readonly gateCommands?: ReadonlyArray<ReadonlyArray<string>>
652
+ readonly appDir?: string
653
+ /** The test gate alone, for one rerun that tells a flaky line from a new one; omit to never rerun. */
654
+ readonly testGate?: (
594
655
  workDir: string,
595
656
  events?: FlowEventsShape
596
657
  ) => Effect.Effect<ReviewResult, FlowError>
658
+ /** How gate output reaches the coder in the fix prompt. */
659
+ readonly fix?: FixPromptOptions
660
+ /** Test-file and marker patterns for the oracle guard; default rules when absent (ADR 0027). */
661
+ readonly oracleRules?: OracleRules
662
+ /**
663
+ * The autonomy contract profile for the coder's system prompt (ADR 0027
664
+ * decision 5). Default: the roster executor's `contract`, else `full`.
665
+ */
666
+ readonly contract?: ContractProfile
667
+ /**
668
+ * The arguments of every tool call the story's coder made since `since`
669
+ * (epoch ms), from the run's transcript; `undefined` when the run keeps
670
+ * none for it. With it, a task's `verified:` claims are checked
671
+ * (ADR 0027 decision 6).
672
+ */
673
+ readonly toolCalls?: (
674
+ story: Story,
675
+ since: number
676
+ ) => Effect.Effect<ReadonlyArray<string> | undefined, FlowError>
597
677
  /** Story-level judge over the branch's diff against the epic branch; omit to skip. */
598
678
  readonly judge?: (
599
679
  story: Story,
@@ -717,6 +797,49 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
717
797
  join(options.stateDir, `stories/${story.id}.findings.md`)
718
798
  /** What the coder's tasks learned, carried into the next task of the same story. */
719
799
  const notesPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.notes.md`)
800
+ // Gates with a memory (ADR 0027): baselines are keyed by the commit a
801
+ // change started from, the app dir and the commands; without commands
802
+ // nothing is recorded and every red line blocks, as before.
803
+ const appDir = options.appDir ?? "."
804
+ const gateCommands = options.gateCommands
805
+ const rootsOf = (workDir: string): ReadonlyArray<string> => [join(workDir, appDir), workDir]
806
+ const keyFor = (commit: string): string =>
807
+ baselineKey({ baseCommit: commit, appDir, commands: gateCommands ?? [] })
808
+ const baselineOf = (
809
+ commit: string,
810
+ result: ReviewResult,
811
+ workDir: string
812
+ ): Effect.Effect<GateBaseline> =>
813
+ Effect.map(Clock.currentTimeMillis, (recordedAt) =>
814
+ GateBaseline.make({
815
+ baseCommit: commit,
816
+ appDir,
817
+ commands: (gateCommands ?? []).map((command) => command.join(" ")),
818
+ failingLines: failingLinesOf(result, rootsOf(workDir)),
819
+ ...(result.passed === undefined ? {} : { passedCount: result.passed }),
820
+ recordedAt
821
+ })
822
+ )
823
+ /** The baseline for `commit`, recording one from `run` when none is stored. */
824
+ const ensureBaseline = (
825
+ commit: string,
826
+ run: Effect.Effect<ReviewResult, FlowError>,
827
+ workDir: string
828
+ ): Effect.Effect<GateBaseline | undefined, FlowError> =>
829
+ gateCommands === undefined
830
+ ? Effect.succeed(undefined)
831
+ : ensureStoredBaseline({
832
+ files,
833
+ stateDir: options.stateDir,
834
+ commit,
835
+ appDir,
836
+ commands: gateCommands,
837
+ run,
838
+ roots: rootsOf(workDir),
839
+ now: Clock.currentTimeMillis
840
+ })
841
+ /** What each story inherited, for its outcome. */
842
+ const inheritedByStory = new Map<string, ReadonlyArray<string>>()
720
843
  const findingLines = (result: ReviewResult): ReadonlyArray<string> =>
721
844
  result.issues.map((issue) => {
722
845
  const where =
@@ -774,6 +897,11 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
774
897
 
775
898
  yield* epicCheckoutClean()
776
899
  yield* stage(events, "epic branch", context.git.checkoutOrCreate(epicBranch))
900
+ if (gateCommands !== undefined) {
901
+ // The gates as the epic head stands: what every story may inherit.
902
+ const epicHead = yield* context.git.checkpoint
903
+ yield* ensureBaseline(epicHead, options.gates(context.workDir, events), context.workDir)
904
+ }
777
905
 
778
906
  const waves = topologicalWaves(plan)
779
907
  const waveOf = (id: string): string | undefined => {
@@ -814,12 +942,29 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
814
942
  Timed.make({ kind: "merge", label: "merge", ms, ...(failed ? { failed } : {}) })
815
943
  )
816
944
  const gate = yield* options.gates(context.workDir, lane)
817
- if (!gate.isClean) {
945
+ // Charged only with what the merge added: the head before it had
946
+ // a baseline (run start or the previous merge).
947
+ const before = yield* ensureBaseline(
948
+ checkpoint,
949
+ Effect.succeed(ReviewResult.make({ issues: [] })),
950
+ context.workDir
951
+ )
952
+ const verdict = triageGates(gate, before, rootsOf(context.workDir))
953
+ if (!verdict.blocking.isClean) {
818
954
  // Never leave a red epic head for the next story to inherit.
819
955
  yield* context.git.rollback(checkpoint)
820
956
  return yield* failed(
821
957
  story,
822
- `epic gates failed after merging; merge undone:\n${issueLines(gate)}`
958
+ `epic gates failed after merging; merge undone:\n${issueLines(verdict.blocking)}`
959
+ )
960
+ }
961
+ if (gateCommands !== undefined) {
962
+ const mergedHead = yield* context.git.checkpoint
963
+ yield* writeBaseline(
964
+ files,
965
+ options.stateDir,
966
+ keyFor(mergedHead),
967
+ yield* baselineOf(mergedHead, gate, context.workDir)
823
968
  )
824
969
  }
825
970
  yield* laneOf(story).publish(
@@ -972,13 +1117,69 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
972
1117
  const git = storyContext.git
973
1118
  const watchedSeats: StorySeats = { ...seats, context: storyContext }
974
1119
  const extra = options.system === undefined ? undefined : yield* options.system(story)
975
- const system = [
976
- perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
977
- gateRules,
978
- extra
979
- ]
980
- .filter((part): part is string => part !== undefined && part.trim().length > 0)
981
- .join("\n\n")
1120
+ const contractProfile: ContractProfile =
1121
+ options.contract ??
1122
+ (coderExecutor === undefined ? undefined : roster?.contractOf?.(coderExecutor)) ??
1123
+ "full"
1124
+ const system = withContract(
1125
+ [
1126
+ perimeterRules(story, { plan, worktree: state.worktree, epicCheckout: context.workDir }),
1127
+ gateRules,
1128
+ extra
1129
+ ]
1130
+ .filter((part): part is string => part !== undefined && part.trim().length > 0)
1131
+ .join("\n\n"),
1132
+ contractProfile
1133
+ )
1134
+ /** What the coder claimed to have run and did not, task by task, for the judge. */
1135
+ const evidenceNotes: Array<string> = []
1136
+ const checkEvidence = (
1137
+ task: Task,
1138
+ trailer: Trailer,
1139
+ startedAt: number
1140
+ ): Effect.Effect<void, FlowError> =>
1141
+ Effect.gen(function* () {
1142
+ const calls =
1143
+ options.toolCalls === undefined ? undefined : yield* options.toolCalls(story, startedAt)
1144
+ const confidence =
1145
+ trailer.confidence === undefined ? {} : { confidence: trailer.confidence }
1146
+ if (calls === undefined) {
1147
+ yield* laneOf(story).publish(
1148
+ EvidenceChecked.make({
1149
+ task: task.title,
1150
+ claimed: trailer.verified.length,
1151
+ unverified: 0,
1152
+ unchecked: true,
1153
+ lane: story.id,
1154
+ ...confidence
1155
+ })
1156
+ )
1157
+ return
1158
+ }
1159
+ const unverified = unverifiedClaims(trailer.verified, calls)
1160
+ yield* laneOf(story).publish(
1161
+ EvidenceChecked.make({
1162
+ task: task.title,
1163
+ claimed: trailer.verified.length,
1164
+ unverified: unverified.length,
1165
+ lane: story.id,
1166
+ ...confidence
1167
+ })
1168
+ )
1169
+ if (unverified.length > 0) {
1170
+ evidenceNotes.push(
1171
+ `task "${task.title}": claimed ${unverified.map((command) => `\`${command}\``).join(", ")} — no tool call ran it`
1172
+ )
1173
+ yield* appendFindings(
1174
+ story,
1175
+ `evidence "${task.title}" — ${unverified.length} claimed command(s) never ran`,
1176
+ ReviewResult.make({ issues: fabricatedStatusIssues(unverified), summary: "" })
1177
+ )
1178
+ }
1179
+ if (trailer.confidence === "low") {
1180
+ evidenceNotes.push(`task "${task.title}": the coder said confidence: low`)
1181
+ }
1182
+ })
982
1183
  const prompt = storyPrompt(story)
983
1184
 
984
1185
  // Catch up before setup: the epic may have changed the manifest too.
@@ -1024,11 +1225,65 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1024
1225
 
1025
1226
  // The target's gates plus the perimeter: a stray path is a gate failure
1026
1227
  // the task review loop hands back to the coder before anything commits.
1228
+ // The epic head this worktree started from: what the story may inherit.
1229
+ const storyBase = yield* git.checkpoint
1230
+ const storyBaseline = ensureBaseline(
1231
+ storyBase,
1232
+ options.gates(state.worktree, laneOf(story)),
1233
+ state.worktree
1234
+ )
1235
+ // The oracle guard over the story's whole change against the epic:
1236
+ // committed on the branch and uncommitted in the worktree (ADR 0027).
1237
+ const oracleGate = (target: ReviewResult): Effect.Effect<ReviewResult, FlowError> =>
1238
+ story.testsChange
1239
+ ? Effect.succeed(ReviewResult.make({ issues: [] }))
1240
+ : Effect.gen(function* () {
1241
+ const committed = yield* git.diffVsBase(epicBranch)
1242
+ const uncommitted = yield* git.diffAll
1243
+ const base =
1244
+ gateCommands === undefined ? undefined : (yield* storyBaseline)?.passedCount
1245
+ const issues = checkOracle(
1246
+ parseUnifiedDiff(`${committed}\n${uncommitted}`),
1247
+ { base, current: target.passed },
1248
+ options.oracleRules ?? defaultOracleRules,
1249
+ false
1250
+ )
1251
+ return ReviewResult.make({ issues, summary: issues.length === 0 ? "" : "oracle guard" })
1252
+ })
1027
1253
  const gates: Effect.Effect<ReviewResult, FlowError> = Effect.gen(function* () {
1028
- const target = yield* options.gates(state.worktree, laneOf(story))
1254
+ const target = yield* options.gates(state.worktree, laneOf(story), {
1255
+ files,
1256
+ dir: storyGateLogDir(options.stateDir, story.id)
1257
+ })
1029
1258
  const changed = yield* perimeterNow(story, git)
1030
- return combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story))
1259
+ return combined(
1260
+ combined(target, perimeterGate([...changed.sharedReadOnly, ...changed.outside], story)),
1261
+ yield* oracleGate(target)
1262
+ )
1031
1263
  })
1264
+ const triage: GateTriageOptions = {
1265
+ baseline: storyBaseline,
1266
+ roots: rootsOf(state.worktree),
1267
+ ...(options.testGate === undefined
1268
+ ? {}
1269
+ : { rerunTest: options.testGate(state.worktree, laneOf(story)) })
1270
+ }
1271
+ if (gateCommands !== undefined) {
1272
+ const inherited = (yield* storyBaseline)?.failingLines ?? []
1273
+ if (inherited.length > 0) {
1274
+ inheritedByStory.set(story.id, inherited)
1275
+ yield* appendFindings(
1276
+ story,
1277
+ "gate failures inherited from the base (not charged to this story)",
1278
+ ReviewResult.make({
1279
+ issues: inherited.map((line) =>
1280
+ ReviewIssue.make({ severity: "Info", title: line, description: "", origin: "base" })
1281
+ ),
1282
+ summary: ""
1283
+ })
1284
+ )
1285
+ }
1286
+ }
1032
1287
 
1033
1288
  const coderTurn = (text: string): Effect.Effect<string, FlowError> =>
1034
1289
  Effect.flatMap(makeChat(storyContext.coder, { system, events, agent: "coder" }), (chat) =>
@@ -1037,7 +1292,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1037
1292
 
1038
1293
  const commitGreen = (message: string, why: string): Effect.Effect<void, FlowError> =>
1039
1294
  Effect.gen(function* () {
1040
- const regated = yield* gates
1295
+ const reported = yield* Ref.make<ReadonlySet<string>>(new Set())
1296
+ const regated = yield* applyTriage(yield* gates, triage, laneOf(story), reported)
1041
1297
  if (!regated.isClean) {
1042
1298
  return yield* failed(story, `gates broke ${why}:\n${issueLines(regated)}`)
1043
1299
  }
@@ -1171,6 +1427,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1171
1427
  },
1172
1428
  checkoutBranch: false,
1173
1429
  lint: gates,
1430
+ triage,
1431
+ ...(options.fix === undefined ? {} : { fix: options.fix }),
1174
1432
  // A story's final state is judged and gated downstream (judge round,
1175
1433
  // perimeter check, epic gates), so a task the coder finds already
1176
1434
  // satisfied — without saying the exact sentinel — must not sink the
@@ -1188,6 +1446,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1188
1446
  }${settled ? "" : ", fixing"}`,
1189
1447
  result
1190
1448
  ),
1449
+ onTaskReply: (task, _reply, trailer, startedAt) => checkEvidence(task, trailer, startedAt),
1191
1450
  ...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
1192
1451
  ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
1193
1452
  })
@@ -1239,7 +1498,19 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1239
1498
  // be on the epic (an earlier story or run put it there). Judge that
1240
1499
  // code instead: the owned paths against the empty tree.
1241
1500
  const empty = diff.trim().length === 0
1242
- const subject = empty ? yield* git.diffVsBaseScoped(emptyTree, story.owned, false) : diff
1501
+ const judgedCode = empty ? yield* git.diffVsBaseScoped(emptyTree, story.owned, false) : diff
1502
+ // What the coder claimed and could not show travels with the subject
1503
+ // (ADR 0027 decision 6): the judge weighs the code knowing the
1504
+ // story's own account of its testing is not evidence.
1505
+ const subject =
1506
+ evidenceNotes.length === 0
1507
+ ? judgedCode
1508
+ : [
1509
+ "Evidence notes from the run (not part of the diff):",
1510
+ ...evidenceNotes.map((note) => `- ${note}`),
1511
+ "",
1512
+ judgedCode
1513
+ ].join("\n")
1243
1514
  // The verdict is kept beside a fingerprint of what was judged: a
1244
1515
  // rerun that finds the same diff gets the same answer without a
1245
1516
  // model call, and the last verdict is on disk to read. Only the
@@ -1406,6 +1677,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1406
1677
  ...(last === undefined ? {} : { executor: last })
1407
1678
  })
1408
1679
  )
1680
+ const inherited = inheritedByStory.get(story.id)
1409
1681
  return StoryOutcome.make({
1410
1682
  id: story.id,
1411
1683
  title: story.title,
@@ -1414,7 +1686,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1414
1686
  ...(result.judge === undefined ? {} : { judge: result.judge }),
1415
1687
  ...(result.executor === undefined ? {} : { executor: result.executor }),
1416
1688
  ...(result.totals === undefined ? {} : { estimatedTokens: result.totals.total }),
1417
- ...(result.totals?.costUsd === undefined ? {} : { estimatedCostUsd: result.totals.costUsd })
1689
+ ...(result.totals?.costUsd === undefined ? {} : { estimatedCostUsd: result.totals.costUsd }),
1690
+ ...(inherited === undefined ? {} : { inherited })
1418
1691
  })
1419
1692
  })
1420
1693
 
package/src/StoryPlan.ts CHANGED
@@ -44,6 +44,15 @@ export class Story extends Schema.Class<Story>("Story")({
44
44
  acceptance: Schema.Array(Schema.String).pipe(
45
45
  Schema.withConstructorDefault(Effect.succeed([])),
46
46
  Schema.withDecodingDefaultKey(Effect.succeed([]))
47
+ ),
48
+ /**
49
+ * The story may delete, skip or rewrite tests (ADR 0027): the oracle guard
50
+ * stays silent for it. Absent or false, a deleted test file, an added skip
51
+ * marker or a passed-count drop is a gate failure.
52
+ */
53
+ testsChange: Schema.Boolean.pipe(
54
+ Schema.withConstructorDefault(Effect.succeed(false)),
55
+ Schema.withDecodingDefaultKey(Effect.succeed(false))
47
56
  )
48
57
  }) {}
49
58
 
@@ -366,7 +375,8 @@ export const storyHash = (story: Story): string =>
366
375
  ...(story.readFirst.length === 0
367
376
  ? {}
368
377
  : { readFirst: [...story.readFirst].map(normalizePath) }),
369
- ...(story.acceptance.length === 0 ? {} : { acceptance: [...story.acceptance] })
378
+ ...(story.acceptance.length === 0 ? {} : { acceptance: [...story.acceptance] }),
379
+ ...(story.testsChange ? { testsChange: true } : {})
370
380
  })
371
381
  )
372
382