@llm4ts/shell 2.4.1 → 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";
@@ -28,8 +35,8 @@ import { FlowEvents, Info } from "@llm4ts/flow/FlowEvents";
28
35
  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
- import { asReadOnly, 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";
38
+ import { asReadOnly, asToolless, coderFromEnv, makeNodeWorkspace, nodePlainFileStore, openPack, resolveFlowInput, runFlowMain, runNode, ScriptUsage, stage } from "@llm4ts/runner";
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({
@@ -80,7 +88,14 @@ const program = Effect.gen(function* () {
80
88
  workspace: input.workspace,
81
89
  userPrompt: input.prompt,
82
90
  coder,
83
- reasoning: coder,
91
+ // Every convention pass runs as a one-shot structuredAndPublish()
92
+ // call with no tool-loop continuation — a model that reaches for a
93
+ // tool mid-turn (invited by "Explore the repository…" in three of
94
+ // the four passes) resolves with no text instead of an error
95
+ // ("empty response … no text to parse as structured output"). A
96
+ // read-only coder still offers a read tool over some connectors
97
+ // (pi in particular); asToolless offers none.
98
+ reasoning: asToolless(coderFromEnv(process.env)),
84
99
  environment: process.env
85
100
  }, (context) => Effect.gen(function* () {
86
101
  const repo = yield* makeNodeWorkspace(input.workDir, workspaceLimitsFromEnv(process.env, legacySourceWorkspaceLimits));
@@ -107,42 +122,64 @@ const program = Effect.gen(function* () {
107
122
  "pack, or choose a different LLM4TS_FORK_AS."
108
123
  });
109
124
  }
110
- 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("**"));
111
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) }));
112
144
  const sections = [];
113
- for (const [index, pass] of passes.entries()) {
114
- const askText = conventionPassAsk(pass, index === 0 ? grounding : undefined);
115
- // Tech Stack & Dependencies (index 0) is the only pass carrying
116
- // grounding file content — a real package.json/tsconfig.json can
117
- // make it substantially larger than the other three, ungrounded
118
- // passes. `capped` bounds every attempt's prompt size (guarding
119
- // against a truncated, unparseable structured response); `withShrink`
120
- // retries at a smaller budget on an actual provider overflow —
121
- // the same pairing every other modernize-* flow already uses
122
- // 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.
123
154
  const result = yield* stage(context.events, pass.heading, withShrink(pass.heading, (cap) => Effect.gen(function* () {
124
155
  const prompt = yield* capped(pass.heading, askText, cap);
125
156
  return yield* structuredAndPublish(context.reasoning, context.events, prompt, ConventionSection, conventionSectionJsonSchema);
126
157
  })).pipe(Effect.provideService(FlowEvents, context.events)));
158
+ yield* context.events.publish(Info.make({ message: result.markdown }));
127
159
  sections.push(result.markdown);
128
160
  }
129
161
  const conventionsMd = sections.join("\n\n");
162
+ const provenanceMd = provenanceMarkdown(targetKind, selections, feedback);
130
163
  yield* stage(context.events, "clean", Effect.sync(() => rmSync(destinationAbs, { recursive: true, force: true })));
131
164
  yield* stage(context.events, "fork", forkPackFiles(opened, destinationAbs, files));
132
165
  yield* stage(context.events, "fork patterns", forkKitPatterns(opened, destinationAbs, files));
133
166
  const sourcePackMd = yield* opened.workspace.read(`${opened.dir}/pack.md`);
134
167
  yield* files.writeAtomic(join(destinationAbs, "pack.md"), forkPackMarkdown(sourcePackMd, forkAs));
135
168
  yield* files.writeAtomic(join(destinationAbs, "conventions.md"), conventionsMd);
169
+ yield* files.writeAtomic(join(destinationAbs, "provenance.md"), provenanceMd);
136
170
  yield* files.writeAtomic(join(destinationAbs, "reviewers", "target-conventions.md"), targetConventionsReviewer);
137
171
  yield* files.writeAtomic(join(destinationAbs, "README.md"), withDraftApproval(forkedReadme(opened.pack.name, forkAs, targetKind, basename(input.workDir))));
138
172
  yield* stage(context.events, "commit", context.git
139
173
  .commitPaths(`pack-fork: fork '${opened.pack.name}' as '${forkAs}'`, [destinationRel])
140
174
  .pipe(Effect.asVoid));
141
175
  yield* context.events.publish(Info.make({
142
- message: `forked pack ready — review ${destinationRel}/README.md and ` +
143
- `${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 " +
144
179
  `inside ${input.workDir} run modernize-implement with ` +
145
- `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."
146
183
  }));
147
184
  }));
148
185
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.4.1",
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.1",
56
- "@llm4ts/runner": "2.4.1",
57
- "@llm4ts/flow": "2.4.1"
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"