@llm4ts/shell 2.4.2 → 2.5.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.
@@ -96,14 +96,22 @@ export const backendPasses = [
96
96
  }
97
97
  ];
98
98
  export const passesForTargetKind = (kind) => kind === "frontend" ? frontendPasses : backendPasses;
99
- export const conventionPassAsk = (pass, grounding) => [
99
+ export const conventionPassAsk = (pass, grounding, refinement) => [
100
100
  pass.instructions,
101
101
  `Respond with a single markdown section starting with "## ${pass.heading}".`,
102
102
  "If a topic doesn't apply to this repository, say so explicitly rather than " +
103
103
  "inventing one.",
104
104
  ...(grounding === undefined || grounding.length === 0
105
105
  ? []
106
- : [`Repository files for grounding:\n\n${grounding}`])
106
+ : [`Repository files for grounding:\n\n${grounding}`]),
107
+ ...(refinement === undefined
108
+ ? []
109
+ : [
110
+ "A previous run of this analysis produced the findings below, and a human " +
111
+ "reviewed them. Revise — do not simply repeat what you had before:\n\n" +
112
+ `Previous findings (all categories):\n\n${refinement.priorConventions}\n\n` +
113
+ `Human feedback on that previous run:\n\n${refinement.feedback}`
114
+ ])
107
115
  ].join("\n\n");
108
116
  export class ConventionSection extends Schema.Class("ConventionSection")({
109
117
  markdown: Schema.String
@@ -120,9 +128,122 @@ export const conventionSectionJsonSchema = {
120
128
  },
121
129
  required: ["markdown"]
122
130
  };
123
- export const techStackGroundingFiles = (kind) => kind === "frontend"
124
- ? ["package.json", "tsconfig.json"]
125
- : ["pom.xml", "build.gradle", "build.gradle.kts"];
131
+ export class GroundingSelection extends Schema.Class("GroundingSelection")({
132
+ selections: Schema.Array(Schema.Struct({
133
+ category: Schema.String,
134
+ path: Schema.String,
135
+ reason: Schema.String
136
+ }))
137
+ }) {
138
+ }
139
+ export const groundingSelectionJsonSchema = {
140
+ type: "object",
141
+ properties: {
142
+ selections: {
143
+ type: "array",
144
+ items: {
145
+ type: "object",
146
+ properties: {
147
+ category: {
148
+ type: "string",
149
+ description: "Must exactly match one of the category headings given."
150
+ },
151
+ path: {
152
+ type: "string",
153
+ description: "Must be copied EXACTLY from the candidate file paths given — never " +
154
+ "invented or guessed."
155
+ },
156
+ reason: {
157
+ type: "string",
158
+ description: "One line explaining why this file is relevant to the category."
159
+ }
160
+ },
161
+ required: ["category", "path", "reason"]
162
+ }
163
+ }
164
+ },
165
+ required: ["selections"]
166
+ };
167
+ /** At most this many selected files get read (and fed as grounding) per category. */
168
+ export const maxSelectedFilesPerCategory = 6;
169
+ export const groundingSelectionAsk = (passes, candidatePaths, refinement) => [
170
+ "A coding agent needs to analyze this repository's real conventions across the " +
171
+ "categories below. From the list of real file paths given, select the files " +
172
+ "most likely to reveal each category's conventions — representative source " +
173
+ "and configuration files, not every file of a kind.",
174
+ ...passes.map((pass) => `## ${pass.heading}\n\n${pass.instructions}`),
175
+ `Select at most ${maxSelectedFilesPerCategory} files per category — quality over ` +
176
+ "quantity. Every path you return must be copied EXACTLY from the candidate " +
177
+ "list below; never invent or guess a path. A category with nothing relevant " +
178
+ "in this repository can have zero selections — say so via an empty selection " +
179
+ "list for it rather than forcing an irrelevant file in.",
180
+ `Candidate file paths (${candidatePaths.length}):\n\n${candidatePaths.join("\n")}`,
181
+ ...(refinement === undefined
182
+ ? []
183
+ : [
184
+ "A previous run selected files and produced the findings below; a human " +
185
+ "reviewed them and gave feedback. Reconsider the selection in light of " +
186
+ "it — the feedback may point at files that were missed entirely:\n\n" +
187
+ `Previous findings (all categories):\n\n${refinement.priorConventions}\n\n` +
188
+ `Human feedback on that previous run:\n\n${refinement.feedback}`
189
+ ])
190
+ ].join("\n\n");
191
+ /**
192
+ * Drops any selection whose path wasn't in the real candidate list (never trust
193
+ * an LLM-fabricated path into a file read) and caps each category at
194
+ * `maxSelectedFilesPerCategory`, keeping the model's own priority order.
195
+ */
196
+ export const validSelections = (selection, candidatePaths) => {
197
+ const candidates = new Set(candidatePaths);
198
+ const perCategoryCount = new Map();
199
+ const kept = [];
200
+ for (const item of selection.selections) {
201
+ if (!candidates.has(item.path)) {
202
+ continue;
203
+ }
204
+ const count = perCategoryCount.get(item.category) ?? 0;
205
+ if (count >= maxSelectedFilesPerCategory) {
206
+ continue;
207
+ }
208
+ perCategoryCount.set(item.category, count + 1);
209
+ kept.push(item);
210
+ }
211
+ return kept;
212
+ };
213
+ export const selectionsForCategory = (selections, category) => selections
214
+ .filter((selection) => selection.category === category)
215
+ .map((selection) => selection.path);
216
+ export const provenanceMarkdown = (kind, selections, feedback) => {
217
+ const lines = [
218
+ "# Provenance",
219
+ "",
220
+ "Which real files this pack's conventions.md was grounded on, and why — trace a " +
221
+ "rule back to the file that justified it.",
222
+ ""
223
+ ];
224
+ if (feedback !== undefined && feedback.length > 0) {
225
+ lines.push("## Feedback applied this run", "", feedback, "");
226
+ }
227
+ for (const pass of passesForTargetKind(kind)) {
228
+ lines.push(`## ${pass.heading}`, "");
229
+ const forCategory = selections.filter((selection) => selection.category === pass.heading);
230
+ if (forCategory.length === 0) {
231
+ lines.push("No files selected for grounding — this category's findings rely on the " +
232
+ "model's general knowledge only, not a specific file in this repository.", "");
233
+ }
234
+ else {
235
+ for (const selection of forCategory) {
236
+ lines.push(`- \`${selection.path}\` — ${selection.reason}`);
237
+ }
238
+ lines.push("");
239
+ }
240
+ }
241
+ return lines.join("\n");
242
+ };
243
+ export const parseFeedback = (environment) => {
244
+ const raw = environment.LLM4TS_FEEDBACK?.trim();
245
+ return raw === undefined || raw.length === 0 ? undefined : raw;
246
+ };
126
247
  export const readGroundingFiles = (workspace, paths) => Effect.gen(function* () {
127
248
  const sections = [];
128
249
  for (const path of paths) {
@@ -27,6 +27,7 @@ import { capped, renderTruncation, truncations, withShrink } from "@llm4ts/flow/
27
27
  import { FlowEvents } from "@llm4ts/flow/FlowEvents";
28
28
  import { judgeAllPrograms } from "@llm4ts/flow/ProgramJudge";
29
29
  import { Provenance, makeProvenanceStore } from "@llm4ts/flow/Provenance";
30
+ import { ApprovedMarker, DraftApprovalMarker, requireApproval } from "@llm4ts/flow/Approval";
30
31
  import { loadPatternCards, taggedPatternIds } from "@llm4ts/flow/Patterns";
31
32
  import { ReviewIssue } from "@llm4ts/flow/Review";
32
33
  import { checkWall, wallBreachMessage } from "@llm4ts/flow/Wall";
@@ -160,6 +161,20 @@ const program = Effect.gen(function* () {
160
161
  flowDir: import.meta.dirname
161
162
  }));
162
163
  const pack = opened.pack;
164
+ // A pack whose own README.md carries the draft/approved marker (a
165
+ // fork from pack-fork, today — see docs/adr/0018-pack-fork.md) must
166
+ // be approved before this flow touches anything. A plain pack with
167
+ // no README.md, or one that never opted into the marker convention,
168
+ // is unaffected.
169
+ yield* stage(context.events, "approval", Effect.gen(function* () {
170
+ const readmeAbs = join(opened.workspace.root, opened.dir, "README.md");
171
+ const readmeContent = yield* files.read(readmeAbs);
172
+ if (readmeContent !== undefined &&
173
+ (readmeContent.includes(DraftApprovalMarker) ||
174
+ readmeContent.includes(ApprovedMarker))) {
175
+ yield* requireApproval(files, readmeAbs);
176
+ }
177
+ }));
163
178
  yield* stage(context.events, "wall", Effect.gen(function* () {
164
179
  if (pack.sources === undefined) {
165
180
  return yield* context.events.publish(Info.make({ message: "pack has no sources regex — wall check skipped" }));
@@ -10,6 +10,12 @@
10
10
  // LLM4TS_PACK=<source pack> resolved as for any flow (default cobol-springboot)
11
11
  // LLM4TS_TARGET_KIND=frontend|backend
12
12
  // LLM4TS_FORK_AS=<new pack name> lowercase kebab-case
13
+ // Optional:
14
+ // LLM4TS_FEEDBACK=<free text> revise an existing fork under the same
15
+ // LLM4TS_FORK_AS instead of starting over —
16
+ // the prior conventions.md and this feedback
17
+ // both feed every pass, including file
18
+ // selection, which can change too.
13
19
  //
14
20
  // The forked pack lands at
15
21
  // `<repo>/.llm4ts/kits/forked/packs/<LLM4TS_FORK_AS>/` — a dedicated
@@ -18,8 +24,9 @@
18
24
  // would hide its other packs from this project). `scaffold:` is dropped:
19
25
  // the whole point is that the target repository already exists.
20
26
  //
21
- // One-shot, not resumable: re-running overwrites the previous fork under
22
- // the same LLM4TS_FORK_AS name.
27
+ // Not resumable: re-running clears and overwrites the previous fork under
28
+ // the same LLM4TS_FORK_AS name (LLM4TS_FEEDBACK revises the CONTENT of that
29
+ // overwrite, not the one-shot-per-run mechanics).
23
30
  import { basename, join, relative as relativePath, resolve as resolvePath } from "node:path";
24
31
  import { rmSync } from "node:fs";
25
32
  import * as Effect from "effect/Effect";
@@ -29,7 +36,7 @@ import { capped, withShrink } from "@llm4ts/flow/Context";
29
36
  import { withDraftApproval } from "@llm4ts/flow/Approval";
30
37
  import { legacySourceWorkspaceLimits, workspaceLimitsFromEnv } from "@llm4ts/flow/Workspace";
31
38
  import { asReadOnly, asToolless, coderFromEnv, makeNodeWorkspace, nodePlainFileStore, openPack, resolveFlowInput, runFlowMain, runNode, ScriptUsage, stage } from "@llm4ts/runner";
32
- import { ConventionSection, conventionPassAsk, conventionSectionJsonSchema, forkPackMarkdown, forkedReadme, parseForkAs, parseTargetKind, passesForTargetKind, readGroundingFiles, targetConventionsReviewer, techStackGroundingFiles } from "./lib/pack-fork.js";
39
+ import { ConventionSection, conventionPassAsk, conventionSectionJsonSchema, forkPackMarkdown, forkedReadme, GroundingSelection, groundingSelectionAsk, groundingSelectionJsonSchema, parseFeedback, parseForkAs, parseTargetKind, passesForTargetKind, provenanceMarkdown, readGroundingFiles, selectionsForCategory, targetConventionsReviewer, validSelections } from "./lib/pack-fork.js";
33
40
  const forkPackFiles = Effect.fn("flows/pack-fork.forkPackFiles")(function* (source, destinationAbs, files) {
34
41
  // `source.dir` is "." for an absolute-path pack reference (Packs.ts's
35
42
  // `locatePack`), "packs/<name>" for a relative-directory or kit pack
@@ -73,6 +80,7 @@ const program = Effect.gen(function* () {
73
80
  const input = yield* resolveFlowInput("Analyze the target repository and fork the pack with its conventions.");
74
81
  const targetKind = yield* parseTargetKind(process.env);
75
82
  const forkAs = yield* parseForkAs(process.env);
83
+ const feedback = parseFeedback(process.env);
76
84
  const coder = asReadOnly(coderFromEnv(process.env));
77
85
  const files = nodePlainFileStore;
78
86
  yield* runNode({
@@ -114,42 +122,64 @@ const program = Effect.gen(function* () {
114
122
  "pack, or choose a different LLM4TS_FORK_AS."
115
123
  });
116
124
  }
117
- const grounding = yield* stage(context.events, "grounding", readGroundingFiles(repo, techStackGroundingFiles(targetKind)));
125
+ // Captured BEFORE "clean" (below) can remove it — a --feedback re-run
126
+ // revises what a previous run found rather than starting from zero.
127
+ const priorConventions = feedback === undefined
128
+ ? undefined
129
+ : yield* repo
130
+ .read(join(destinationRel, "conventions.md"))
131
+ .pipe(Effect.catch(() => Effect.succeed(undefined)));
132
+ const refinement = priorConventions === undefined || feedback === undefined
133
+ ? undefined
134
+ : { priorConventions, feedback };
135
+ const candidatePaths = yield* stage(context.events, "discover", repo.discover("**"));
118
136
  const passes = passesForTargetKind(targetKind);
137
+ const selectionAsk = groundingSelectionAsk(passes, candidatePaths, refinement);
138
+ const selectionResult = yield* stage(context.events, "select grounding", withShrink("select grounding", (cap) => Effect.gen(function* () {
139
+ const prompt = yield* capped("select grounding", selectionAsk, cap);
140
+ return yield* structuredAndPublish(context.reasoning, context.events, prompt, GroundingSelection, groundingSelectionJsonSchema);
141
+ })).pipe(Effect.provideService(FlowEvents, context.events)));
142
+ const selections = validSelections(selectionResult, candidatePaths);
143
+ yield* context.events.publish(Info.make({ message: provenanceMarkdown(targetKind, selections, feedback) }));
119
144
  const sections = [];
120
- for (const [index, pass] of passes.entries()) {
121
- const askText = conventionPassAsk(pass, index === 0 ? grounding : undefined);
122
- // Tech Stack & Dependencies (index 0) is the only pass carrying
123
- // grounding file content — a real package.json/tsconfig.json can
124
- // make it substantially larger than the other three, ungrounded
125
- // passes. `capped` bounds every attempt's prompt size (guarding
126
- // against a truncated, unparseable structured response); `withShrink`
127
- // retries at a smaller budget on an actual provider overflow —
128
- // the same pairing every other modernize-* flow already uses
129
- // ahead of a structuredAndPublish call.
145
+ for (const pass of passes) {
146
+ const groundingPaths = selectionsForCategory(selections, pass.heading);
147
+ const grounding = yield* readGroundingFiles(repo, groundingPaths);
148
+ const askText = conventionPassAsk(pass, grounding, refinement);
149
+ // `capped` bounds every attempt's prompt size (guarding against a
150
+ // truncated, unparseable structured response); `withShrink` retries
151
+ // at a smaller budget on an actual provider overflow — the same
152
+ // pairing every other modernize-* flow already uses ahead of a
153
+ // structuredAndPublish call.
130
154
  const result = yield* stage(context.events, pass.heading, withShrink(pass.heading, (cap) => Effect.gen(function* () {
131
155
  const prompt = yield* capped(pass.heading, askText, cap);
132
156
  return yield* structuredAndPublish(context.reasoning, context.events, prompt, ConventionSection, conventionSectionJsonSchema);
133
157
  })).pipe(Effect.provideService(FlowEvents, context.events)));
158
+ yield* context.events.publish(Info.make({ message: result.markdown }));
134
159
  sections.push(result.markdown);
135
160
  }
136
161
  const conventionsMd = sections.join("\n\n");
162
+ const provenanceMd = provenanceMarkdown(targetKind, selections, feedback);
137
163
  yield* stage(context.events, "clean", Effect.sync(() => rmSync(destinationAbs, { recursive: true, force: true })));
138
164
  yield* stage(context.events, "fork", forkPackFiles(opened, destinationAbs, files));
139
165
  yield* stage(context.events, "fork patterns", forkKitPatterns(opened, destinationAbs, files));
140
166
  const sourcePackMd = yield* opened.workspace.read(`${opened.dir}/pack.md`);
141
167
  yield* files.writeAtomic(join(destinationAbs, "pack.md"), forkPackMarkdown(sourcePackMd, forkAs));
142
168
  yield* files.writeAtomic(join(destinationAbs, "conventions.md"), conventionsMd);
169
+ yield* files.writeAtomic(join(destinationAbs, "provenance.md"), provenanceMd);
143
170
  yield* files.writeAtomic(join(destinationAbs, "reviewers", "target-conventions.md"), targetConventionsReviewer);
144
171
  yield* files.writeAtomic(join(destinationAbs, "README.md"), withDraftApproval(forkedReadme(opened.pack.name, forkAs, targetKind, basename(input.workDir))));
145
172
  yield* stage(context.events, "commit", context.git
146
173
  .commitPaths(`pack-fork: fork '${opened.pack.name}' as '${forkAs}'`, [destinationRel])
147
174
  .pipe(Effect.asVoid));
148
175
  yield* context.events.publish(Info.make({
149
- message: `forked pack ready — review ${destinationRel}/README.md and ` +
150
- `${destinationRel}/conventions.md, set '- [x] Approved', then from ` +
176
+ message: `forked pack ready — review ${destinationRel}/README.md, ` +
177
+ `${destinationRel}/conventions.md, and ${destinationRel}/provenance.md ` +
178
+ "(which file justified which rule), set '- [x] Approved', then from " +
151
179
  `inside ${input.workDir} run modernize-implement with ` +
152
- `LLM4TS_PACK=forked/${forkAs}`
180
+ `LLM4TS_PACK=forked/${forkAs}. Not satisfied? Re-run this same command ` +
181
+ "with LLM4TS_FEEDBACK=<what to fix> to revise this fork instead of " +
182
+ "starting over."
153
183
  }));
154
184
  }));
155
185
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Interactive shell and CLI for llm4ts: flow discovery, run-a-flow, and view",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,9 +52,9 @@
52
52
  "dependencies": {
53
53
  "@effect/platform-node": "4.0.0-rc.115",
54
54
  "@effect/platform-node-shared": "4.0.0-rc.115",
55
- "@llm4ts/core": "2.4.2",
56
- "@llm4ts/flow": "2.4.2",
57
- "@llm4ts/runner": "2.4.2"
55
+ "@llm4ts/core": "2.5.0",
56
+ "@llm4ts/flow": "2.5.0",
57
+ "@llm4ts/runner": "2.5.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "effect": "4.0.0-rc.115"