@llm4ts/flow 2.32.0 → 2.34.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/src/Pack.ts CHANGED
@@ -79,6 +79,44 @@ export interface Pack {
79
79
  * pack lens carries after the shared preamble, or `- preamble: off`.
80
80
  */
81
81
  readonly reviewRules: { readonly text: string; readonly preamble: boolean } | undefined
82
+ /**
83
+ * `target:` (ADR 0028): the template of a source file's port
84
+ * (`{{dir}}/{{base}}.rs`); present only in a porting pack.
85
+ */
86
+ readonly target: string | undefined
87
+ /** `comment:` (ADR 0028): the target language's line-comment marker for the PORT STATUS trailer; default `//`. */
88
+ readonly comment: string | undefined
89
+ /**
90
+ * `## Diagnostics` (ADR 0028): `- command: …` prints what is red after a
91
+ * build, `- format: json | cargo` says how to read it. Present only in a
92
+ * porting pack.
93
+ */
94
+ /**
95
+ * `## Ledger` (ADR 0028): what `port-ledger` classifies. `- unit: <regex>`
96
+ * whose first capture group names a unit in a source file, `- classes: A,
97
+ * B, UNKNOWN`, `- question: <what to decide for each unit>`.
98
+ */
99
+ readonly ledger:
100
+ | { readonly unit: string; readonly classes: ReadonlyArray<string>; readonly question: string }
101
+ | undefined
102
+ /**
103
+ * `## Differential` (ADR 0028): what `port-tests` compares. `- tests: <regex>`
104
+ * over test files, `- legacy: <command with {{file}}>` run on the old
105
+ * build, `- target: <command with {{file}}>` on the new, `- timeout: <s>`.
106
+ */
107
+ readonly differential:
108
+ | {
109
+ readonly tests: string
110
+ readonly legacy: ReadonlyArray<string>
111
+ readonly target: ReadonlyArray<string>
112
+ readonly timeoutSeconds: number
113
+ }
114
+ | undefined
115
+ /** `## Audit` (ADR 0028): `- dimensions: a, b, c` the rulebook auditors take; default list in the flow. */
116
+ readonly audit: ReadonlyArray<string> | undefined
117
+ readonly diagnostics:
118
+ | { readonly command: ReadonlyArray<string>; readonly format: "json" | "cargo" | "tsc" }
119
+ | undefined
82
120
  readonly dir: string
83
121
  readonly gate: (name: string) => ReadonlyArray<string> | undefined
84
122
  readonly prompt: (name: string) => string | undefined
@@ -305,6 +343,42 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
305
343
  message: `pack manifest 'spec-schema:' must be 'pagespec' when set, got: ${specSchema}`
306
344
  })
307
345
  }
346
+ const diagnosticsValues = namedItems(section(manifest.sections, "Diagnostics"))
347
+ const diagnostics =
348
+ diagnosticsValues.command === undefined
349
+ ? undefined
350
+ : {
351
+ command: diagnosticsValues.command.split(/\s+/),
352
+ format:
353
+ diagnosticsValues.format?.toLowerCase() === "cargo"
354
+ ? ("cargo" as const)
355
+ : diagnosticsValues.format?.toLowerCase() === "tsc"
356
+ ? ("tsc" as const)
357
+ : ("json" as const)
358
+ }
359
+ const ledgerValues = namedItems(section(manifest.sections, "Ledger"))
360
+ const ledger =
361
+ ledgerValues.unit === undefined
362
+ ? undefined
363
+ : {
364
+ unit: ledgerValues.unit,
365
+ classes: commaList(ledgerValues.classes),
366
+ question: ledgerValues.question ?? "Classify this unit."
367
+ }
368
+ const differentialValues = namedItems(section(manifest.sections, "Differential"))
369
+ const differential =
370
+ differentialValues.tests === undefined ||
371
+ differentialValues.legacy === undefined ||
372
+ differentialValues.target === undefined
373
+ ? undefined
374
+ : {
375
+ tests: differentialValues.tests,
376
+ legacy: differentialValues.legacy.split(/\s+/),
377
+ target: differentialValues.target.split(/\s+/),
378
+ timeoutSeconds: Number.parseInt(differentialValues.timeout ?? "", 10) || 120
379
+ }
380
+ const auditBody = section(manifest.sections, "Audit")
381
+ const audit = auditBody === undefined ? undefined : commaList(namedItems(auditBody).dimensions)
308
382
  const oracleValues = namedItems(section(manifest.sections, "Oracle"))
309
383
  const oracle: OracleRules | undefined =
310
384
  section(manifest.sections, "Oracle") === undefined
@@ -379,6 +453,12 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
379
453
  consolidate,
380
454
  oracle,
381
455
  reviewRules,
456
+ target: fields["target"],
457
+ comment: fields["comment"],
458
+ ledger,
459
+ differential,
460
+ audit,
461
+ diagnostics,
382
462
  dir: directory,
383
463
  gate: (name) => gates[name],
384
464
  prompt: (name) => prompts[name],
package/src/Port.ts ADDED
@@ -0,0 +1,429 @@
1
+ // Porting a code base file by file (ADR 0028): the manifest, its batches, the
2
+ // deterministic target path, the machine-readable PORT STATUS trailer every
3
+ // draft ends with, and the pilot report. Pure; the flows own the seats.
4
+ import { basename, dirname, extname } from "node:path"
5
+ import * as Schema from "effect/Schema"
6
+ import type { ReviewResult } from "./Review.ts"
7
+ import { DraftApprovalMarker } from "./Approval.ts"
8
+ import type { QueueOutcome } from "./WorkQueue.ts"
9
+
10
+ export class PortEntry extends Schema.Class<PortEntry>("PortEntry")({
11
+ /** Repo-relative source path; also the queue item id. */
12
+ id: Schema.String,
13
+ source: Schema.String,
14
+ target: Schema.String,
15
+ loc: Schema.Int
16
+ }) {}
17
+
18
+ /**
19
+ * The target path of a source, from the pack's `target:` template:
20
+ * `{{dir}}` (the source's directory, "" at the root), `{{base}}` (its name
21
+ * without extension), `{{ext}}` (the extension without the dot), `{{path}}`
22
+ * (the whole source path).
23
+ */
24
+ export const targetPathOf = (template: string, source: string): string => {
25
+ const dir = dirname(source)
26
+ const ext = extname(source)
27
+ const rendered = template
28
+ .replaceAll("{{dir}}", dir === "." ? "" : dir)
29
+ .replaceAll("{{base}}", basename(source, ext))
30
+ .replaceAll("{{ext}}", ext.replace(/^\./u, ""))
31
+ .replaceAll("{{path}}", source)
32
+ return rendered.replace(/^\/+/u, "").replaceAll("//", "/")
33
+ }
34
+
35
+ export const linesOf = (text: string): number =>
36
+ text.length === 0 ? 0 : text.split(/\r?\n/u).filter((line) => line.trim().length > 0).length
37
+
38
+ export interface BatchPolicy {
39
+ /** Files per batch. Default 100. */
40
+ readonly files?: number
41
+ /** A batch whose first file is longer than this (lines) shrinks to `small`. Default 2200. */
42
+ readonly bigLoc?: number
43
+ /** Default 6. */
44
+ readonly small?: number
45
+ }
46
+
47
+ /** Contiguous batches of the manifest; a batch led by a big file is small (the Bun rule). */
48
+ export const batchesOf = (
49
+ entries: ReadonlyArray<PortEntry>,
50
+ policy: BatchPolicy = {}
51
+ ): ReadonlyArray<ReadonlyArray<PortEntry>> => {
52
+ const files = Math.max(1, policy.files ?? 100)
53
+ const bigLoc = policy.bigLoc ?? 2_200
54
+ const small = Math.max(1, policy.small ?? 6)
55
+ const batches: Array<ReadonlyArray<PortEntry>> = []
56
+ let index = 0
57
+ while (index < entries.length) {
58
+ const first = entries[index]
59
+ const size = first !== undefined && first.loc > bigLoc ? small : files
60
+ batches.push(entries.slice(index, index + size))
61
+ index += size
62
+ }
63
+ return batches
64
+ }
65
+
66
+ export const PortConfidence = Schema.Literals(["high", "medium", "low"])
67
+ export type PortConfidence = typeof PortConfidence.Type
68
+
69
+ export interface PortStatus {
70
+ readonly confidence: PortConfidence | undefined
71
+ readonly todos: number | undefined
72
+ readonly notes: string | undefined
73
+ }
74
+
75
+ const statusWindow = 40
76
+
77
+ /**
78
+ * The PORT STATUS trailer of a draft: in any comment syntax, within the last
79
+ * lines of the file, `confidence: high|medium|low`, `todos: <n>`, `notes: …`.
80
+ * All undefined when the draft has no trailer.
81
+ */
82
+ export const portStatusIn = (text: string): PortStatus => {
83
+ const lines = text.split(/\r?\n/u)
84
+ const tail = lines.slice(Math.max(0, lines.length - statusWindow))
85
+ const start = tail.findIndex((line) => /PORT STATUS/u.test(line))
86
+ if (start < 0) {
87
+ return { confidence: undefined, todos: undefined, notes: undefined }
88
+ }
89
+ const block = tail.slice(start).join("\n")
90
+ const confidence = /confidence:\s*(high|medium|low)\b/iu.exec(block)?.[1]?.toLowerCase()
91
+ const todos = /todos?:\s*(\d+)/iu.exec(block)?.[1]
92
+ const notes = /notes?:\s*(.+)$/imu.exec(block)?.[1]?.trim()
93
+ return {
94
+ confidence:
95
+ confidence === "high" || confidence === "medium" || confidence === "low"
96
+ ? confidence
97
+ : undefined,
98
+ todos: todos === undefined ? undefined : Number.parseInt(todos, 10),
99
+ notes: notes === undefined || notes.length === 0 ? undefined : notes.replace(/\s*\*\/\s*$/u, "")
100
+ }
101
+ }
102
+
103
+ /** The instruction every implementer gets: how to end the file. */
104
+ export const portStatusInstruction = (comment: string): string =>
105
+ [
106
+ `End the file with a machine-readable trailer in ${comment} comments, exactly this shape:`,
107
+ `${comment} PORT STATUS`,
108
+ `${comment} source: <the source path>`,
109
+ `${comment} confidence: high | medium | low`,
110
+ `${comment} todos: <number of TODO(port) markers you left>`,
111
+ `${comment} notes: <one line: what you could not translate faithfully, or "none">`,
112
+ "`confidence: low` means the logic is probably wrong and must be re-read against the source."
113
+ ].join("\n")
114
+
115
+ export interface PilotFacts {
116
+ readonly outcomes: ReadonlyArray<QueueOutcome>
117
+ readonly piloted: number
118
+ readonly remaining: number
119
+ readonly elapsedMs: number
120
+ readonly estimatedCostUsd: number | undefined
121
+ }
122
+
123
+ const minutes = (ms: number): string => `${(ms / 60_000).toFixed(1)} min`
124
+
125
+ /** The pilot report (R7 of the plan): rates measured, totals extrapolated, an approval to continue. */
126
+ export const renderPilotReport = (facts: PilotFacts): string => {
127
+ const done = facts.outcomes.filter((outcome) => outcome.status === "done")
128
+ const failed = facts.outcomes.filter((outcome) => outcome.status === "failed")
129
+ const count = (level: PortConfidence) =>
130
+ done.filter((outcome) => outcome.confidence === level).length
131
+ const todos = done.reduce((sum, outcome) => sum + (outcome.todos ?? 0), 0)
132
+ const perItemMs = facts.piloted === 0 ? 0 : facts.elapsedMs / facts.piloted
133
+ const perItemCost =
134
+ facts.estimatedCostUsd === undefined || facts.piloted === 0
135
+ ? undefined
136
+ : facts.estimatedCostUsd / facts.piloted
137
+ return [
138
+ "# Port pilot",
139
+ "",
140
+ `- piloted: ${facts.piloted} file(s), ${done.length} done, ${failed.length} failed, in ${minutes(facts.elapsedMs)}`,
141
+ `- confidence: ${count("high")} high, ${count("medium")} medium, ${count("low")} low; ${todos} TODO(port) marker(s)`,
142
+ ...(perItemCost === undefined
143
+ ? []
144
+ : [
145
+ `- cost: ~$${(facts.estimatedCostUsd ?? 0).toFixed(2)} (~$${perItemCost.toFixed(3)} per file)`
146
+ ]),
147
+ `- remaining: ${facts.remaining} file(s) → about ${minutes(perItemMs * facts.remaining)} at this rate${
148
+ perItemCost === undefined ? "" : ` and ~$${(perItemCost * facts.remaining).toFixed(2)}`
149
+ }`,
150
+ "",
151
+ ...(failed.length === 0
152
+ ? []
153
+ : [
154
+ "## Failed",
155
+ "",
156
+ ...failed.map((outcome) => `- ${outcome.id}: ${outcome.note ?? ""}`),
157
+ ""
158
+ ]),
159
+ "Read the drafted files beside their sources, then tick the box to let the next run",
160
+ "port the rest; edit `prompts/porting.md` in the pack first if the drafts show a",
161
+ "pattern the rulebook should settle.",
162
+ "",
163
+ DraftApprovalMarker,
164
+ ""
165
+ ].join("\n")
166
+ }
167
+
168
+ /** A whole new file rendered as a unified diff of additions, for reviewers that judge diffs. */
169
+ export const asAddedFileDiff = (path: string, content: string): string => {
170
+ const lines = content.split(/\r?\n/u)
171
+ return [
172
+ `diff --git a/${path} b/${path}`,
173
+ "new file mode 100644",
174
+ "--- /dev/null",
175
+ `+++ b/${path}`,
176
+ `@@ -0,0 +1,${lines.length} @@`,
177
+ ...lines.map((line) => `+${line}`)
178
+ ].join("\n")
179
+ }
180
+
181
+ // ---- Ledger (port-ledger) --------------------------------------------------------------
182
+
183
+ /** One classified unit of a source file, with the line that proves it. */
184
+ export class LedgerRow extends Schema.Class<LedgerRow>("LedgerRow")({
185
+ file: Schema.String,
186
+ unit: Schema.String,
187
+ class: Schema.String,
188
+ /** `file:line` of the statement the classification rests on. */
189
+ evidence: Schema.String,
190
+ confidence: PortConfidence
191
+ }) {}
192
+
193
+ const tsvEscape = (value: string): string => value.replaceAll("\t", " ").replaceAll("\n", " ")
194
+
195
+ /** The ledger as a TSV with a header: `file unit class evidence confidence`. */
196
+ export const renderLedger = (rows: ReadonlyArray<LedgerRow>): string =>
197
+ [
198
+ "file\tunit\tclass\tevidence\tconfidence",
199
+ ...rows.map((row) =>
200
+ [row.file, row.unit, row.class, row.evidence, row.confidence].map(tsvEscape).join("\t")
201
+ )
202
+ ].join("\n") + "\n"
203
+
204
+ export const parseLedger = (text: string): ReadonlyArray<LedgerRow> =>
205
+ text
206
+ .split(/\r?\n/u)
207
+ .slice(1)
208
+ .filter((line) => line.trim().length > 0)
209
+ .flatMap((line) => {
210
+ const [file, unit, klass, evidence, confidence] = line.split("\t")
211
+ return file === undefined || unit === undefined || klass === undefined
212
+ ? []
213
+ : [
214
+ LedgerRow.make({
215
+ file,
216
+ unit,
217
+ class: klass,
218
+ evidence: evidence ?? "",
219
+ confidence:
220
+ confidence === "high" || confidence === "medium" || confidence === "low"
221
+ ? confidence
222
+ : "low"
223
+ })
224
+ ]
225
+ })
226
+
227
+ /** The rows of one source file, rendered for its implementer ("trust the table over local guessing"). */
228
+ export const ledgerRowsFor = (rows: ReadonlyArray<LedgerRow>, file: string): string | undefined => {
229
+ const mine = rows.filter((row) => row.file === file)
230
+ return mine.length === 0
231
+ ? undefined
232
+ : mine
233
+ .map((row) => `- ${row.unit}: ${row.class} (${row.evidence}; ${row.confidence})`)
234
+ .join("\n")
235
+ }
236
+
237
+ /** The units a pack's `## Ledger` regex names in a source file, each once. */
238
+ export const unitsIn = (text: string, unitRegex: string): ReadonlyArray<string> => {
239
+ const pattern = new RegExp(unitRegex, "gmu")
240
+ const units: Array<string> = []
241
+ for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
242
+ const name = match.slice(1).find((group) => group !== undefined && group.length > 0)
243
+ if (name !== undefined && !units.includes(name)) units.push(name)
244
+ if (match[0].length === 0) pattern.lastIndex += 1
245
+ }
246
+ return units
247
+ }
248
+
249
+ // ---- Refutes (port-guide, port-ledger) ----------------------------------------------------
250
+
251
+ /** A claim stands unless a majority of its refuters reject it (the Bun port's 3-vote refute). */
252
+ export const standsAfterRefutes = (votes: ReadonlyArray<boolean>): boolean => {
253
+ const rejected = votes.filter((holds) => !holds).length
254
+ return votes.length === 0 ? true : rejected * 2 < votes.length
255
+ }
256
+
257
+ // ---- Guide audit (port-guide) -------------------------------------------------------------
258
+
259
+ export class GuideFinding extends Schema.Class<GuideFinding>("GuideFinding")({
260
+ dimension: Schema.String,
261
+ finding: Schema.String,
262
+ /** A rulebook line, a sample source line, or a draft line that shows it. */
263
+ evidence: Schema.String,
264
+ /** The rule the rulebook should gain or change, one line. */
265
+ proposedRule: Schema.String
266
+ }) {}
267
+
268
+ export const defaultAuditDimensions: ReadonlyArray<string> = [
269
+ "error model",
270
+ "ownership and memory",
271
+ "collections and strings",
272
+ "control flow and compile-time constructs",
273
+ "API shape and naming",
274
+ "concurrency and resources",
275
+ "build, test and tooling idioms",
276
+ "what not to translate"
277
+ ]
278
+
279
+ export const renderGuideAudit = (facts: {
280
+ readonly pack: string
281
+ readonly kept: ReadonlyArray<GuideFinding>
282
+ readonly dropped: ReadonlyArray<GuideFinding>
283
+ readonly trial: ReadonlyArray<string>
284
+ readonly sample: ReadonlyArray<string>
285
+ }): string =>
286
+ [
287
+ `# Rulebook audit — pack ${facts.pack}`,
288
+ "",
289
+ `- sample files: ${facts.sample.join(", ") || "none"}`,
290
+ `- findings kept after the refute: ${facts.kept.length}; dropped: ${facts.dropped.length}`,
291
+ "",
292
+ ...(facts.kept.length === 0
293
+ ? ["No finding survived: the rulebook stands as written.", ""]
294
+ : [
295
+ "## Proposed rules",
296
+ "",
297
+ "Each is an `append-rule` to `prompts/porting.md`, applied on the next run once approved.",
298
+ "",
299
+ ...facts.kept.flatMap((finding) => [
300
+ `### ${finding.dimension}`,
301
+ "",
302
+ `**Finding.** ${finding.finding.trim()}`,
303
+ "",
304
+ `**Evidence.** ${finding.evidence.trim()}`,
305
+ "",
306
+ "```diff",
307
+ `+ - ${finding.proposedRule.trim()}`,
308
+ "```",
309
+ ""
310
+ ])
311
+ ]),
312
+ ...(facts.trial.length === 0
313
+ ? []
314
+ : [
315
+ "## Trial port: where the native port and the rulebook port differ",
316
+ "",
317
+ ...facts.trial.map((line) => `- ${line}`),
318
+ ""
319
+ ]),
320
+ ...(facts.dropped.length === 0
321
+ ? []
322
+ : [
323
+ "## Dropped by the refute",
324
+ "",
325
+ ...facts.dropped.map((finding) => `- ${finding.dimension}: ${finding.finding.trim()}`),
326
+ ""
327
+ ]),
328
+ DraftApprovalMarker,
329
+ ""
330
+ ].join("\n")
331
+
332
+ // ---- Differential (port-tests) ------------------------------------------------------------
333
+
334
+ export const DiffClass = Schema.Literals(["pass", "diverge", "crash", "hang", "legacy-red"])
335
+ export type DiffClass = typeof DiffClass.Type
336
+
337
+ export class DiffVerdict extends Schema.Class<DiffVerdict>("DiffVerdict")({
338
+ file: Schema.String,
339
+ class: DiffClass,
340
+ legacyPassed: Schema.optionalKey(Schema.Int),
341
+ targetPassed: Schema.optionalKey(Schema.Int),
342
+ detail: Schema.String
343
+ }) {}
344
+
345
+ /**
346
+ * The Bun test swarm's verdict: a test file passes when the target exits 0
347
+ * AND its pass count equals the legacy baseline's (when both are known). A
348
+ * legacy run that is red itself is no evidence about the target.
349
+ */
350
+ export const diffVerdict = (
351
+ file: string,
352
+ legacy: ReviewResult,
353
+ target: ReviewResult,
354
+ roots: ReadonlyArray<string> = []
355
+ ): DiffVerdict => {
356
+ const strip = (text: string): string =>
357
+ roots.reduce((acc, root) => acc.replaceAll(`${root}/`, ""), text)
358
+ const legacyPassed = legacy.passed
359
+ const targetPassed = target.passed
360
+ const counts = {
361
+ ...(legacyPassed === undefined ? {} : { legacyPassed }),
362
+ ...(targetPassed === undefined ? {} : { targetPassed })
363
+ }
364
+ if (!legacy.isClean) {
365
+ return DiffVerdict.make({
366
+ file,
367
+ class: "legacy-red",
368
+ ...counts,
369
+ detail: "the legacy build fails this file too; nothing to compare"
370
+ })
371
+ }
372
+ const gate = target.issues.find((issue) => issue.gateClass !== undefined)
373
+ if (gate?.gateClass === "hang") {
374
+ return DiffVerdict.make({ file, class: "hang", ...counts, detail: strip(gate.description) })
375
+ }
376
+ if (gate?.gateClass === "crash") {
377
+ return DiffVerdict.make({ file, class: "crash", ...counts, detail: strip(gate.description) })
378
+ }
379
+ if (!target.isClean) {
380
+ return DiffVerdict.make({
381
+ file,
382
+ class: "diverge",
383
+ ...counts,
384
+ detail: strip(target.issues.map((issue) => issue.description).join("\n"))
385
+ })
386
+ }
387
+ if (legacyPassed !== undefined && targetPassed !== undefined && targetPassed !== legacyPassed) {
388
+ return DiffVerdict.make({
389
+ file,
390
+ class: "diverge",
391
+ ...counts,
392
+ detail: `${targetPassed} test(s) pass on the target, ${legacyPassed} on the legacy build`
393
+ })
394
+ }
395
+ return DiffVerdict.make({ file, class: "pass", ...counts, detail: "" })
396
+ }
397
+
398
+ /** The `.diag` a fixer reads as its only runtime evidence. */
399
+ export const renderDiag = (verdict: DiffVerdict, tailChars = 4_000): string =>
400
+ [
401
+ `# ${verdict.file}: ${verdict.class}`,
402
+ "",
403
+ `- legacy passed: ${verdict.legacyPassed ?? "unknown"}; target passed: ${verdict.targetPassed ?? "unknown"}`,
404
+ "",
405
+ "## Target output (tail)",
406
+ "",
407
+ "```",
408
+ verdict.detail.length > tailChars ? `…${verdict.detail.slice(-tailChars)}` : verdict.detail,
409
+ "```",
410
+ ""
411
+ ].join("\n")
412
+
413
+ export const renderDifferentialReport = (
414
+ round: number,
415
+ verdicts: ReadonlyArray<DiffVerdict>
416
+ ): string => {
417
+ const by = (klass: DiffClass) => verdicts.filter((verdict) => verdict.class === klass)
418
+ return [
419
+ `# Differential tests, round ${round}`,
420
+ "",
421
+ `- files: ${verdicts.length}; pass ${by("pass").length}, diverge ${by("diverge").length}, crash ${by("crash").length}, hang ${by("hang").length}, legacy-red ${by("legacy-red").length}`,
422
+ "",
423
+ ...(["diverge", "crash", "hang"] as const).flatMap((klass) =>
424
+ by(klass).length === 0
425
+ ? []
426
+ : [`## ${klass}`, "", ...by(klass).map((verdict) => `- ${verdict.file}`), ""]
427
+ )
428
+ ].join("\n")
429
+ }
package/src/Retro.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * tasks appended to a story's plan, or a story entry edited in the epic
10
10
  * plan (which restarts that story). Nothing is applied unreviewed.
11
11
  */
12
- import { join } from "node:path"
12
+ import { join, resolve, sep } from "node:path"
13
13
  import * as Effect from "effect/Effect"
14
14
  import * as Option from "effect/Option"
15
15
  import * as Schema from "effect/Schema"
@@ -1278,7 +1278,14 @@ export const applyApprovedRetros = Effect.fn("@llm4ts/flow/Retro.apply")(functio
1278
1278
  yield* say(`${runId}: rule edit to ${edit.target} is not allowed; skipped`)
1279
1279
  continue
1280
1280
  }
1281
- const path = join(options.rulesRoot, edit.target)
1281
+ const root = resolve(options.rulesRoot)
1282
+ const path = resolve(root, edit.target)
1283
+ // The allowlist already rejects absolute paths and `..`; this keeps the
1284
+ // resolved file under the repository root whatever the string looked like.
1285
+ if (path !== root && !path.startsWith(`${root}${sep}`)) {
1286
+ yield* say(`${runId}: rule edit to ${edit.target} resolves outside the repository; skipped`)
1287
+ continue
1288
+ }
1282
1289
  const existing = yield* files.read(path).pipe(Effect.catch(() => Effect.succeed(undefined)))
1283
1290
  const next = applyRuleEdit(existing, edit)
1284
1291
  if (next === undefined) {