@llm4ts/flow 2.37.0 → 2.39.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 (56) hide show
  1. package/dist/Domains.d.ts +1 -1
  2. package/dist/Domains.d.ts.map +1 -1
  3. package/dist/Domains.js +7 -3
  4. package/dist/Domains.js.map +1 -1
  5. package/dist/Flow.d.ts +3 -1
  6. package/dist/Flow.d.ts.map +1 -1
  7. package/dist/Flow.js +1 -0
  8. package/dist/Flow.js.map +1 -1
  9. package/dist/GraphCache.d.ts +70 -0
  10. package/dist/GraphCache.d.ts.map +1 -0
  11. package/dist/GraphCache.js +111 -0
  12. package/dist/GraphCache.js.map +1 -0
  13. package/dist/GraphQuery.d.ts +67 -0
  14. package/dist/GraphQuery.d.ts.map +1 -0
  15. package/dist/GraphQuery.js +212 -0
  16. package/dist/GraphQuery.js.map +1 -0
  17. package/dist/GraphRender.d.ts +26 -0
  18. package/dist/GraphRender.d.ts.map +1 -0
  19. package/dist/GraphRender.js +154 -0
  20. package/dist/GraphRender.js.map +1 -0
  21. package/dist/GraphRules.d.ts +86 -0
  22. package/dist/GraphRules.d.ts.map +1 -0
  23. package/dist/GraphRules.js +246 -0
  24. package/dist/GraphRules.js.map +1 -0
  25. package/dist/GraphWorklist.d.ts +64 -0
  26. package/dist/GraphWorklist.d.ts.map +1 -0
  27. package/dist/GraphWorklist.js +283 -0
  28. package/dist/GraphWorklist.js.map +1 -0
  29. package/dist/Pack.d.ts +7 -0
  30. package/dist/Pack.d.ts.map +1 -1
  31. package/dist/Pack.js +9 -5
  32. package/dist/Pack.js.map +1 -1
  33. package/dist/Review.d.ts +12 -0
  34. package/dist/Review.d.ts.map +1 -1
  35. package/dist/Review.js +11 -1
  36. package/dist/Review.js.map +1 -1
  37. package/dist/Stories.d.ts +16 -0
  38. package/dist/Stories.d.ts.map +1 -1
  39. package/dist/Stories.js +42 -7
  40. package/dist/Stories.js.map +1 -1
  41. package/dist/Survey.d.ts +112 -3
  42. package/dist/Survey.d.ts.map +1 -1
  43. package/dist/Survey.js +644 -41
  44. package/dist/Survey.js.map +1 -1
  45. package/package.json +7 -2
  46. package/src/Domains.ts +7 -4
  47. package/src/Flow.ts +5 -1
  48. package/src/GraphCache.ts +191 -0
  49. package/src/GraphQuery.ts +315 -0
  50. package/src/GraphRender.ts +208 -0
  51. package/src/GraphRules.ts +293 -0
  52. package/src/GraphWorklist.ts +405 -0
  53. package/src/Pack.ts +15 -5
  54. package/src/Review.ts +25 -1
  55. package/src/Stories.ts +66 -12
  56. package/src/Survey.ts +846 -52
package/src/Pack.ts CHANGED
@@ -4,6 +4,7 @@ import * as Schema from "effect/Schema"
4
4
  import { Dimension } from "@llm4ts/core/eval/Eval"
5
5
  import type { ConsolidateRules } from "./Domains.ts"
6
6
  import { PlanParseError } from "./FlowError.ts"
7
+ import { edgeKindsOf, parseGraphRules, type GraphRules } from "./GraphRules.ts"
7
8
  import { oracleRulesFrom, type OracleRules } from "./OracleGuard.ts"
8
9
  import { Reviewer, parseReviewer } from "./Reviewer.ts"
9
10
  import { CoverageRule } from "./SpecChecks.ts"
@@ -37,6 +38,12 @@ export interface Pack {
37
38
  readonly judgeDimensions: ReadonlyArray<Dimension>
38
39
  readonly coverage: ReadonlyArray<CoverageRule>
39
40
  readonly survey: ReadonlyArray<CoverageRule>
41
+ /**
42
+ * The graph vocabulary (ADR 0030): `## Node:`, `## Edge:` (with every
43
+ * `## Survey:` folded in as a file-to-file edge), `## Join:`, `## Probe:`
44
+ * and the `## Graph` budgets.
45
+ */
46
+ readonly graph: GraphRules
40
47
  readonly prompts: Readonly<Record<string, string>>
41
48
  readonly lenses: ReadonlyArray<Reviewer>
42
49
  readonly lessons: string | undefined
@@ -391,16 +398,18 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
391
398
  cluster: commaList(consolidateValues.cluster),
392
399
  context: commaList(consolidateValues.context)
393
400
  }
401
+ const survey = rules(manifest.sections, "## Survey: ")
402
+ const graph = yield* parseGraphRules(manifest.sections, survey)
394
403
  if (consolidate !== undefined) {
395
- const surveyNames = new Set(rules(manifest.sections, "## Survey: ").map((rule) => rule.name))
404
+ const knownKinds = new Set([...survey.map((rule) => rule.name), ...edgeKindsOf(graph)])
396
405
  const unknown = [...consolidate.cluster, ...consolidate.context].filter(
397
- (kind) => !surveyNames.has(kind) && !kind.startsWith("llm-") && !kind.endsWith("*")
406
+ (kind) => !knownKinds.has(kind) && !kind.startsWith("llm-") && !kind.endsWith("*")
398
407
  )
399
408
  if (unknown.length > 0) {
400
409
  return yield* PlanParseError.make({
401
410
  message:
402
- `pack manifest '## Consolidate' names edge kinds no '## Survey:' rule produces: ${unknown.join(", ")} ` +
403
- `(known: ${[...surveyNames].join(", ") || "none"}; 'llm-*' matches refined edges)`
411
+ `pack manifest '## Consolidate' names edge kinds no '## Survey:', '## Edge:' or '## Join:' rule produces: ${unknown.join(", ")} ` +
412
+ `(known: ${[...knownKinds].join(", ") || "none"}; 'llm-*' matches refined edges)`
404
413
  })
405
414
  }
406
415
  const both = consolidate.cluster.filter((kind) => consolidate.context.includes(kind))
@@ -438,7 +447,8 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
438
447
  }),
439
448
  judgeDimensions: dimensions(section(manifest.sections, "Judge")),
440
449
  coverage: rules(manifest.sections, "## Coverage: "),
441
- survey: rules(manifest.sections, "## Survey: "),
450
+ survey,
451
+ graph,
442
452
  prompts,
443
453
  lenses,
444
454
  lessons: lessons === undefined || lessons.length === 0 ? undefined : lessons,
package/src/Review.ts CHANGED
@@ -735,8 +735,24 @@ export interface ReviewAndFixOptions {
735
735
  readonly fixWith?: (prompt: string) => Effect.Effect<string, FlowError>
736
736
  /** End the task, typed `Stalled`, when a fix round leaves the diff byte-identical (ADR 0027 decision 11). */
737
737
  readonly stallOnIdenticalDiff?: boolean
738
+ /**
739
+ * When a round settles before `maxRounds` (ADR 0031): "clean" (default)
740
+ * when it has no findings; "blocking" also when its findings are all
741
+ * non-blocking. A red gate is always blocking.
742
+ */
743
+ readonly settle?: SettleRule
738
744
  }
739
745
 
746
+ /** When a review round stops sending findings back to the coder (ADR 0031). */
747
+ export type SettleRule = "clean" | "blocking"
748
+
749
+ /** A finding that must be fixed before the work moves on: Critical (ADR 0031). */
750
+ export const isBlocking = (issue: ReviewIssue): boolean => issue.severity === "Critical"
751
+
752
+ /** The findings that may wait: everything not blocking (ADR 0031). */
753
+ export const nonBlockingIssues = (result: ReviewResult): ReadonlyArray<ReviewIssue> =>
754
+ result.issues.filter((issue) => !isBlocking(issue))
755
+
740
756
  export interface ReviewCacheLocation {
741
757
  readonly files: PlainFileStoreShape
742
758
  readonly dir: string
@@ -881,6 +897,10 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
881
897
  const reported = yield* Ref.make<ReadonlySet<string>>(new Set())
882
898
  const oracleNoted = yield* Ref.make(false)
883
899
 
900
+ const settle = options.settle ?? "clean"
901
+ // A red gate is never settled early, whatever its findings' severities.
902
+ const gateRed = yield* Ref.make(false)
903
+
884
904
  const reviewOnce = (
885
905
  round: number,
886
906
  previous: ReviewResult | undefined
@@ -893,6 +913,7 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
893
913
  options.events,
894
914
  oracleNoted
895
915
  )
916
+ yield* Ref.set(gateRed, !lint.isClean)
896
917
  if (!lint.isClean) {
897
918
  return lint
898
919
  }
@@ -1017,7 +1038,10 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
1017
1038
  }
1018
1039
  yield* Ref.set(lastDiff, diffNow)
1019
1040
  const result = yield* reviewOnce(round, previous)
1020
- const settled = result.isClean || round >= maxRounds
1041
+ const settled =
1042
+ result.isClean ||
1043
+ round >= maxRounds ||
1044
+ (settle === "blocking" && !(yield* Ref.get(gateRed)) && !result.issues.some(isBlocking))
1021
1045
  yield* options.events.publish(
1022
1046
  ReviewFindings.make({
1023
1047
  round,
package/src/Stories.ts CHANGED
@@ -74,7 +74,9 @@ import {
74
74
  ReviewIssue,
75
75
  ReviewResult,
76
76
  applyTriage,
77
+ isBlocking,
77
78
  loadRepoReviewRules,
79
+ nonBlockingIssues,
78
80
  type FixPromptOptions,
79
81
  type GateTriageOptions
80
82
  } from "./Review.ts"
@@ -722,6 +724,14 @@ export interface StoriesOptions {
722
724
  readonly failFast?: boolean
723
725
  readonly reviewers?: ReadonlyArray<Reviewer>
724
726
  readonly maxRounds?: number
727
+ /**
728
+ * Defer what does not block (ADR 0031): a task's review settles once its
729
+ * findings are all non-blocking, the judge clears a story whose findings
730
+ * are all non-blocking, and what is left is written to
731
+ * `stories/<id>.deferred.md` (`deferredPath`) when the story finishes,
732
+ * for a follow-up round. Default false: every finding is fixed or fails.
733
+ */
734
+ readonly deferNonBlocking?: boolean
725
735
  /**
726
736
  * Second opinion on a BLOCKED_ON claim the plan cannot settle (a path no
727
737
  * story owns, or a parallel story's). `real: false` sends the coder back
@@ -762,6 +772,24 @@ const combined = (first: ReviewResult, second: ReviewResult): ReviewResult =>
762
772
  summary: [first.summary, second.summary].filter((part) => part.length > 0).join("; ")
763
773
  })
764
774
 
775
+ /**
776
+ * Where a finished story's deferred findings are kept (ADR 0031): one
777
+ * Markdown list per story under the executor's state directory, absent when
778
+ * nothing was deferred.
779
+ */
780
+ export const deferredPath = (stateDir: string, storyId: string): string =>
781
+ join(stateDir, `stories/${storyId}.deferred.md`)
782
+
783
+ /** A deferred finding as one Markdown bullet, prefixed with where it was found. */
784
+ export const deferredLine = (source: string, issue: ReviewIssue): string => {
785
+ const where =
786
+ issue.file === undefined
787
+ ? ""
788
+ : ` (${issue.file}${issue.line === undefined ? "" : `:${issue.line}`})`
789
+ const detail = issue.description.trim().length === 0 ? "" : `: ${issue.description.trim()}`
790
+ return `- [${issue.severity}] ${source} — ${issue.title}${where}${detail}`
791
+ }
792
+
765
793
  const issueLines = (result: ReviewResult): string =>
766
794
  result.issues.map((issue) => `- ${issue.title}: ${issue.description}`).join("\n")
767
795
 
@@ -843,6 +871,7 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
843
871
  const epicBranch = options.epicBranch ?? `epic/${plan.epicId}`
844
872
  const concurrency = Math.max(1, options.concurrency ?? 3)
845
873
  const judgeRounds = Math.max(1, options.judgeRounds ?? 2)
874
+ const deferring = options.deferNonBlocking === true
846
875
  const statePath = (story: Story): string => join(options.stateDir, `stories/${story.id}.json`)
847
876
  const planPath = (story: Story): string => join(options.stateDir, `stories/${story.id}.plan.md`)
848
877
  /** The judge's last verdict beside a fingerprint of what it judged. */
@@ -1196,6 +1225,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1196
1225
  )
1197
1226
  /** What the coder claimed to have run and did not, task by task, for the judge. */
1198
1227
  const evidenceNotes: Array<string> = []
1228
+ /** Findings left for a follow-up round (ADR 0031), written when the story finishes. */
1229
+ const deferred: Array<string> = []
1199
1230
  const checkEvidence = (
1200
1231
  task: Task,
1201
1232
  trailer: Trailer,
@@ -1513,13 +1544,21 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1513
1544
  // the story's findings log for people to read.
1514
1545
  reviewCache: { files, dir: reviewCacheDir(story) },
1515
1546
  onReview: (task, round, result, settled) =>
1516
- appendFindings(
1517
- story,
1518
- `review "${task.title}" round ${round} — ${
1519
- result.isClean ? "clean" : `${result.issues.length} issue(s)`
1520
- }${settled ? "" : ", fixing"}`,
1521
- result
1522
- ),
1547
+ Effect.gen(function* () {
1548
+ // A settled round's leftovers are deferred, not dropped: when the
1549
+ // rounds ran out, blocking ones too, since the task commits anyway.
1550
+ const left = settled && deferring ? result.issues : []
1551
+ for (const issue of left) {
1552
+ deferred.push(deferredLine(`review "${task.title}"`, issue))
1553
+ }
1554
+ yield* appendFindings(
1555
+ story,
1556
+ `review "${task.title}" round ${round} — ${
1557
+ result.isClean ? "clean" : `${result.issues.length} issue(s)`
1558
+ }${settled ? (left.length === 0 ? "" : ", deferred") : ", fixing"}`,
1559
+ result
1560
+ )
1561
+ }),
1523
1562
  onTaskReply: (task, _reply, trailer, startedAt) => checkEvidence(task, trailer, startedAt),
1524
1563
  // The repository's own review rules ride along as one extra lens
1525
1564
  // (ADR 0027 decision 9), read from the epic checkout.
@@ -1528,7 +1567,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1528
1567
  ...(options.fixer === undefined ? {} : { fixer: options.fixer }),
1529
1568
  ...(options.stall === undefined ? {} : { stall: options.stall }),
1530
1569
  ...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
1531
- ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
1570
+ ...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds }),
1571
+ ...(deferring ? { settle: "blocking" as const } : {})
1532
1572
  })
1533
1573
  yield* guarded(implementTasks, implementTasks)
1534
1574
 
@@ -1684,11 +1724,17 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1684
1724
  dimensions: verdict instanceof StoryVerdict ? verdict.dimensions : []
1685
1725
  })
1686
1726
  )
1687
- if (verdict.isClean) {
1727
+ // Deferring, a verdict with nothing blocking clears: what it found
1728
+ // waits for the follow-up round instead of another revision.
1729
+ if (verdict.isClean || (deferring && !verdict.issues.some(isBlocking))) {
1730
+ const waiting = nonBlockingIssues(verdict)
1731
+ for (const issue of waiting) {
1732
+ deferred.push(deferredLine(`judge round ${round}`, issue))
1733
+ }
1688
1734
  inPlace = empty
1689
- judgeNote = empty
1690
- ? `verified already in place (round ${round})`
1691
- : `judge cleared (round ${round})`
1735
+ judgeNote = `${empty ? "verified already in place" : "judge cleared"} (round ${round}${
1736
+ waiting.length === 0 ? "" : `, ${waiting.length} finding(s) deferred`
1737
+ })`
1692
1738
  break
1693
1739
  }
1694
1740
  if (round >= judgeRounds) {
@@ -1709,6 +1755,14 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
1709
1755
 
1710
1756
  const changed = yield* git.changedFilesVsBase(epicBranch)
1711
1757
  yield* enforcePerimeter(changed, story)
1758
+ if (deferring) {
1759
+ yield* deferred.length === 0
1760
+ ? files.remove(deferredPath(options.stateDir, story.id))
1761
+ : files.writeAtomic(
1762
+ deferredPath(options.stateDir, story.id),
1763
+ [`# Deferred from ${story.id}: ${story.title}`, "", ...deferred, ""].join("\n")
1764
+ )
1765
+ }
1712
1766
  const totals = seats.totals === undefined ? undefined : yield* seats.totals
1713
1767
  const history = roster === undefined ? [] : yield* roster.history
1714
1768
  return {