@llm4ts/shell 0.6.0 → 0.6.1

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.
@@ -0,0 +1,249 @@
1
+ // Legacy modernization phase 3: implement the seeded plan behind the pack's gates.
2
+ //
3
+ // Runs rooted at the TARGET repository (`--repo <target>`), from the specs
4
+ // alone. The clean-room wall is ENFORCED, not advised: the flow refuses to
5
+ // start when anything matching the pack's legacy `sources:` regex sits in the
6
+ // target workspace, so the coder provably never reads legacy source.
7
+ //
8
+ // Per plan task: a shared chat implements it, the pack's reviewer lenses plus
9
+ // the minimal roster review the diff behind a gate (build for the first,
10
+ // tests-encoding task; test for the rest), and the task is committed. The
11
+ // first task must leave the acceptance tests RED — tests that pass before any
12
+ // implementation encode nothing, so the flow aborts. Pattern cards cited by
13
+ // the seeded specs are injected into the coder's brief as an advisory
14
+ // translation playbook (the specs still win).
15
+ //
16
+ // After the loop the verify gate must be green, then a spec-compliance judge
17
+ // scores the whole branch against the committed specs and feeds sub-bar
18
+ // reasoning back to the coder, bounded by LLM4TS_JUDGE_ROUNDS (default 2).
19
+ //
20
+ // Run: modernize-implement --repo ~/services/meridian-transfers
21
+ import { join } from "node:path";
22
+ import * as Effect from "effect/Effect";
23
+ import { Dimension, Sample } from "@llm4ts/core/eval/Eval";
24
+ import { judge } from "@llm4ts/core/eval/Judge";
25
+ import { makeChat } from "@llm4ts/flow/Chat";
26
+ import { FlowAborted, FlowLlmError } from "@llm4ts/flow/FlowError";
27
+ import { Info } from "@llm4ts/flow/FlowEvents";
28
+ import { loadPatternCards, taggedPatternIds } from "@llm4ts/flow/Patterns";
29
+ import { makePlanStore } from "@llm4ts/flow/Persistence";
30
+ import { implementTaskLoop, stage } from "@llm4ts/flow/PlanExecution";
31
+ import { lintCommand, minimalReviewers, reviewAndFixLoop } from "@llm4ts/flow/Review";
32
+ import { checkWall, wallBreachMessage } from "@llm4ts/flow/Wall";
33
+ import { asReadOnly, coderFromEnv } from "@llm4ts/runner/Connectors";
34
+ import { resolveFlowInput } from "@llm4ts/runner/FlowArgs";
35
+ import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner";
36
+ import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore";
37
+ import { nodeProcessExecutor } from "@llm4ts/runner/NodeProcessExecutor";
38
+ import { makeNodeWorkspace } from "@llm4ts/runner/NodeWorkspace";
39
+ import { loadUniversalPatternCards, openPack } from "@llm4ts/runner/Packs";
40
+ const ModDir = "docs/modernization";
41
+ const judgeRounds = () => {
42
+ const raw = Number.parseInt(process.env.LLM4TS_JUDGE_ROUNDS ?? "", 10);
43
+ return Number.isFinite(raw) && raw > 0 ? raw : 2;
44
+ };
45
+ const complianceDimensions = [
46
+ Dimension.make({
47
+ name: "spec-compliance",
48
+ rubric: "Does the implementation satisfy every rule in the committed specs — exact values, " +
49
+ "validation order, error paths — without weakening, deleting, or loosening any test or scenario?"
50
+ }),
51
+ Dimension.make({
52
+ name: "scenario-coverage",
53
+ rubric: "Is every BDD scenario in the seeded feature files exercised by an acceptance test in this diff?"
54
+ })
55
+ ];
56
+ /** Concatenates the committed specs — the judge's contract text. */
57
+ const gatherSpecs = Effect.fn("modernize-implement.gatherSpecs")(function* (target, specsDir) {
58
+ const paths = yield* target.discover(`${specsDir}/**`).pipe(Effect.orElseSucceed(() => []));
59
+ const parts = [];
60
+ for (const path of [...paths].sort()) {
61
+ const text = yield* target.read(path).pipe(Effect.orElseSucceed(() => ""));
62
+ if (text.trim().length > 0) {
63
+ parts.push(`===== ${path} =====\n${text}`);
64
+ }
65
+ }
66
+ return parts.join("\n\n");
67
+ });
68
+ const issueText = (issues) => issues.map((issue) => `${issue.title}\n${issue.description}`.trim()).join("\n\n");
69
+ const program = Effect.gen(function* () {
70
+ const input = yield* resolveFlowInput("Implement the seeded modernization plan");
71
+ const coder = coderFromEnv(process.env);
72
+ const files = nodePlainFileStore;
73
+ const planPath = join(input.workDir, ModDir, "plan.md");
74
+ yield* runNode({
75
+ workDir: input.workDir,
76
+ workspace: input.workspace,
77
+ userPrompt: input.prompt,
78
+ coder,
79
+ reasoning: asReadOnly(coder),
80
+ reviewers: [asReadOnly(coder)],
81
+ environment: process.env
82
+ }, (context) => Effect.gen(function* () {
83
+ const target = yield* makeNodeWorkspace(input.workDir);
84
+ const opened = yield* stage(context.events, "pack", openPack({
85
+ environment: process.env,
86
+ launchDir: input.workspace,
87
+ flowDir: import.meta.dirname
88
+ }));
89
+ const pack = opened.pack;
90
+ yield* stage(context.events, "wall", Effect.gen(function* () {
91
+ if (pack.sources === undefined) {
92
+ return yield* context.events.publish(Info.make({ message: "pack has no sources regex — wall check skipped" }));
93
+ }
94
+ const result = yield* checkWall(target, pack.sources);
95
+ if (result._tag === "Breached") {
96
+ return yield* FlowAborted.make({
97
+ message: wallBreachMessage(result, "The implementation must be driven by the specs alone; remove the files and rerun.")
98
+ });
99
+ }
100
+ yield* context.events.publish(Info.make({ message: "clean-room wall: no legacy source in the target workspace" }));
101
+ }));
102
+ const store = makePlanStore(files);
103
+ const plan = yield* store.load(planPath);
104
+ if (plan === undefined) {
105
+ return yield* FlowAborted.make({
106
+ message: `no plan at ${planPath} — run modernize-seed first`
107
+ });
108
+ }
109
+ const gate = (name) => {
110
+ const command = pack.gate(name);
111
+ return command === undefined
112
+ ? undefined
113
+ : lintCommand(nodeProcessExecutor, context.events, command, input.workDir);
114
+ };
115
+ const buildGate = gate("build");
116
+ const testGate = gate("test");
117
+ const verifyGate = gate("verify") ?? testGate;
118
+ yield* stage(context.events, "branch", context.git.checkoutOrCreate(plan.epicId).pipe(Effect.asVoid));
119
+ // Pattern selection is deterministic: extraction tagged each program's
120
+ // fragment with the cards its SOURCE matched, the specs carry those
121
+ // ids, and only the cited cards reach the brief.
122
+ const specText = yield* gatherSpecs(target, pack.specsDir);
123
+ const cards = [
124
+ ...(yield* loadPatternCards(opened.workspace, `${opened.dir}/patterns`)),
125
+ ...(yield* loadUniversalPatternCards([input.workspace, import.meta.dirname]))
126
+ ];
127
+ const cited = new Set(taggedPatternIds(specText));
128
+ const playbook = cards.filter((card) => cited.has(card.id));
129
+ const system = [
130
+ pack.prompt("implement"),
131
+ pack.lessons === undefined
132
+ ? undefined
133
+ : `Lessons from previous modernization runs — apply them:\n${pack.lessons}`,
134
+ playbook.length === 0
135
+ ? undefined
136
+ : "Pattern cards cited by the specs — the translation playbook (advisory, the specs win):\n\n" +
137
+ playbook.map((card) => `### ${card.id}\n${card.body}`).join("\n\n")
138
+ ]
139
+ .filter((part) => part !== undefined)
140
+ .join("\n\n");
141
+ if (playbook.length > 0) {
142
+ yield* context.events.publish(Info.make({ message: `${playbook.length} pattern card(s) cited by the specs` }));
143
+ }
144
+ const coderChat = yield* makeChat(context.coder, { system });
145
+ const firstTitle = plan.tasks[0]?.title;
146
+ yield* implementTaskLoop(store, context.events, planPath, plan, (task) => Effect.gen(function* () {
147
+ const testsTask = task.title === firstTitle;
148
+ yield* coderChat.ask(plan.taskPrompt(task));
149
+ yield* reviewAndFixLoop({
150
+ reviewers: [...minimalReviewers, ...pack.lenses],
151
+ reviewerService: context.reviewers[0] ?? context.reasoning,
152
+ coder: coderChat,
153
+ taskTitle: task.title,
154
+ currentDiff: context.git.diffAll,
155
+ changedFiles: context.git.defaultBase.pipe(Effect.flatMap((base) => context.git.changedFilesVsBase(base))),
156
+ events: context.events,
157
+ ...(testsTask
158
+ ? buildGate === undefined
159
+ ? {}
160
+ : { lint: buildGate }
161
+ : testGate === undefined
162
+ ? {}
163
+ : { lint: testGate }),
164
+ parallelism: 1
165
+ });
166
+ if (testsTask && testGate !== undefined) {
167
+ const red = yield* testGate;
168
+ if (red.isClean) {
169
+ return yield* FlowAborted.make({
170
+ message: "the new acceptance tests pass before any implementation — they encode nothing"
171
+ });
172
+ }
173
+ }
174
+ yield* context.git.commitAll(`${plan.epicId}: ${task.title}`).pipe(Effect.asVoid);
175
+ }));
176
+ // The task loop marks each task complete AFTER its per-task commit, so
177
+ // the final task's plan update would otherwise be left uncommitted.
178
+ // A no-op when the loop already committed everything.
179
+ yield* context.git.commitAll(`${plan.epicId}: plan state`).pipe(Effect.asVoid);
180
+ if (verifyGate !== undefined) {
181
+ yield* stage(context.events, "verify", Effect.gen(function* () {
182
+ const result = yield* verifyGate;
183
+ if (!result.isClean) {
184
+ return yield* FlowAborted.make({
185
+ message: `verify gate failed:\n${issueText(result.issues)}`
186
+ });
187
+ }
188
+ }));
189
+ }
190
+ // The branch-level judge: bounded rounds of feedback, each re-gated and
191
+ // committed, failing the flow if the bar is never cleared.
192
+ yield* stage(context.events, "judge", Effect.gen(function* () {
193
+ const contractText = yield* gatherSpecs(target, pack.specsDir);
194
+ const complianceJudge = judge(context.reasoning, complianceDimensions);
195
+ const rounds = judgeRounds();
196
+ for (let round = 1; round <= rounds; round += 1) {
197
+ const base = yield* context.git.defaultBase;
198
+ const diff = yield* context.git.diffVsBase(base);
199
+ const scored = yield* complianceJudge
200
+ .evaluate(Sample.make({
201
+ response: diff,
202
+ context: contractText,
203
+ query: input.prompt
204
+ }))
205
+ .pipe(Effect.mapError(FlowLlmError.from));
206
+ const below = scored.scores.filter((score) => {
207
+ const max = complianceDimensions.find((d) => d.name === score.name)?.maxScore ?? 2;
208
+ return score.score < max;
209
+ });
210
+ if (below.length === 0) {
211
+ return yield* context.events.publish(Info.make({ message: "spec-compliance judge: branch cleared the bar" }));
212
+ }
213
+ if (round >= rounds) {
214
+ return yield* FlowAborted.make({
215
+ message: `spec-compliance judge not cleared after ${rounds} round(s):\n` +
216
+ below.map((d) => `- ${d.name} ${d.score}: ${d.reasoning}`).join("\n")
217
+ });
218
+ }
219
+ yield* coderChat.ask([
220
+ "The final spec-compliance review scored the branch below the bar. Close these gaps",
221
+ "without weakening any test, then stop:",
222
+ ...below.map((d) => `- ${d.name} (${d.score}): ${d.reasoning}`)
223
+ ].join("\n"));
224
+ if (verifyGate !== undefined) {
225
+ const regated = yield* verifyGate;
226
+ if (!regated.isClean) {
227
+ return yield* FlowAborted.make({
228
+ message: "verify gate broke while addressing judge feedback"
229
+ });
230
+ }
231
+ }
232
+ yield* context.git
233
+ .commitAll(`${plan.epicId}: address spec-compliance feedback`)
234
+ .pipe(Effect.asVoid);
235
+ }
236
+ }));
237
+ // Publishing is best-effort: a repository with no remote or forge is a
238
+ // normal local run, not a failure.
239
+ yield* stage(context.events, "publish", Effect.gen(function* () {
240
+ const base = yield* context.git.defaultBase;
241
+ yield* context.git.push("origin", plan.epicId);
242
+ const pr = yield* context.hosting.createPr(`modernize: ${plan.epicId}`, `Implements the approved spec pack. Plan: ${ModDir}/plan.md — all gates green.`, base);
243
+ yield* context.events.publish(Info.make({ message: `PR: ${pr.url}` }));
244
+ }).pipe(Effect.catch((error) => context.events.publish(Info.make({
245
+ message: `publish skipped (no remote/forge configured): ${error.message}`
246
+ })))));
247
+ }));
248
+ });
249
+ runFlowMain(program);
@@ -0,0 +1,237 @@
1
+ // Legacy modernization phase 5: review the increment against its spec pack and distil lessons.
2
+ //
3
+ // Runs rooted at the TARGET repository (`--repo <target>`) behind the enforced
4
+ // clean-room wall — the review judges spec-driven work only.
5
+ //
6
+ // 1. The full reviewer roster plus the pack's own lenses (filtered to the
7
+ // files this branch touched) review the diff against the committed specs.
8
+ // 2. A spec-compliance judge scores the branch on the same two dimensions
9
+ // modernize-implement gates on.
10
+ // 3. Findings and scores are distilled into: fixes (spec VIOLATIONS, which
11
+ // become fix specs plus plan tasks for another implement pass),
12
+ // improvements (worthwhile but not violations), and lessons — rules of
13
+ // thumb that generalize, appended to the pack's lessons.md so the next
14
+ // estate starts smarter.
15
+ //
16
+ // Run: modernize-review --repo ~/services/meridian-transfers
17
+ import { join } from "node:path";
18
+ import * as Effect from "effect/Effect";
19
+ import * as Schema from "effect/Schema";
20
+ import { Dimension, Sample } from "@llm4ts/core/eval/Eval";
21
+ import { judge } from "@llm4ts/core/eval/Judge";
22
+ import { FlowAborted, FlowLlmError } from "@llm4ts/flow/FlowError";
23
+ import { Info } from "@llm4ts/flow/FlowEvents";
24
+ import { appendPackLesson } from "@llm4ts/flow/Pack";
25
+ import { makePlanStore } from "@llm4ts/flow/Persistence";
26
+ import { Plan, Task } from "@llm4ts/flow/Plan";
27
+ import { stage } from "@llm4ts/flow/PlanExecution";
28
+ import { allReviewers, mergeReviewResults, reviewJsonSchema, reviewPrompt, ReviewResult } from "@llm4ts/flow/Review";
29
+ import { checkWall, wallBreachMessage } from "@llm4ts/flow/Wall";
30
+ import { asReadOnly, coderFromEnv } from "@llm4ts/runner/Connectors";
31
+ import { resolveFlowInput } from "@llm4ts/runner/FlowArgs";
32
+ import { runFlowMain, runNode } from "@llm4ts/runner/FlowRunner";
33
+ import { nodePlainFileStore } from "@llm4ts/runner/NodePlainFileStore";
34
+ import { makeNodeWorkspace } from "@llm4ts/runner/NodeWorkspace";
35
+ import { openPack } from "@llm4ts/runner/Packs";
36
+ const ModDir = "docs/modernization";
37
+ class FixSpec extends Schema.Class("FixSpec")({
38
+ title: Schema.String,
39
+ spec: Schema.String,
40
+ taskTitle: Schema.String,
41
+ taskDescription: Schema.String
42
+ }) {
43
+ }
44
+ class ReviewOutcome extends Schema.Class("ReviewOutcome")({
45
+ fixes: Schema.Array(FixSpec),
46
+ improvements: Schema.Array(FixSpec),
47
+ lessons: Schema.Array(Schema.String)
48
+ }) {
49
+ }
50
+ const fixSpecJsonSchema = {
51
+ type: "object",
52
+ properties: {
53
+ title: { type: "string" },
54
+ spec: { type: "string" },
55
+ taskTitle: { type: "string" },
56
+ taskDescription: { type: "string" }
57
+ },
58
+ required: ["title", "spec", "taskTitle", "taskDescription"]
59
+ };
60
+ const reviewOutcomeJsonSchema = {
61
+ type: "object",
62
+ properties: {
63
+ fixes: { type: "array", items: { ...fixSpecJsonSchema } },
64
+ improvements: { type: "array", items: { ...fixSpecJsonSchema } },
65
+ lessons: { type: "array", items: { type: "string" } }
66
+ },
67
+ required: ["fixes", "improvements", "lessons"]
68
+ };
69
+ const complianceDimensions = [
70
+ Dimension.make({
71
+ name: "spec-compliance",
72
+ rubric: "Does the implementation satisfy every rule in the committed specs — exact values, " +
73
+ "validation order, error paths — without weakening, deleting, or loosening any test or scenario?"
74
+ }),
75
+ Dimension.make({
76
+ name: "scenario-coverage",
77
+ rubric: "Is every BDD scenario in the seeded feature files exercised by an acceptance test on this branch?"
78
+ })
79
+ ];
80
+ const slug = (title) => title
81
+ .toLowerCase()
82
+ .replace(/[^a-z0-9]+/g, "-")
83
+ .replace(/^-|-$/g, "")
84
+ .slice(0, 60);
85
+ const gatherDir = Effect.fn("modernize-review.gatherDir")(function* (workspace, directory) {
86
+ const paths = yield* workspace.discover(`${directory}/**`).pipe(Effect.orElseSucceed(() => []));
87
+ const parts = [];
88
+ for (const path of [...paths].sort()) {
89
+ const text = yield* workspace.read(path).pipe(Effect.orElseSucceed(() => ""));
90
+ if (text.trim().length > 0) {
91
+ parts.push(`===== ${path} =====\n${text}`);
92
+ }
93
+ }
94
+ return parts.join("\n\n");
95
+ });
96
+ const distillPrompt = (pack, findings, scored, diff) => [
97
+ pack.prompt("review") ?? "",
98
+ "",
99
+ "Below are the raw reviewer findings and judge scores for a modernization increment.",
100
+ "Distill them:",
101
+ '- "fixes": findings where the implementation VIOLATES the committed specs. Each gets a',
102
+ " short spec document (Markdown: what is wrong, the spec rule it violates, the expected",
103
+ " behaviour) and a plan task (title + description naming the spec rules/scenarios).",
104
+ '- "improvements": worthwhile follow-ups that do NOT violate the specs.',
105
+ '- "lessons": rules of thumb that would help FUTURE modernizations of this kind — phrased',
106
+ " generally (no file paths from this repo), one sentence each. Only include lessons that",
107
+ " generalize; an empty list is a fine answer.",
108
+ "",
109
+ "Reviewer findings:",
110
+ findings.issues
111
+ .map((issue) => `- [${issue.severity}] ${issue.title}: ${issue.description}`)
112
+ .join("\n"),
113
+ "",
114
+ "Judge scores:",
115
+ scored.scores.map((score) => `- ${score.name}: ${score.score} — ${score.reasoning}`).join("\n"),
116
+ "",
117
+ "Diff under review:",
118
+ diff
119
+ ].join("\n");
120
+ const program = Effect.gen(function* () {
121
+ const input = yield* resolveFlowInput("Review the modernization increment against its spec pack");
122
+ const coder = coderFromEnv(process.env);
123
+ const files = nodePlainFileStore;
124
+ const planPath = join(input.workDir, ModDir, "plan.md");
125
+ yield* runNode({
126
+ workDir: input.workDir,
127
+ workspace: input.workspace,
128
+ userPrompt: input.prompt,
129
+ coder,
130
+ reasoning: asReadOnly(coder),
131
+ reviewers: [asReadOnly(coder)],
132
+ environment: process.env
133
+ }, (context) => Effect.gen(function* () {
134
+ const target = yield* makeNodeWorkspace(input.workDir);
135
+ const opened = yield* stage(context.events, "pack", openPack({
136
+ environment: process.env,
137
+ launchDir: input.workspace,
138
+ flowDir: import.meta.dirname
139
+ }));
140
+ const pack = opened.pack;
141
+ const reviewService = context.reviewers[0] ?? context.reasoning;
142
+ yield* stage(context.events, "wall", Effect.gen(function* () {
143
+ if (pack.sources === undefined) {
144
+ return yield* context.events.publish(Info.make({ message: "pack has no sources regex — wall check skipped" }));
145
+ }
146
+ const result = yield* checkWall(target, pack.sources);
147
+ if (result._tag === "Breached") {
148
+ return yield* FlowAborted.make({
149
+ message: wallBreachMessage(result, "The review must judge spec-driven work only; remove the files and rerun.")
150
+ });
151
+ }
152
+ yield* context.events.publish(Info.make({ message: "clean-room wall: no legacy source in the target workspace" }));
153
+ }));
154
+ const base = yield* context.git.defaultBase;
155
+ const diff = yield* context.git.diffVsBase(base);
156
+ if (diff.trim().length === 0) {
157
+ return yield* FlowAborted.make({
158
+ message: `nothing to review: no diff vs ${base} on this branch`
159
+ });
160
+ }
161
+ const changedFiles = yield* context.git.changedFilesVsBase(base);
162
+ const specText = yield* gatherDir(target, pack.specsDir);
163
+ const findings = yield* stage(context.events, "review", Effect.gen(function* () {
164
+ // Sequential on purpose: free provider tiers rate-limit concurrent
165
+ // reviewers, and the roster is small.
166
+ const roster = [...allReviewers, ...pack.lenses].filter((lens) => lens.matches(changedFiles));
167
+ const results = [];
168
+ for (const lens of roster) {
169
+ const prompt = `${lens.systemPrompt}\n\n${reviewPrompt(`modernization increment vs committed specs\n\n${specText}`, diff)}`;
170
+ results.push(yield* reviewService
171
+ .executeStructured(prompt, ReviewResult, reviewJsonSchema)
172
+ .pipe(Effect.mapError(FlowLlmError.from)));
173
+ }
174
+ return mergeReviewResults(results);
175
+ }));
176
+ const scored = yield* stage(context.events, "judge", judge(context.reasoning, complianceDimensions)
177
+ .evaluate(Sample.make({ response: diff, context: specText, query: input.prompt }))
178
+ .pipe(Effect.mapError(FlowLlmError.from)));
179
+ const outcome = yield* stage(context.events, "distill", context.reasoning
180
+ .executeStructured(distillPrompt(pack, findings, scored, diff), ReviewOutcome, reviewOutcomeJsonSchema)
181
+ .pipe(Effect.mapError(FlowLlmError.from)));
182
+ yield* stage(context.events, "fix specs", Effect.gen(function* () {
183
+ const documents = [
184
+ ...outcome.fixes.map((fix) => ["fix", fix]),
185
+ ...outcome.improvements.map((fix) => ["improvement", fix])
186
+ ];
187
+ for (const [kind, fix] of documents) {
188
+ yield* files.writeAtomic(join(input.workDir, pack.specsDir, "fixes", `${kind}-${slug(fix.title)}.md`), `# ${fix.title}\n\n${fix.spec}\n`);
189
+ }
190
+ if (outcome.fixes.length === 0) {
191
+ return yield* context.events.publish(Info.make({ message: "no spec violations — no plan increment" }));
192
+ }
193
+ const store = makePlanStore(files);
194
+ const plan = yield* store.load(planPath);
195
+ if (plan === undefined) {
196
+ return yield* FlowAborted.make({
197
+ message: `no plan at ${planPath} — run modernize-seed first`
198
+ });
199
+ }
200
+ yield* store.save(planPath, Plan.make({
201
+ ...plan,
202
+ tasks: [
203
+ ...plan.tasks,
204
+ ...outcome.fixes.map((fix) => Task.make({
205
+ title: fix.taskTitle,
206
+ description: fix.taskDescription,
207
+ completed: false
208
+ }))
209
+ ]
210
+ }));
211
+ yield* context.events.publish(Info.make({
212
+ message: `${outcome.fixes.length} fix task(s) appended — rerun modernize-implement`
213
+ }));
214
+ }));
215
+ yield* stage(context.events, "lessons", Effect.gen(function* () {
216
+ if (outcome.lessons.length === 0) {
217
+ return yield* context.events.publish(Info.make({ message: "no generalizable lessons this round" }));
218
+ }
219
+ for (const lesson of outcome.lessons) {
220
+ yield* appendPackLesson(opened.workspace, opened.dir, lesson);
221
+ }
222
+ yield* context.events.publish(Info.make({
223
+ message: `${outcome.lessons.length} lesson(s) appended to ${opened.dir}/lessons.md — ` +
224
+ "review and commit the pack change"
225
+ }));
226
+ }));
227
+ yield* stage(context.events, "commit", context.git
228
+ .commitAll(`modernize(${pack.name}): review — ${outcome.fixes.length} fix(es), ` +
229
+ `${outcome.improvements.length} improvement(s)`)
230
+ .pipe(Effect.asVoid));
231
+ yield* context.events.publish(Info.make({
232
+ message: `fixes=${outcome.fixes.length} improvements=${outcome.improvements.length} ` +
233
+ `lessons=${outcome.lessons.length}`
234
+ }));
235
+ }));
236
+ });
237
+ runFlowMain(program);