@llm4ts/shell 2.32.0 → 2.33.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/port.js +126 -0
- package/flows/port-compile.js +139 -0
- package/flows/port-files.js +166 -0
- package/kits/port/README.md +27 -0
- package/kits/port/packs/zig-rust/pack.md +26 -0
- package/kits/port/packs/zig-rust/patterns/pitfalls-zig-rust.md +22 -0
- package/kits/port/packs/zig-rust/prompts/porting.md +67 -0
- package/kits/port/packs/zig-rust/reviewers/port-fidelity.md +13 -0
- package/package.json +4 -4
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Shared by the port flows (ADR 0028): the pack as a porting pack, the
|
|
2
|
+
// manifest of sources and their targets, the rulebook and pitfall cards
|
|
3
|
+
// the implementer reads, and the prompts. The seats and the queue are the
|
|
4
|
+
// flows' own business.
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import { autonomyContract } from "@llm4ts/flow/AutonomyContract";
|
|
8
|
+
import { cap } from "@llm4ts/flow/Context";
|
|
9
|
+
import { FlowAborted } from "@llm4ts/flow/FlowError";
|
|
10
|
+
import { loadPatternCards } from "@llm4ts/flow/Patterns";
|
|
11
|
+
import { PortEntry, linesOf, portStatusInstruction, targetPathOf } from "@llm4ts/flow/Port";
|
|
12
|
+
import { matchingFiles } from "@llm4ts/flow/SpecChecks";
|
|
13
|
+
/** Where the port flows keep their state, under the target repository. */
|
|
14
|
+
export const portStateDir = (workDir) => join(workDir, ".llm4ts", "port");
|
|
15
|
+
export const defaultRulebook = [
|
|
16
|
+
"You translate one source file to its target language, faithfully and mechanically:",
|
|
17
|
+
"the same structure, the same names, the same control flow, so a reviewer can read source",
|
|
18
|
+
"and target side by side. Translate what the source does, never what you think it should",
|
|
19
|
+
"do. Flag anything you cannot translate confidently with a TODO(port): <reason> comment",
|
|
20
|
+
"instead of guessing: a flag is better than wrong code."
|
|
21
|
+
].join("\n");
|
|
22
|
+
/** The pack read as a porting pack: `target:` is required, the rulebook is `prompts/porting.md`. */
|
|
23
|
+
export const asPortingPack = Effect.fn("port.asPortingPack")(function* (opened) {
|
|
24
|
+
const pack = opened.pack;
|
|
25
|
+
if (pack.target === undefined) {
|
|
26
|
+
return yield* FlowAborted.make({
|
|
27
|
+
message: `pack '${pack.name}' has no 'target:' template (e.g. target: {{dir}}/{{base}}.rs); it is not a porting pack`
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const cards = yield* loadPatternCards(opened.workspace, `${opened.dir}/patterns`);
|
|
31
|
+
const pitfalls = cards
|
|
32
|
+
.filter((card) => card.id.startsWith("pitfalls"))
|
|
33
|
+
.map((card) => `### ${card.id}\n${card.body.trim()}`)
|
|
34
|
+
.join("\n\n");
|
|
35
|
+
return {
|
|
36
|
+
pack,
|
|
37
|
+
target: pack.target,
|
|
38
|
+
comment: pack.comment ?? "//",
|
|
39
|
+
rulebook: pack.prompt("porting") ?? defaultRulebook,
|
|
40
|
+
pitfalls
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
/** Every source the pack matches, with its line count and target; sorted by path. */
|
|
44
|
+
export const portManifest = Effect.fn("port.manifest")(function* (repo, files, workDir, porting) {
|
|
45
|
+
const sources = yield* matchingFiles(repo, porting.pack.sources ?? "(?!)", porting.pack.exclude);
|
|
46
|
+
const entries = [];
|
|
47
|
+
for (const source of sources) {
|
|
48
|
+
const text = (yield* files.read(join(workDir, source))) ?? "";
|
|
49
|
+
entries.push(PortEntry.make({
|
|
50
|
+
id: source,
|
|
51
|
+
source,
|
|
52
|
+
target: targetPathOf(porting.target, source),
|
|
53
|
+
loc: linesOf(text)
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
return entries;
|
|
57
|
+
});
|
|
58
|
+
/** The denials that keep an implementer on its one file (the Bun port's prompt, in spirit). */
|
|
59
|
+
export const implementerDenials = [
|
|
60
|
+
"Read exactly one source file: the one named below. Do NOT read other source files for",
|
|
61
|
+
"context: the rulebook's maps are authoritative for cross-file references. Do NOT run a",
|
|
62
|
+
"build, a test or any git command: the flow builds and commits. Write only the target file.",
|
|
63
|
+
"Match the source's structure: same function names, same field order, same control flow."
|
|
64
|
+
].join("\n");
|
|
65
|
+
export const implementerSystem = (porting) => [
|
|
66
|
+
autonomyContract("minimal"),
|
|
67
|
+
"",
|
|
68
|
+
"# Porting rulebook",
|
|
69
|
+
porting.rulebook,
|
|
70
|
+
...(porting.pitfalls.length === 0
|
|
71
|
+
? []
|
|
72
|
+
: ["", "# Pitfalls: syntactically alike, semantically different", porting.pitfalls]),
|
|
73
|
+
"",
|
|
74
|
+
implementerDenials
|
|
75
|
+
].join("\n");
|
|
76
|
+
export const implementerPrompt = (entry, sourceText, porting, sourceChars) => [
|
|
77
|
+
`Port \`${entry.source}\` (${entry.loc} lines). Write the ported file at: ${entry.target}`,
|
|
78
|
+
"The draft need not compile; it must capture the logic faithfully.",
|
|
79
|
+
"",
|
|
80
|
+
portStatusInstruction(porting.comment),
|
|
81
|
+
"",
|
|
82
|
+
`Source file \`${entry.source}\`:`,
|
|
83
|
+
"```",
|
|
84
|
+
cap(sourceText, sourceChars).text,
|
|
85
|
+
"```"
|
|
86
|
+
].join("\n");
|
|
87
|
+
/** The fixer's brief for a drafted file: apply findings against the source, nothing else. */
|
|
88
|
+
export const portFixerSystem = (porting) => [
|
|
89
|
+
autonomyContract("minimal"),
|
|
90
|
+
"",
|
|
91
|
+
"You apply review findings to a ported file another coder drafted. Apply the findings and",
|
|
92
|
+
"nothing else; where a finding conflicts with the source file's behaviour, the source wins.",
|
|
93
|
+
"Keep the PORT STATUS trailer at the end and update its confidence and todos honestly.",
|
|
94
|
+
"If a finding is wrong (the reviewer misread the code), skip it and say so in one line.",
|
|
95
|
+
"",
|
|
96
|
+
"# Porting rulebook",
|
|
97
|
+
porting.rulebook
|
|
98
|
+
].join("\n");
|
|
99
|
+
/** The compile fixer's brief: one unit, its diagnostics, no build, no git. */
|
|
100
|
+
export const compileFixerSystem = (porting) => [
|
|
101
|
+
autonomyContract("minimal"),
|
|
102
|
+
"",
|
|
103
|
+
"You fix compiler diagnostics in ONE unit of a mechanically ported code base. Edit only the",
|
|
104
|
+
"files the diagnostics name and their unit. Other units are being fixed by other coders at",
|
|
105
|
+
"the same time: a broken import from another unit is EXPECTED, leave it. Do NOT run a build",
|
|
106
|
+
"or any git command: the flow rebuilds once per round and commits. Never stub a function,",
|
|
107
|
+
"weaken a type to `any` or its equivalent, or add a comment explaining why a workaround is",
|
|
108
|
+
"acceptable: if the fix needs a paragraph of justification, the fix is wrong. Where a fix",
|
|
109
|
+
"needs another unit to change, skip it and say which unit in one line.",
|
|
110
|
+
"",
|
|
111
|
+
"# Porting rulebook",
|
|
112
|
+
porting.rulebook
|
|
113
|
+
].join("\n");
|
|
114
|
+
export const portEnv = (environment) => {
|
|
115
|
+
const int = (raw, fallback, min = 1) => {
|
|
116
|
+
const value = Number.parseInt(raw ?? "", 10);
|
|
117
|
+
return Number.isFinite(value) && value >= min ? value : fallback;
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
pilot: int(environment.LLM4TS_PORT_PILOT, 0, 1),
|
|
121
|
+
concurrency: int(environment.LLM4TS_PORT_CONCURRENCY, 4),
|
|
122
|
+
batch: int(environment.LLM4TS_PORT_BATCH, 100),
|
|
123
|
+
votes: int(environment.LLM4TS_REVIEW_VOTES, 2),
|
|
124
|
+
sourceChars: int(environment.LLM4TS_PORT_SOURCE_CHARS, 120_000, 1_000)
|
|
125
|
+
};
|
|
126
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Compiler diagnostics as a work queue (ADR 0028, the Bun port's Phase B/D):
|
|
2
|
+
// the pack's `## Diagnostics` command runs once per round, its output is
|
|
3
|
+
// grouped by unit, one fixer per unit edits only that unit (no build, no
|
|
4
|
+
// git), two adversarial votes review, the flow rebuilds and commits. Until
|
|
5
|
+
// dry, or until a round lowers nothing (typed Stalled).
|
|
6
|
+
//
|
|
7
|
+
// LLM4TS_PACK=zig-rust llm4ts run port-compile --repo ~/src/bun
|
|
8
|
+
//
|
|
9
|
+
// Knobs: LLM4TS_PORT_CONCURRENCY (4), LLM4TS_REVIEW_VOTES (2 here),
|
|
10
|
+
// LLM4TS_PORT_COMPILE_ROUNDS (6).
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import * as Effect from "effect/Effect";
|
|
13
|
+
import * as Ref from "effect/Ref";
|
|
14
|
+
import { makeChat } from "@llm4ts/flow/Chat";
|
|
15
|
+
import { groupDiagnostics, parseDiagnostics, renderDiagnostics } from "@llm4ts/flow/Diagnostics";
|
|
16
|
+
import { FlowAborted, ProcessError, Stalled } from "@llm4ts/flow/FlowError";
|
|
17
|
+
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
18
|
+
import { adversarialReviewer, reviewAndFixLoop } from "@llm4ts/flow/Review";
|
|
19
|
+
import { runQueue } from "@llm4ts/flow/WorkQueue";
|
|
20
|
+
import { asReadOnly, coderFromEnv, nodePlainFileStore, nodeProcessExecutor, openPack, resolveFlowInput, runFlowMain, runNode, stage } from "@llm4ts/runner";
|
|
21
|
+
import { asPortingPack, compileFixerSystem, portEnv, portStateDir } from "./lib/port.js";
|
|
22
|
+
const program = Effect.gen(function* () {
|
|
23
|
+
const input = yield* resolveFlowInput("Fix every compiler diagnostic, one unit at a time");
|
|
24
|
+
const coder = coderFromEnv(process.env);
|
|
25
|
+
const files = nodePlainFileStore;
|
|
26
|
+
const knobs = portEnv(process.env);
|
|
27
|
+
const maxRounds = Math.max(1, Number.parseInt(process.env.LLM4TS_PORT_COMPILE_ROUNDS ?? "6", 10) || 6);
|
|
28
|
+
const stateDir = portStateDir(input.workDir);
|
|
29
|
+
yield* runNode({
|
|
30
|
+
workDir: input.workDir,
|
|
31
|
+
workspace: input.workspace,
|
|
32
|
+
userPrompt: input.prompt,
|
|
33
|
+
coder,
|
|
34
|
+
reasoning: asReadOnly(coder),
|
|
35
|
+
reviewers: [asReadOnly(coder)],
|
|
36
|
+
environment: process.env
|
|
37
|
+
}, (context) => Effect.gen(function* () {
|
|
38
|
+
const events = context.events;
|
|
39
|
+
const opened = yield* stage(events, "pack", openPack({
|
|
40
|
+
environment: process.env,
|
|
41
|
+
launchDir: input.workspace,
|
|
42
|
+
flowDir: import.meta.dirname
|
|
43
|
+
}));
|
|
44
|
+
const porting = yield* asPortingPack(opened);
|
|
45
|
+
const diagnostics = porting.pack.diagnostics;
|
|
46
|
+
if (diagnostics === undefined) {
|
|
47
|
+
return yield* FlowAborted.make({
|
|
48
|
+
message: `pack '${porting.pack.name}' has no '## Diagnostics' section (- command: …, - format: json | cargo)`
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const survey = Effect.gen(function* () {
|
|
52
|
+
const ran = yield* nodeProcessExecutor
|
|
53
|
+
.run(diagnostics.command, input.workDir, {})
|
|
54
|
+
.pipe(Effect.mapError((cause) => ProcessError.make({ message: diagnostics.command.join(" "), detail: cause.message })));
|
|
55
|
+
return parseDiagnostics([...ran.stdout, ...ran.stderr].join("\n"), diagnostics.format);
|
|
56
|
+
});
|
|
57
|
+
let previous;
|
|
58
|
+
let flat = 0;
|
|
59
|
+
for (let round = 1; round <= maxRounds; round += 1) {
|
|
60
|
+
const found = yield* stage(events, `diagnostics round ${round}`, survey);
|
|
61
|
+
const groups = groupDiagnostics(found);
|
|
62
|
+
yield* files.writeAtomic(join(stateDir, `diagnostics-${round}.md`), [
|
|
63
|
+
`# Diagnostics, round ${round}`,
|
|
64
|
+
"",
|
|
65
|
+
...groups.map((group) => `## ${group.unit}\n\n${renderDiagnostics(group.diagnostics, 200)}`)
|
|
66
|
+
].join("\n"));
|
|
67
|
+
if (found.length === 0) {
|
|
68
|
+
yield* events.publish(Info.make({ message: `compile: clean after ${round - 1} round(s)` }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (previous !== undefined && found.length >= previous) {
|
|
72
|
+
flat += 1;
|
|
73
|
+
if (flat >= 2) {
|
|
74
|
+
return yield* Stalled.make({
|
|
75
|
+
signal: "no-progress",
|
|
76
|
+
detail: `compile: ${found.length} diagnostic(s) after round ${round - 1}, no lower than the round before`
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
flat = 0;
|
|
82
|
+
}
|
|
83
|
+
previous = found.length;
|
|
84
|
+
yield* events.publish(Info.make({
|
|
85
|
+
message: `compile: round ${round}, ${found.length} diagnostic(s) in ${groups.length} unit(s)`
|
|
86
|
+
}));
|
|
87
|
+
const finished = yield* Ref.make(new Set());
|
|
88
|
+
const units = groups.map((group) => ({
|
|
89
|
+
id: group.unit,
|
|
90
|
+
diagnostics: group.diagnostics
|
|
91
|
+
}));
|
|
92
|
+
yield* runQueue({
|
|
93
|
+
label: `compile round ${round}`,
|
|
94
|
+
items: units,
|
|
95
|
+
done: (unit) => Effect.map(Ref.get(finished), (set) => set.has(unit.id)),
|
|
96
|
+
concurrency: knobs.concurrency,
|
|
97
|
+
maxRounds: 1,
|
|
98
|
+
events,
|
|
99
|
+
ledger: { files, path: join(stateDir, "compile-ledger.jsonl") },
|
|
100
|
+
work: (unit) => Effect.gen(function* () {
|
|
101
|
+
const chat = yield* makeChat(context.coder, {
|
|
102
|
+
system: compileFixerSystem(porting),
|
|
103
|
+
events,
|
|
104
|
+
agent: "coder",
|
|
105
|
+
stall: { repeats: 5 }
|
|
106
|
+
});
|
|
107
|
+
yield* chat.ask([
|
|
108
|
+
`Unit \`${unit.id}\`: ${unit.diagnostics.length} diagnostic(s) from \`${diagnostics.command.join(" ")}\`.`,
|
|
109
|
+
"Fix them in this unit's files. This file is your only runtime evidence; do not rebuild.",
|
|
110
|
+
"",
|
|
111
|
+
renderDiagnostics(unit.diagnostics)
|
|
112
|
+
].join("\n"));
|
|
113
|
+
yield* reviewAndFixLoop({
|
|
114
|
+
reviewers: [adversarialReviewer, ...porting.pack.lenses],
|
|
115
|
+
reviewerService: context.reviewers[0] ?? context.reasoning,
|
|
116
|
+
coder: chat,
|
|
117
|
+
taskTitle: `compile ${unit.id}`,
|
|
118
|
+
currentDiff: context.git.diffAll,
|
|
119
|
+
events,
|
|
120
|
+
maxRounds: 2,
|
|
121
|
+
votes: knobs.votes
|
|
122
|
+
});
|
|
123
|
+
yield* Ref.update(finished, (set) => new Set([...set, unit.id]));
|
|
124
|
+
return { note: `${unit.diagnostics.length} diagnostic(s) addressed` };
|
|
125
|
+
})
|
|
126
|
+
}).pipe(
|
|
127
|
+
// A unit the fixer could not finish is the next survey's business.
|
|
128
|
+
Effect.catchTag("Stalled", () => Effect.void));
|
|
129
|
+
const dirty = yield* context.git.uncommittedFiles;
|
|
130
|
+
if (dirty.length > 0) {
|
|
131
|
+
yield* context.git.commitAll(`compile: round ${round}, ${units.length} unit(s)`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
yield* events.publish(Info.make({
|
|
135
|
+
message: `compile: ${maxRounds} round(s) done, diagnostics remain — see ${stateDir}`
|
|
136
|
+
}));
|
|
137
|
+
}));
|
|
138
|
+
});
|
|
139
|
+
runFlowMain(program);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Port a code base file by file (ADR 0028, the Bun port's Phase A): every
|
|
2
|
+
// source the pack matches gets a draft at its target path, written by one
|
|
3
|
+
// implementer that reads the rulebook, the pitfall cards and exactly one
|
|
4
|
+
// source file; reviewed by two adversarial votes; fixed by a separate fixer;
|
|
5
|
+
// ending in a machine-readable PORT STATUS trailer the ledger records. Not
|
|
6
|
+
// clean-room: the source stays in the tree as the spec. Resumable: a target
|
|
7
|
+
// that exists is done.
|
|
8
|
+
//
|
|
9
|
+
// LLM4TS_PACK=zig-rust llm4ts run port-files --repo ~/src/bun
|
|
10
|
+
// LLM4TS_PORT_PILOT=3 … first: three files, a pilot report, an approval
|
|
11
|
+
//
|
|
12
|
+
// Knobs: LLM4TS_PORT_PILOT, LLM4TS_PORT_CONCURRENCY (4), LLM4TS_PORT_BATCH
|
|
13
|
+
// (100), LLM4TS_REVIEW_VOTES (2 here), LLM4TS_PORT_SOURCE_CHARS (120000).
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import * as Clock from "effect/Clock";
|
|
16
|
+
import * as Effect from "effect/Effect";
|
|
17
|
+
import { ApprovedMarker, requireApproval } from "@llm4ts/flow/Approval";
|
|
18
|
+
import { makeChat } from "@llm4ts/flow/Chat";
|
|
19
|
+
import { Info } from "@llm4ts/flow/FlowEvents";
|
|
20
|
+
import { asAddedFileDiff, batchesOf, portStatusIn, renderPilotReport } from "@llm4ts/flow/Port";
|
|
21
|
+
import { adversarialReviewer, reviewAndFixLoop } from "@llm4ts/flow/Review";
|
|
22
|
+
import { runQueue } from "@llm4ts/flow/WorkQueue";
|
|
23
|
+
import { asReadOnly, coderFromEnv, makeNodeWorkspace, nodePlainFileStore, openPack, resolveFlowInput, runFlowMain, runNode, stage } from "@llm4ts/runner";
|
|
24
|
+
import { asPortingPack, implementerPrompt, implementerSystem, portEnv, portFixerSystem, portManifest, portStateDir } from "./lib/port.js";
|
|
25
|
+
const renderReport = (outcomes, total, pending) => {
|
|
26
|
+
const done = outcomes.filter((outcome) => outcome.status === "done");
|
|
27
|
+
const failed = outcomes.filter((outcome) => outcome.status === "failed");
|
|
28
|
+
const by = (level) => done.filter((outcome) => outcome.confidence === level).length;
|
|
29
|
+
const low = done.filter((outcome) => outcome.confidence === "low");
|
|
30
|
+
return [
|
|
31
|
+
"# Port report",
|
|
32
|
+
"",
|
|
33
|
+
`- sources: ${total}; drafted this run: ${done.length}; failed: ${failed.length}; still pending: ${pending}`,
|
|
34
|
+
`- confidence: ${by("high")} high, ${by("medium")} medium, ${by("low")} low; TODO(port) markers: ${done.reduce((sum, outcome) => sum + (outcome.todos ?? 0), 0)}`,
|
|
35
|
+
"",
|
|
36
|
+
...(low.length === 0
|
|
37
|
+
? []
|
|
38
|
+
: [
|
|
39
|
+
"## Re-read against the source first (confidence: low)",
|
|
40
|
+
"",
|
|
41
|
+
...low.map((outcome) => `- ${outcome.id}${outcome.note === undefined ? "" : ` — ${outcome.note}`}`),
|
|
42
|
+
""
|
|
43
|
+
]),
|
|
44
|
+
...(failed.length === 0
|
|
45
|
+
? []
|
|
46
|
+
: ["## Failed", "", ...failed.map((outcome) => `- ${outcome.id}: ${outcome.note ?? ""}`), ""])
|
|
47
|
+
].join("\n");
|
|
48
|
+
};
|
|
49
|
+
const program = Effect.gen(function* () {
|
|
50
|
+
const input = yield* resolveFlowInput("Port every source file the pack matches to its target");
|
|
51
|
+
const coder = coderFromEnv(process.env);
|
|
52
|
+
const files = nodePlainFileStore;
|
|
53
|
+
const knobs = portEnv(process.env);
|
|
54
|
+
const stateDir = portStateDir(input.workDir);
|
|
55
|
+
const pilotPath = join(stateDir, "pilot.md");
|
|
56
|
+
yield* runNode({
|
|
57
|
+
workDir: input.workDir,
|
|
58
|
+
workspace: input.workspace,
|
|
59
|
+
userPrompt: input.prompt,
|
|
60
|
+
coder,
|
|
61
|
+
reasoning: asReadOnly(coder),
|
|
62
|
+
reviewers: [asReadOnly(coder)],
|
|
63
|
+
environment: process.env
|
|
64
|
+
}, (context) => Effect.gen(function* () {
|
|
65
|
+
const events = context.events;
|
|
66
|
+
const repo = yield* makeNodeWorkspace(input.workDir);
|
|
67
|
+
const opened = yield* stage(events, "pack", openPack({
|
|
68
|
+
environment: process.env,
|
|
69
|
+
launchDir: input.workspace,
|
|
70
|
+
flowDir: import.meta.dirname
|
|
71
|
+
}));
|
|
72
|
+
const porting = yield* asPortingPack(opened);
|
|
73
|
+
const manifest = yield* stage(events, "manifest", portManifest(repo, files, input.workDir, porting));
|
|
74
|
+
const exists = (target) => Effect.map(files.read(join(input.workDir, target)), (text) => text !== undefined);
|
|
75
|
+
const pending = [];
|
|
76
|
+
for (const entry of manifest) {
|
|
77
|
+
if (!(yield* exists(entry.target)))
|
|
78
|
+
pending.push(entry);
|
|
79
|
+
}
|
|
80
|
+
yield* events.publish(Info.make({
|
|
81
|
+
message: `port: ${manifest.length} source(s), ${manifest.length - pending.length} already ported, ${pending.length} pending (pack ${porting.pack.name}, target ${porting.target})`
|
|
82
|
+
}));
|
|
83
|
+
if (knobs.pilot === 0 && (yield* files.read(pilotPath)) !== undefined) {
|
|
84
|
+
// A pilot ran: the rest of the port waits for its approval (R7).
|
|
85
|
+
yield* requireApproval(files, pilotPath);
|
|
86
|
+
}
|
|
87
|
+
const items = knobs.pilot > 0 ? pending.slice(0, knobs.pilot) : pending;
|
|
88
|
+
const batches = batchesOf(items, { files: knobs.batch });
|
|
89
|
+
const started = yield* Clock.currentTimeMillis;
|
|
90
|
+
const outcomes = [];
|
|
91
|
+
for (const [index, batch] of batches.entries()) {
|
|
92
|
+
const report = yield* runQueue({
|
|
93
|
+
label: `port batch ${index + 1}/${batches.length}`,
|
|
94
|
+
items: batch,
|
|
95
|
+
done: (entry) => exists(entry.target),
|
|
96
|
+
concurrency: knobs.concurrency,
|
|
97
|
+
maxRounds: 2,
|
|
98
|
+
events,
|
|
99
|
+
ledger: { files, path: join(stateDir, "ledger.jsonl") },
|
|
100
|
+
afterRound: (round, done) => done.length === 0
|
|
101
|
+
? Effect.void
|
|
102
|
+
: Effect.asVoid(context.git.commitPaths(`port: ${done.length} file(s) drafted (batch ${index + 1}, round ${round})`, done.map((entry) => entry.target))),
|
|
103
|
+
work: (entry) => Effect.gen(function* () {
|
|
104
|
+
const source = (yield* files.read(join(input.workDir, entry.source))) ?? "";
|
|
105
|
+
const chat = yield* makeChat(context.coder, {
|
|
106
|
+
system: implementerSystem(porting),
|
|
107
|
+
events,
|
|
108
|
+
agent: "coder",
|
|
109
|
+
stall: { repeats: 5 }
|
|
110
|
+
});
|
|
111
|
+
yield* chat.ask(implementerPrompt(entry, source, porting, knobs.sourceChars));
|
|
112
|
+
const targetPath = join(input.workDir, entry.target);
|
|
113
|
+
if ((yield* files.read(targetPath)) === undefined) {
|
|
114
|
+
return { note: `nothing written at ${entry.target}` };
|
|
115
|
+
}
|
|
116
|
+
yield* reviewAndFixLoop({
|
|
117
|
+
reviewers: [adversarialReviewer, ...porting.pack.lenses],
|
|
118
|
+
reviewerService: context.reviewers[0] ?? context.reasoning,
|
|
119
|
+
coder: chat,
|
|
120
|
+
taskTitle: `port ${entry.source}`,
|
|
121
|
+
currentDiff: Effect.map(files.read(targetPath), (text) => asAddedFileDiff(entry.target, text ?? "")),
|
|
122
|
+
events,
|
|
123
|
+
maxRounds: 2,
|
|
124
|
+
votes: knobs.votes,
|
|
125
|
+
fixWith: (prompt) => Effect.flatMap(makeChat(context.coder, {
|
|
126
|
+
system: portFixerSystem(porting),
|
|
127
|
+
events,
|
|
128
|
+
agent: "coder",
|
|
129
|
+
stall: { repeats: 5 }
|
|
130
|
+
}), (fixer) => fixer.ask(`${prompt}\n\nThe ported file is ${entry.target}; its source is ${entry.source}.`))
|
|
131
|
+
});
|
|
132
|
+
const status = portStatusIn((yield* files.read(targetPath)) ?? "");
|
|
133
|
+
return {
|
|
134
|
+
...(status.confidence === undefined ? {} : { confidence: status.confidence }),
|
|
135
|
+
...(status.todos === undefined ? {} : { todos: status.todos }),
|
|
136
|
+
...(status.notes === undefined || status.notes === "none"
|
|
137
|
+
? {}
|
|
138
|
+
: { note: status.notes })
|
|
139
|
+
};
|
|
140
|
+
})
|
|
141
|
+
});
|
|
142
|
+
outcomes.push(...report.outcomes);
|
|
143
|
+
}
|
|
144
|
+
const stillPending = pending.length - outcomes.filter((outcome) => outcome.status === "done").length;
|
|
145
|
+
yield* files.writeAtomic(join(stateDir, "report.md"), renderReport(outcomes, manifest.length, stillPending));
|
|
146
|
+
if (knobs.pilot > 0) {
|
|
147
|
+
const pilot = renderPilotReport({
|
|
148
|
+
outcomes,
|
|
149
|
+
piloted: items.length,
|
|
150
|
+
remaining: pending.length - items.length,
|
|
151
|
+
elapsedMs: (yield* Clock.currentTimeMillis) - started,
|
|
152
|
+
estimatedCostUsd: undefined
|
|
153
|
+
});
|
|
154
|
+
yield* files.writeAtomic(pilotPath, pilot);
|
|
155
|
+
yield* events.publish(Info.make({
|
|
156
|
+
message: `port: pilot of ${items.length} file(s) done — read ${pilotPath}, tick \`${ApprovedMarker}\`, then run again without LLM4TS_PORT_PILOT to port the remaining ${pending.length - items.length}`
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
yield* events.publish(Info.make({
|
|
161
|
+
message: `port: ${outcomes.filter((o) => o.status === "done").length} drafted, ${stillPending} still pending — report at ${join(stateDir, "report.md")}`
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
}));
|
|
165
|
+
});
|
|
166
|
+
runFlowMain(program);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# port
|
|
2
|
+
|
|
3
|
+
Language-to-language ports, file by file, the way Bun was rewritten from Zig
|
|
4
|
+
to Rust in May 2026 (ADR 0028): a rulebook the human writes with the model, a
|
|
5
|
+
pilot of a few files behind an approval, then every source file drafted at its
|
|
6
|
+
target path by one implementer with exactly one source in view, reviewed by
|
|
7
|
+
two adversarial votes, fixed by a separate fixer, ending in a `PORT STATUS`
|
|
8
|
+
trailer; then compiler diagnostics worked as a queue, one unit per fixer, one
|
|
9
|
+
rebuild per round. The source stays in the tree as the spec: this kit is not
|
|
10
|
+
clean-room.
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
kits/port/
|
|
14
|
+
packs/zig-rust/ the reference pair, distilled from the Bun port
|
|
15
|
+
pack.md sources:, target:, comment:, ## Gates, ## Diagnostics
|
|
16
|
+
prompts/porting.md the rulebook every implementer reads whole
|
|
17
|
+
reviewers/ lenses that diff source and target
|
|
18
|
+
patterns/pitfalls-zig-rust.md syntactically alike, semantically different
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Flows: `port-files` (drafts), `port-compile` (diagnostics as a queue). A
|
|
22
|
+
pack for another pair copies `zig-rust` and replaces the rulebook, the
|
|
23
|
+
pitfall card, `target:` and the diagnostics command.
|
|
24
|
+
|
|
25
|
+
| Pack | Source → target | Diagnostics |
|
|
26
|
+
| ---------- | --------------- | ----------------------------------- |
|
|
27
|
+
| `zig-rust` | Zig → Rust | `cargo check --message-format=json` |
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Pack: zig-rust
|
|
2
|
+
|
|
3
|
+
source: zig
|
|
4
|
+
sources: .*\.zig$
|
|
5
|
+
exclude: (^|/)(zig-out|zig-cache|\.zig-cache|node_modules|vendor)/
|
|
6
|
+
target: {{dir}}/{{base}}.rs
|
|
7
|
+
comment: //
|
|
8
|
+
specs-dir: docs/port
|
|
9
|
+
features-dir: docs/port/features
|
|
10
|
+
|
|
11
|
+
## Gates
|
|
12
|
+
|
|
13
|
+
- check: cargo check --workspace
|
|
14
|
+
|
|
15
|
+
## Diagnostics
|
|
16
|
+
|
|
17
|
+
- command: cargo check --workspace --message-format=json
|
|
18
|
+
- format: cargo
|
|
19
|
+
|
|
20
|
+
## Review rules
|
|
21
|
+
|
|
22
|
+
Every `unsafe` block carries a `// SAFETY: <why>` comment, and no new
|
|
23
|
+
`unsafe` appears outside FFI. A `TODO(port): <reason>` marker is the right
|
|
24
|
+
answer where the translation is uncertain; a guess is not. `anyhow`,
|
|
25
|
+
`tokio`, `rayon`, `async fn` and `std::fs`/`std::net`/`std::process` are
|
|
26
|
+
findings: the rulebook forbids them.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Zig → Rust pitfalls — syntactically alike, semantically different
|
|
3
|
+
matches: .
|
|
4
|
+
tags: port, zig, rust
|
|
5
|
+
---
|
|
6
|
+
The regressions the Bun port shipped (May 2026) were code that reads the same
|
|
7
|
+
in both languages and does something different. Check for each:
|
|
8
|
+
|
|
9
|
+
1. **Asserts with side effects.** Zig's `std.debug.assert(f())` always calls
|
|
10
|
+
`f` in every build; Rust's `debug_assert!(f())` erases the call in release.
|
|
11
|
+
Keep the call, assert the result.
|
|
12
|
+
2. **Slice casts on odd lengths.** The Zig helper truncated a `[]u8` to the
|
|
13
|
+
element size; `bytemuck::cast_slice` panics on a remainder. Truncate first.
|
|
14
|
+
3. **Bounds checks kept.** Zig ReleaseFast dropped them; Rust release keeps
|
|
15
|
+
them, so a latent off-by-one the Zig never hit is reachable. Treat an
|
|
16
|
+
index panic as a real bug in the source's logic, not as noise.
|
|
17
|
+
4. **Comptime format strings.** `comptime` string assembly happened once at
|
|
18
|
+
build time; a runtime `format!` per call changes both cost and, where
|
|
19
|
+
markers are rewritten, the bytes. Use a macro or a `const`.
|
|
20
|
+
5. **Placeholder constants.** A value "to be threaded through later" becomes
|
|
21
|
+
a different limit in the port. Every `TODO(port)` placeholder is listed in
|
|
22
|
+
the trailer and closed in the compile pass.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
You are translating one Zig file to Rust. Read this whole document before
|
|
2
|
+
writing any code. The goal of the first pass is a **draft** `.rs` beside the
|
|
3
|
+
`.zig`, same basename, that captures the logic faithfully; it does **not**
|
|
4
|
+
need to compile. The compile pass makes it compile crate by crate.
|
|
5
|
+
|
|
6
|
+
## Ground rules
|
|
7
|
+
|
|
8
|
+
- Write the `.rs` in the same directory as the `.zig`, same basename. Do not
|
|
9
|
+
invent crate layouts; put the file where the source is.
|
|
10
|
+
- Match the Zig's structure: same `fn` names (snake_case), same field order,
|
|
11
|
+
same control flow. Reviewers diff `.zig` and `.rs` side by side.
|
|
12
|
+
- No `tokio`, `rayon`, `hyper`, `async-trait`, `futures`. No `std::fs`,
|
|
13
|
+
`std::net`, `std::process`: the code base owns its event loop and
|
|
14
|
+
syscalls. No `async fn`.
|
|
15
|
+
- `unsafe` is fine where the Zig was already unsafe. Annotate every block
|
|
16
|
+
with `// SAFETY: <why>`.
|
|
17
|
+
- Leave `// TODO(port): <reason>` for anything you cannot translate
|
|
18
|
+
confidently. Do not guess: flagging is better than wrong code.
|
|
19
|
+
- Leave `// PERF(port): <zig idiom>` wherever the Zig used a
|
|
20
|
+
performance-specific idiom, for the profiling pass.
|
|
21
|
+
- Do not translate tests, build scripts or comptime-only helpers that exist
|
|
22
|
+
to drive the Zig build; note them as `// SKIPPED(port): <what>`.
|
|
23
|
+
|
|
24
|
+
## Type map
|
|
25
|
+
|
|
26
|
+
| Zig | Rust |
|
|
27
|
+
| ------------------------- | ------------------------------------- |
|
|
28
|
+
| `[]const u8` | `&[u8]` (or `&str` only when the Zig guarantees UTF-8) |
|
|
29
|
+
| `[]u8` owned | `Vec<u8>` |
|
|
30
|
+
| `?T` | `Option<T>` |
|
|
31
|
+
| `anyerror!T`, `E!T` | `Result<T, Error>` with the crate's error enum, never `anyhow` |
|
|
32
|
+
| `*T` | `&mut T` or `*mut T` per the lifetime table |
|
|
33
|
+
| `*const T` | `&T` or `*const T` per the lifetime table |
|
|
34
|
+
| `std.ArrayList(T)` | `Vec<T>` |
|
|
35
|
+
| `std.StringHashMap(V)` | `HashMap<Vec<u8>, V>` (the crate's hasher) |
|
|
36
|
+
| `comptime T: type` | a generic parameter or a trait |
|
|
37
|
+
| `switch` on tagged union | `match` on an enum with data |
|
|
38
|
+
| `defer` | a scope guard or `Drop` |
|
|
39
|
+
| `errdefer` | explicit cleanup on the `Err` path |
|
|
40
|
+
|
|
41
|
+
## Idiom map
|
|
42
|
+
|
|
43
|
+
- Allocator parameters: delete them outside the AST crates; the Rust side
|
|
44
|
+
allocates through the type's own methods.
|
|
45
|
+
- `std.debug.assert(expr)` where `expr` has side effects: keep the side
|
|
46
|
+
effect, assert the value (`debug_assert!` erases the call).
|
|
47
|
+
- Casting slices (`@ptrCast`, `@alignCast`) on odd lengths: the Zig helper
|
|
48
|
+
truncated; `bytemuck::cast_slice` panics. Truncate explicitly.
|
|
49
|
+
- Bounds checks the Zig dropped in ReleaseFast stay in Rust; do not
|
|
50
|
+
"optimise" with `get_unchecked` in the draft.
|
|
51
|
+
- `comptime` format strings and string builders become macros or
|
|
52
|
+
`const` arrays, never runtime strings assembled per call.
|
|
53
|
+
|
|
54
|
+
## Output format
|
|
55
|
+
|
|
56
|
+
End the file with the trailer the flow reads:
|
|
57
|
+
|
|
58
|
+
```rust
|
|
59
|
+
// PORT STATUS
|
|
60
|
+
// source: <path>.zig
|
|
61
|
+
// confidence: high | medium | low
|
|
62
|
+
// todos: <count of TODO(port)>
|
|
63
|
+
// notes: <one line>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`confidence: low` means "the logic is probably wrong; re-read the Zig in the
|
|
67
|
+
compile pass".
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
files: \.rs$
|
|
3
|
+
---
|
|
4
|
+
Review a Rust draft against the rulebook as a port reviewer would, with the
|
|
5
|
+
Zig source in mind: the diff is the draft. Report as findings: a function,
|
|
6
|
+
field or branch the source has that the draft lacks (dropped logic); a
|
|
7
|
+
`pub fn deinit(&mut self)` where `impl Drop` is the rulebook's answer;
|
|
8
|
+
`anyhow::Error` or `Box<dyn Error>` where the crate's error enum belongs;
|
|
9
|
+
a bare `as` narrowing cast; an `unsafe` block without a `// SAFETY:` line;
|
|
10
|
+
`async fn`, `tokio`, `rayon`, `std::fs`, `std::net` or `std::process`; a
|
|
11
|
+
`debug_assert!` whose argument had a side effect in the source; a guessed
|
|
12
|
+
translation where a `TODO(port)` was the honest answer. Do not report imports
|
|
13
|
+
that cannot resolve yet or lifetimes: the compile pass owns those.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/shell",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.33.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",
|
|
54
54
|
"@effect/platform-node-shared": "4.0.0",
|
|
55
|
-
"@llm4ts/core": "2.
|
|
56
|
-
"@llm4ts/flow": "2.
|
|
57
|
-
"@llm4ts/runner": "2.
|
|
55
|
+
"@llm4ts/core": "2.33.0",
|
|
56
|
+
"@llm4ts/flow": "2.33.0",
|
|
57
|
+
"@llm4ts/runner": "2.33.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"effect": "4.0.0"
|