@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.
- package/flows/lib/pack-fork.js +126 -5
- package/flows/modernize-implement.js +15 -0
- package/flows/pack-fork.js +56 -19
- package/package.json +4 -4
package/flows/lib/pack-fork.js
CHANGED
|
@@ -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
|
|
124
|
-
|
|
125
|
-
|
|
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" }));
|
package/flows/pack-fork.js
CHANGED
|
@@ -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
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
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
|
|
143
|
-
`${destinationRel}/conventions.md,
|
|
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.
|
|
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.
|
|
56
|
-
"@llm4ts/
|
|
57
|
-
"@llm4ts/
|
|
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"
|