@hicaru/pi-rlm 0.1.8 → 0.1.9

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.
@@ -28,14 +28,13 @@ import { checkResourceLimits } from "../core/resource-limits.ts";
28
28
  import type { InteractiveDeps, RlmConfig, Sampling } from "../core/types.ts";
29
29
  import { SandboxManager } from "../sandbox/sandbox-manager.ts";
30
30
  import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
31
- import type { ProposedEdit, ReplResult } from "../sandbox/protocol.ts";
31
+ import type { ReplResult } from "../sandbox/protocol.ts";
32
32
  import { RlmEmitter } from "./rlm-events.ts";
33
33
  import { SubcallStore } from "./subcall-store.ts";
34
34
  import type { ReplDetails } from "./repl-details.ts";
35
35
  import type { RlmSubcall } from "./rlm-details.ts";
36
36
  import { createEngine } from "../core/engine.ts";
37
37
  import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
38
- import type { EditRegistry } from "../registry/edit-registry.ts";
39
38
  import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
40
39
  import {
41
40
  headlineStatusGlyph,
@@ -51,59 +50,44 @@ export const ReplToolParams = Object.freeze(Type.Object({
51
50
  code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
52
51
  }));
53
52
 
54
- export function surfaceReplEdits(edits: readonly ProposedEdit[], raised: boolean): readonly ProposedEdit[] | undefined {
55
- return edits.length > 0 && !raised ? edits : undefined;
56
- }
57
-
58
- /** Model-visible text assembled from a repl() result, plus the surfaced edits for `details`. */
53
+ /** Model-visible text assembled from a repl() result. */
59
54
  export interface ReplResultText {
60
55
  readonly text: string;
61
- readonly surfacedEdits: readonly ProposedEdit[] | undefined;
62
- }
63
-
64
- function countLines(text: string): number {
65
- if (text.length === 0) return 0;
66
- let count = 1;
67
- for (const ch of text) if (ch === "\n") count++;
68
- return count;
69
- }
70
-
71
- function stagedEditSummary(edits: readonly ProposedEdit[]): string {
72
- const rows = new Array<string>(edits.length);
73
- for (let i = 0; i < edits.length; i++) {
74
- const edit = edits[i];
75
- rows[i] = ` ${edit.id} ${edit.path} (-${countLines(edit.oldText)}/+${countLines(edit.newText)} lines)`;
76
- }
77
- return [
78
- "STAGED_EDITS (apply by id with apply_edits; do NOT re-type content):",
79
- ...rows,
80
- ].join("\n");
81
56
  }
82
57
 
83
58
  /**
84
- * Assemble the model-visible text for a repl() result: cap stdout, append a zero-subcall
85
- * delegation nudge (suppressed when edits were staged), and summarize staged edits by ID
86
- * without exposing oldText/newText bodies to the root model.
59
+ * Assemble the model-visible text for a repl() result: cap stdout and append a
60
+ * zero-subcall delegation nudge when a bulk read went undelegated.
87
61
  */
88
62
  export function buildReplResultText(
89
63
  stdout: string,
90
64
  finalAnswer: string | undefined,
91
- edits: readonly ProposedEdit[],
92
- raised: boolean,
93
65
  subcalls: readonly RlmSubcall[],
94
66
  ): ReplResultText {
95
67
  const answerSubmitted = finalAnswer !== undefined;
96
68
  const rawText = answerSubmitted
97
69
  ? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
98
70
  : stdout || "(no output)";
99
- const surfacedEdits = surfaceReplEdits(edits, raised);
100
- const editsBlock = surfacedEdits ? `\n\n${stagedEditSummary(surfacedEdits)}` : "";
101
- const modelText = rawText + editsBlock;
102
- // Model-visible text is capped; the caller keeps full stdout/final answer in `details` for the TUI.
103
- const cappedText = capReplResultText(modelText) ?? modelText;
71
+ // Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
72
+ const cappedText = capReplResultText(rawText) ?? rawText;
104
73
  const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
105
- const nudge = surfacedEdits || answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
106
- return { text: cappedText + (nudge ?? ""), surfacedEdits };
74
+ const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
75
+ return { text: cappedText + (nudge ?? "") };
76
+ }
77
+
78
+ /** Advisory diagnostics derived from a completed invocation's sub-calls. */
79
+ export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly string[] | undefined {
80
+ let failed = 0;
81
+ let total = 0;
82
+ for (let i = 0; i < subcalls.length; i++) {
83
+ const call = subcalls[i];
84
+ if (call.status !== "error") continue;
85
+ // A batch subcall stands for many prompts; a single call stands for one.
86
+ failed += call.failedCount ?? 1;
87
+ total += call.totalCount ?? 1;
88
+ }
89
+ if (failed === 0) return undefined;
90
+ return Object.freeze([`${failed}/${total} sub-call(s) failed — results may be incomplete`]);
107
91
  }
108
92
 
109
93
  // ── Mutable bridge state (handler indirection) ──
@@ -214,6 +198,7 @@ class NativeBridgeState {
214
198
  if (id) state.currentEmitter?.emitSubcallUpdated({ id,
215
199
  status: error ? "error" : "done", costUsd: cost, tokens,
216
200
  resultPreview: previewText(out[0] ?? ""), detail: error,
201
+ failedCount: failed, totalCount: out.length,
217
202
  });
218
203
  return out;
219
204
  },
@@ -337,7 +322,6 @@ export interface ReplToolDeps {
337
322
  readonly getModel?: () => Model<Api> | undefined;
338
323
  readonly getWorkerModel?: () => Model<Api> | undefined;
339
324
  readonly registry: ModelRegistry;
340
- readonly editRegistry?: EditRegistry;
341
325
  readonly config: RlmConfig;
342
326
  readonly signal?: AbortSignal;
343
327
  readonly onUsage?: (usage: Usage, role: "sub") => void;
@@ -347,7 +331,7 @@ export interface ReplToolDeps {
347
331
  }
348
332
 
349
333
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
350
- const { sandboxManager, workerModel, registry, editRegistry, config, signal, onUsage } = deps;
334
+ const { sandboxManager, workerModel, registry, config, signal, onUsage } = deps;
351
335
  const bridgeState = new NativeBridgeState();
352
336
 
353
337
  // Late-bound cwd — getOrCreate installs handlers only at spawn; never rebuild the closure.
@@ -508,14 +492,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
508
492
  if (queuedId) emitter.emitSubcallUpdated({ id: queuedId, status: "done" });
509
493
 
510
494
  const finalAnswer = result.finalAnswer ?? undefined;
511
- const { text: resultText, surfacedEdits } = buildReplResultText(
495
+ const { text: resultText } = buildReplResultText(
512
496
  result.stdout,
513
497
  finalAnswer,
514
- result.edits,
515
- result.raised,
516
498
  store.getSubcalls(),
517
499
  );
518
- editRegistry?.registerAll(surfacedEdits);
519
500
 
520
501
  const details: ReplDetails = {
521
502
  status: "done",
@@ -525,7 +506,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
525
506
  subcalls: store.getSubcalls(),
526
507
  totals: store.getTotals(),
527
508
  finalAnswer,
528
- edits: surfacedEdits,
509
+ warnings: collectReplWarnings(store.getSubcalls()),
529
510
  };
530
511
  const progressText = finalAnswer !== undefined
531
512
  ? `ANSWER_SUBMITTED (${finalAnswer.length} chars)`
@@ -588,9 +569,6 @@ function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
588
569
  parts.push(formatCost(details.totals.costUsd));
589
570
  if (details.totals.tokens > 0) parts.push(`${formatTokens(details.totals.tokens)} tok`);
590
571
  if (details.executionTimeMs > 0) parts.push(`${details.executionTimeMs}ms`);
591
- if (details.edits && details.edits.length > 0) {
592
- parts.push(theme.fg("success", `${details.edits.length} staged`));
593
- }
594
572
  const stats = parts.length > 0 ? ` ${theme.fg("dim", parts.join(" · "))}` : "";
595
573
 
596
574
  const header = `${glyph} ${theme.fg("toolTitle", theme.bold("REPL"))}${stats}`;
@@ -625,13 +603,9 @@ function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
625
603
  container.addChild(new Text(out, 0, 0));
626
604
  }
627
605
 
628
- if (details.edits && details.edits.length > 0) {
629
- const editFiles = new Set<string>();
630
- for (const edit of details.edits) editFiles.add(edit.path);
606
+ if (details.warnings && details.warnings.length > 0) {
631
607
  container.addChild(new Spacer(1));
632
- container.addChild(new Text(theme.fg("success",
633
- `${details.edits.length} edit${details.edits.length > 1 ? "s" : ""} staged across ${editFiles.size} file${editFiles.size > 1 ? "s" : ""}`,
634
- ), 0, 0));
608
+ container.addChild(new Text(theme.fg("muted", details.warnings.join("\n")), 0, 0));
635
609
  }
636
610
 
637
611
  // Stderr
@@ -6,14 +6,14 @@
6
6
  * getState(): RlmDetails for direct access (spinner loop, final return).
7
7
  *
8
8
  * Subcall storage and totals are delegated to SubcallStore. Root-level state
9
- * (status, prompt, turns, answer, edits) is kept in the aggregator.
9
+ * (status, prompt, turns, answer, warnings) is kept in the aggregator.
10
10
  *
11
11
  * Replaces RlmToolBridge's internal state accumulation. The emitter is pure
12
12
  * dispatch; the aggregator is pure state. Separated for independent testing.
13
13
  */
14
14
 
15
15
  import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
16
- import type { RlmEmitter, TurnEvent, RootUsageEvent, AnswerEvent, EditsEvent, StatusEvent, RootPromptEvent } from "./rlm-events.ts";
16
+ import type { RlmEmitter, TurnEvent, RootUsageEvent, AnswerEvent, StatusEvent, RootPromptEvent, WarningsEvent } from "./rlm-events.ts";
17
17
  import type { RlmDetails, RlmRunStatus } from "./rlm-details.ts";
18
18
  import { EmitterListener } from "./emitter-listener.ts";
19
19
  import { SubcallStore } from "./subcall-store.ts";
@@ -27,7 +27,7 @@ export class RlmEventAggregator extends EmitterListener {
27
27
  private turnCurrent = 0;
28
28
  private turnMax = 0;
29
29
  private answer?: string;
30
- private edits: RlmDetails["edits"] = [];
30
+ private warnings?: readonly string[];
31
31
 
32
32
  constructor(
33
33
  emitter: RlmEmitter,
@@ -40,7 +40,7 @@ export class RlmEventAggregator extends EmitterListener {
40
40
  emitter.onTurn((e) => this.handleTurn(e)),
41
41
  emitter.onRootUsage((e) => this.handleRootUsage(e)),
42
42
  emitter.onAnswer((e) => this.handleAnswer(e)),
43
- emitter.onEdits((e) => this.handleEdits(e)),
43
+ emitter.onWarnings((e) => this.handleWarnings(e)),
44
44
  emitter.onStatus((e) => this.handleStatus(e)),
45
45
  emitter.onRootPrompt((e) => this.handleRootPrompt(e)),
46
46
  ]);
@@ -64,8 +64,8 @@ export class RlmEventAggregator extends EmitterListener {
64
64
  this.notify();
65
65
  }
66
66
 
67
- private handleEdits(event: EditsEvent): void {
68
- this.edits = event.edits;
67
+ private handleWarnings(event: WarningsEvent): void {
68
+ this.warnings = event.warnings;
69
69
  this.notify();
70
70
  }
71
71
 
@@ -90,7 +90,7 @@ export class RlmEventAggregator extends EmitterListener {
90
90
  subcalls: this.store.getSubcalls(),
91
91
  totals: this.store.getTotals(),
92
92
  answer: this.answer,
93
- edits: this.edits,
93
+ warnings: this.warnings,
94
94
  };
95
95
  }
96
96
 
@@ -6,8 +6,6 @@
6
6
  * after every mutation, enabling Pi's built-in progressive TUI re-render.
7
7
  */
8
8
 
9
- import type { ProposedEdit } from "../sandbox/protocol.ts";
10
-
11
9
  export type SubcallKind = "root" | "rlm" | "llm" | "batch" | "tool";
12
10
  export type SubcallStatus = "running" | "done" | "error";
13
11
  export type RlmRunStatus = "running" | "done" | "error" | "aborted";
@@ -29,6 +27,10 @@ export interface RlmSubcall {
29
27
  readonly endedAt?: number;
30
28
  readonly costUsd: number;
31
29
  readonly tokens: number;
30
+ /** For batch subcalls: failed prompt count (partial failure). */
31
+ readonly failedCount?: number;
32
+ /** For batch subcalls: total prompt count. */
33
+ readonly totalCount?: number;
32
34
  }
33
35
 
34
36
  export interface RlmDetails {
@@ -38,7 +40,8 @@ export interface RlmDetails {
38
40
  readonly subcalls: readonly RlmSubcall[];
39
41
  readonly totals: { readonly costUsd: number; readonly tokens: number };
40
42
  readonly answer?: string;
41
- readonly edits?: readonly ProposedEdit[];
43
+ /** Advisory diagnostics — surfaced to the user, never a failure. */
44
+ readonly warnings?: readonly string[];
42
45
  }
43
46
 
44
47
  export interface SubcallInit {
@@ -12,7 +12,6 @@
12
12
 
13
13
  import { EventEmitter } from "node:events";
14
14
  import type { SubcallKind, SubcallStatus, RlmRunStatus } from "./rlm-details.ts";
15
- import type { ProposedEdit } from "../sandbox/protocol.ts";
16
15
 
17
16
  // ── Event payloads ──
18
17
 
@@ -40,6 +39,14 @@ export interface SubcallUpdatedEvent {
40
39
  readonly costUsd?: number;
41
40
  /** Delta — additive on both the subcall and running totals. */
42
41
  readonly tokens?: number;
42
+ /** For batch subcalls: failed prompt count. */
43
+ readonly failedCount?: number;
44
+ /** For batch subcalls: total prompt count. */
45
+ readonly totalCount?: number;
46
+ }
47
+
48
+ export interface WarningsEvent {
49
+ readonly warnings: readonly string[];
43
50
  }
44
51
 
45
52
  export interface TurnEvent {
@@ -56,10 +63,6 @@ export interface AnswerEvent {
56
63
  readonly text: string;
57
64
  }
58
65
 
59
- export interface EditsEvent {
60
- readonly edits: readonly ProposedEdit[];
61
- }
62
-
63
66
  export interface StatusEvent {
64
67
  readonly status: RlmRunStatus;
65
68
  }
@@ -110,9 +113,9 @@ export class RlmEmitter {
110
113
  this.ee.emit("answer", { text } satisfies AnswerEvent);
111
114
  }
112
115
 
113
- /** Set proposed edits (root-only). */
114
- emitEdits(edits: readonly ProposedEdit[]): void {
115
- this.ee.emit("edits", { edits } satisfies EditsEvent);
116
+ /** Set advisory warnings (root-only; never a failure). */
117
+ emitWarnings(warnings: readonly string[]): void {
118
+ this.ee.emit("warnings", { warnings } satisfies WarningsEvent);
116
119
  }
117
120
 
118
121
  /** Set the root run status (done/error/aborted). */
@@ -152,9 +155,9 @@ export class RlmEmitter {
152
155
  return () => { this.ee.off("answer", handler); };
153
156
  }
154
157
 
155
- onEdits(handler: (event: EditsEvent) => void): () => void {
156
- this.ee.on("edits", handler);
157
- return () => { this.ee.off("edits", handler); };
158
+ onWarnings(handler: (event: WarningsEvent) => void): () => void {
159
+ this.ee.on("warnings", handler);
160
+ return () => { this.ee.off("warnings", handler); };
158
161
  }
159
162
 
160
163
  onStatus(handler: (event: StatusEvent) => void): () => void {
@@ -156,14 +156,9 @@ function renderExpanded(details: RlmDetails, theme: Theme): Component {
156
156
  container.addChild(new Markdown(details.answer, 0, 0, getMarkdownTheme()));
157
157
  }
158
158
 
159
- if (details.edits && details.edits.length > 0) {
159
+ if (details.warnings && details.warnings.length > 0) {
160
160
  container.addChild(new Spacer(1));
161
- const editFiles = new Set(details.edits.map(e => e.path));
162
- container.addChild(new Text(
163
- theme.fg("muted", "─── Edits ───") +
164
- `\n ${theme.fg("dim", `${details.edits.length} edit${details.edits.length > 1 ? "s" : ""} proposed across ${editFiles.size} file${editFiles.size > 1 ? "s" : ""}`)}`,
165
- 0, 0,
166
- ));
161
+ container.addChild(new Text(theme.fg("muted", details.warnings.join("\n")), 0, 0));
167
162
  }
168
163
 
169
164
  return container;
@@ -66,6 +66,8 @@ export class SubcallStore extends EmitterListener {
66
66
  sc.tokens += event.tokens;
67
67
  this.totalTokens += event.tokens;
68
68
  }
69
+ if (event.failedCount !== undefined) sc.failedCount = event.failedCount;
70
+ if (event.totalCount !== undefined) sc.totalCount = event.totalCount;
69
71
  }
70
72
 
71
73
  // ── Read ──
@@ -41,7 +41,7 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
41
41
  item("maxTokens", "Token ceiling", config.maxTokens != null ? String(config.maxTokens) : "none", CHOICES.maxTokens, "Total input+output token cap for the whole recursive tree."),
42
42
  item("maxErrors", "Max consecutive errors", config.maxErrors != null ? String(config.maxErrors) : "none", CHOICES.maxErrors, "Stop after this many consecutive failing turns; none disables the guard."),
43
43
  item("orchestrator", "Orchestrator addendum", config.orchestrator ? "on" : "off", CHOICES.orchestrator, "Append extra divide-and-conquer guidance to the root model system prompt."),
44
- item("pipeline", "Phase pipeline", config.pipeline ? "on" : "off", CHOICES.pipeline, "Enable artifact-gated phases: clarify→research→blueprint→implement fanout→validate (clarify needs Ask user on)."),
44
+ item("pipeline", "Phase pipeline", config.pipeline ? "on" : "off", CHOICES.pipeline, "Enable artifact-gated phases: clarify→research→blueprint→validate (read-only plan pipeline; clarify needs Ask user on)."),
45
45
  item("maxBackwardJumps", "Max validate→blueprint loops", String(config.maxBackwardJumps), CHOICES.maxBackwardJumps, "Bounded corrective re-entries when validation reports blockers_count > 0."),
46
46
  item("compaction", "Trajectory compaction", config.compaction ? "on" : "off", CHOICES.compaction, "Summarize old turns when history approaches the model context window."),
47
47
  item("rootSamplingMaxTokens", "Root model output cap (tok)", String(config.rootSampling?.maxTokens ?? 16384), CHOICES.rootSamplingMaxTokens, "Max output tokens per root-model turn. Lower values keep each turn lean."),
@@ -49,7 +49,7 @@ export async function showConfigPanel(ctx: ExtensionContext, config: RlmConfig):
49
49
  item("askUserQuestion", "[Interactive] Ask user", config.askUserQuestion ? "on" : "off", CHOICES.askUserQuestion, "Allow root REPL code to present structured ask_user_question dialogs."),
50
50
  item("todo", "[Interactive] Todo", config.todo ? "on" : "off", CHOICES.todo, "Allow REPL code to manage a visible todo task list."),
51
51
  item("libraryLoader", "Library loader", config.libraryLoader ? "on" : "off", CHOICES.libraryLoader,
52
- "Allow load_library() to pull an external dir, file, or git repo into a new context_N slot."),
52
+ "Allow load_library() to pull an external dir, file, or git repo into the shared context list."),
53
53
  item("__save__", "Save & close", "↵", ["↵"], "Save these settings and close (Esc also saves)."),
54
54
  ];
55
55
 
@@ -1,22 +0,0 @@
1
- import type { ProposedEdit } from "../sandbox/protocol.ts";
2
-
3
- export class EditRegistry {
4
- private readonly edits = new Map<string, ProposedEdit>();
5
-
6
- registerAll(edits: readonly ProposedEdit[] | undefined): void {
7
- if (edits === undefined) return;
8
- for (const edit of edits) this.edits.set(edit.id, edit);
9
- }
10
-
11
- get(id: string): ProposedEdit | undefined {
12
- return this.edits.get(id);
13
- }
14
-
15
- delete(id: string): boolean {
16
- return this.edits.delete(id);
17
- }
18
-
19
- clear(): void {
20
- this.edits.clear();
21
- }
22
- }
package/src/text/edits.ts DELETED
@@ -1,164 +0,0 @@
1
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { constants } from "node:fs";
3
- import { dirname, resolve } from "node:path";
4
- import type { ProposedEdit } from "../sandbox/protocol.ts";
5
- import { errorMessage, formatError } from "../util/errors.ts";
6
-
7
- export interface AnchorEdit {
8
- readonly oldText: string;
9
- readonly newText: string;
10
- }
11
-
12
- export function countOccurrences(haystack: string, needle: string): number {
13
- if (needle.length === 0) return 0;
14
- let count = 0;
15
- let offset = 0;
16
- for (;;) {
17
- const match = haystack.indexOf(needle, offset);
18
- if (match < 0) return count;
19
- count++;
20
- offset = match + needle.length;
21
- }
22
- }
23
-
24
- /**
25
- * Literal string replace of the first occurrence of `oldText` with `newText`.
26
- * Splice-based so `$&` / `$$` / `$'` in newText are NOT treated as
27
- * special replacement patterns (String.prototype.replace string-form hazard).
28
- */
29
- export function replaceOnceLiteral(content: string, oldText: string, newText: string): string {
30
- const idx = content.indexOf(oldText);
31
- if (idx < 0) return content;
32
- return content.slice(0, idx) + newText + content.slice(idx + oldText.length);
33
- }
34
-
35
- export type PlanEditResult =
36
- | {
37
- readonly ok: true;
38
- /** create = new file; replace = one-shot anchor swap; already-applied = idempotent skip */
39
- readonly kind: "create" | "replace" | "already-applied";
40
- readonly before: string;
41
- readonly after: string;
42
- }
43
- | { readonly ok: false; readonly error: string };
44
-
45
- async function pathExists(abs: string): Promise<boolean> {
46
- try {
47
- await access(abs, constants.F_OK);
48
- return true;
49
- } catch {
50
- return false;
51
- }
52
- }
53
-
54
- /**
55
- * Validate and compute an edit without writing.
56
- * Shared by headless apply and the native apply_edits tool (DRY).
57
- *
58
- * Idempotent retry: if a prior unit already landed the change
59
- * (oldText absent + newText already present for replace; create with identical content),
60
- * returns kind "already-applied" so a failed fanout can be retried without wedging.
61
- *
62
- * Create-file: refuses to clobber when the target exists with different content.
63
- */
64
- export async function planEdit(
65
- cwd: string,
66
- path: string,
67
- oldText: string,
68
- newText: string,
69
- ): Promise<PlanEditResult> {
70
- try {
71
- const fullPath = resolve(cwd, path);
72
- if (oldText.length === 0) {
73
- if (await pathExists(fullPath)) {
74
- const existing = await readFile(fullPath, "utf8");
75
- if (existing === newText) {
76
- return { ok: true, kind: "already-applied", before: existing, after: existing };
77
- }
78
- return {
79
- ok: false,
80
- error: formatError(
81
- `${path}: file already exists with different content — refuse to clobber (create requires empty target or identical content)`,
82
- ),
83
- };
84
- }
85
- return { ok: true, kind: "create", before: "", after: newText };
86
- }
87
-
88
- const content = await readFile(fullPath, "utf8");
89
- const occurrences = countOccurrences(content, oldText);
90
- if (occurrences === 0) {
91
- // Idempotent skip: prior apply already removed the anchor and left newText.
92
- // Deletions (newText === "") always "include" empty string — treat them as
93
- // retry-unsafe so a typo'd anchor fails instead of silently skipping.
94
- if (newText.length > 0 && content.includes(newText)) {
95
- return { ok: true, kind: "already-applied", before: content, after: content };
96
- }
97
- return { ok: false, error: formatError(`anchor occurs 0 times in ${path}`) };
98
- }
99
- if (occurrences !== 1) {
100
- return { ok: false, error: formatError(`anchor occurs ${occurrences} times in ${path}`) };
101
- }
102
- const after = replaceOnceLiteral(content, oldText, newText);
103
- return { ok: true, kind: "replace", before: content, after };
104
- } catch (err: unknown) {
105
- return { ok: false, error: formatError(`${path}: ${errorMessage(err)}`) };
106
- }
107
- }
108
-
109
- export type ApplyOneEditResult =
110
- | { readonly ok: true; readonly before: string; readonly after: string; readonly kind: "create" | "replace" | "already-applied" }
111
- | { readonly ok: false; readonly error: string };
112
-
113
- /**
114
- * Apply a single anchor edit to the working tree (direct disk write).
115
- * Used by the implement fanout and any headless apply path.
116
- */
117
- export async function applyOneEdit(
118
- cwd: string,
119
- path: string,
120
- oldText: string,
121
- newText: string,
122
- ): Promise<ApplyOneEditResult> {
123
- const planned = await planEdit(cwd, path, oldText, newText);
124
- if (!planned.ok) return planned;
125
- if (planned.kind === "already-applied") {
126
- return { ok: true, before: planned.before, after: planned.after, kind: "already-applied" };
127
- }
128
- try {
129
- const fullPath = resolve(cwd, path);
130
- if (planned.kind === "create") {
131
- await mkdir(dirname(fullPath), { recursive: true });
132
- }
133
- await writeFile(fullPath, planned.after, "utf8");
134
- return { ok: true, before: planned.before, after: planned.after, kind: planned.kind };
135
- } catch (err: unknown) {
136
- return { ok: false, error: formatError(`${path}: ${errorMessage(err)}`) };
137
- }
138
- }
139
-
140
- export type ApplyProposedEditsResult =
141
- | { readonly ok: true; readonly applied: number }
142
- | { readonly ok: false; readonly error: string; readonly applied: number };
143
-
144
- /**
145
- * Apply a series of proposed edits to the working tree (patch series, not a race).
146
- * Shared by the implement fanout and any headless apply path.
147
- * Safe to re-run: already-applied units succeed without re-writing.
148
- */
149
- export async function applyProposedEdits(
150
- edits: readonly ProposedEdit[],
151
- cwd: string,
152
- ): Promise<ApplyProposedEditsResult> {
153
- let applied = 0;
154
- for (let i = 0; i < edits.length; i++) {
155
- const edit = edits[i];
156
- if (edit === undefined) continue;
157
- const one = await applyOneEdit(cwd, edit.path, edit.oldText, edit.newText);
158
- if (!one.ok) {
159
- return { ok: false, error: one.error, applied };
160
- }
161
- applied++;
162
- }
163
- return { ok: true, applied };
164
- }