@hheei/pi-apply-patch 0.2.0 → 0.3.1

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.
@@ -1,6 +1,7 @@
1
- import { acquireMutationLock, errorMessage } from "@hheei/pi-ext-core";
1
+ import { errorMessage } from "@hheei/pi-ext-core";
2
2
  import { APPLY_PATCH_MAX_FILE_SIZE, createLocalPatchFs, FsTransportError, fileTooLarge, gcAgentPatchTemps, publishPreparedFile, sftpTimeoutMs, } from "./fs.js";
3
- import { runJsDiffUpdate } from "./jsdiff.js";
3
+ import { diagnoseRejectedHunk, runJsDiffUpdate } from "./jsdiff.js";
4
+ import { CONFIRMED_STATUSES } from "./outcome.js";
4
5
  import { findV4aPatchConflicts, operationTouchedPaths, parseV4aPatch, } from "./parser.js";
5
6
  import { assertPatchPath } from "./paths.js";
6
7
  const SNAPSHOT_MAX_LINES = 150;
@@ -36,20 +37,15 @@ function plannedDelta(operation, content) {
36
37
  return { addedLines: 0, removedLines: lineCount(content) };
37
38
  return hunkDelta(operation.hunks);
38
39
  }
40
+ /** Totals sum the rows, so the header can never disagree with the table drawn below it. */
39
41
  function progressTotals(operations) {
40
- const paths = new Set();
41
42
  let addedLines = 0;
42
43
  let removedLines = 0;
43
44
  for (const operation of operations) {
44
- paths.add(operation.path);
45
- if (operation.status === "applied" ||
46
- operation.status === "fuzzy" ||
47
- operation.status === "partial") {
48
- addedLines += operation.addedLines;
49
- removedLines += operation.removedLines;
50
- }
45
+ addedLines += operation.addedLines;
46
+ removedLines += operation.removedLines;
51
47
  }
52
- return { files: paths.size, addedLines, removedLines };
48
+ return { addedLines, removedLines };
53
49
  }
54
50
  function splitTextLines(content) {
55
51
  const lines = new TextDecoder().decode(content).split(/\r\n|\n|\r/);
@@ -218,6 +214,50 @@ async function applyUpdate(fs, index, operation, policy, signal) {
218
214
  };
219
215
  }
220
216
  }
217
+ /**
218
+ * A rejection reports lines of the file it left behind. Later operations on the same path publish
219
+ * their own shifts over those lines, so a diagnostic taken mid-patch can end up naming lines the
220
+ * model cannot find. Resolving it once more against the published file keeps the numbers usable.
221
+ * Best effort: an unreadable or vanished file keeps the original diagnostic.
222
+ */
223
+ async function relocateRejections(fs, patch, rejected, applied, signal) {
224
+ for (const [position, entry] of rejected.entries()) {
225
+ if (entry.diagnostics.length === 0)
226
+ continue;
227
+ const lastIndex = Math.max(...entry.operationIndices);
228
+ const shifted = applied.some((operation) => operation.operationIndex > lastIndex &&
229
+ operation.paths.some((path) => entry.paths.includes(path)));
230
+ if (!shifted)
231
+ continue;
232
+ const path = entry.paths[0];
233
+ if (path === undefined)
234
+ continue;
235
+ let content;
236
+ try {
237
+ content = await fs.readFollow(path, signal);
238
+ }
239
+ catch {
240
+ continue;
241
+ }
242
+ const update = entry.operationIndices
243
+ .map((index) => patch.operations[index])
244
+ .find((operation) => operation?.kind === "update");
245
+ if (update?.kind !== "update")
246
+ continue;
247
+ const diagnostics = entry.diagnostics.map((diagnostic) => {
248
+ const hunk = update.hunks[diagnostic.hunkIndex - 1];
249
+ if (hunk === undefined)
250
+ return diagnostic;
251
+ try {
252
+ return diagnoseRejectedHunk(content, hunk, diagnostic.hunkIndex);
253
+ }
254
+ catch {
255
+ return diagnostic;
256
+ }
257
+ });
258
+ rejected[position] = Object.freeze({ ...entry, diagnostics: Object.freeze(diagnostics) });
259
+ }
260
+ }
221
261
  export async function applyPatchInWorkspace(options) {
222
262
  options.signal?.throwIfAborted();
223
263
  const fs = options.fs ?? createLocalPatchFs(options.workspaceRoot);
@@ -238,20 +278,35 @@ export async function applyPatchInWorkspace(options) {
238
278
  operationIndex: index,
239
279
  kind: operation.kind,
240
280
  path: operation.kind === "update" ? (operation.moveTo ?? operation.path) : operation.path,
241
- ...plannedDelta(operation, undefined),
281
+ // A pending row previews the bytes it is about to write; a row refused before execution
282
+ // reports none, because it wrote nothing.
283
+ ...(rejectedIndices.has(index)
284
+ ? { addedLines: 0, removedLines: 0 }
285
+ : plannedDelta(operation, undefined)),
242
286
  status: rejectedIndices.has(index) ? "rejected" : "pending",
243
287
  }));
288
+ /**
289
+ * The bytes one row reports, which are always this operation's own outcome rather than the
290
+ * request that asked for it. A pending row previews what it is about to write and a partial
291
+ * update counts only the hunks that landed; a row refused, never sent or never confirmed
292
+ * reports nothing, because nothing there is known to have been written.
293
+ */
294
+ const rowDelta = (current, status, update, prepared) => {
295
+ if (status !== "pending" && !CONFIRMED_STATUSES.has(status))
296
+ return { addedLines: 0, removedLines: 0 };
297
+ if (update !== undefined)
298
+ return update.kind === "update" && prepared !== undefined
299
+ ? { addedLines: prepared.addedLines, removedLines: prepared.removedLines }
300
+ : plannedDelta(update, undefined);
301
+ return { addedLines: current.addedLines, removedLines: current.removedLines };
302
+ };
244
303
  const setStatus = (index, status, outcome, update, partialReason, prepared) => {
245
304
  const current = progressOperations[index];
246
305
  if (current === undefined)
247
306
  throw new Error(`Missing patch progress operation: ${index}`);
248
307
  progressOperations[index] = Object.freeze({
249
308
  ...current,
250
- ...(update === undefined
251
- ? {}
252
- : update.kind !== "update" || prepared === undefined
253
- ? plannedDelta(update, undefined)
254
- : { addedLines: prepared.addedLines, removedLines: prepared.removedLines }),
309
+ ...rowDelta(current, status, update, prepared),
255
310
  status,
256
311
  ...(status === "partial" && update !== undefined && outcome !== undefined
257
312
  ? {
@@ -274,134 +329,127 @@ export async function applyPatchInWorkspace(options) {
274
329
  setStatus(index, "not_applied");
275
330
  notApplied.push(rejection(index, operationTouchedPaths(operation), "NotApplied"));
276
331
  };
277
- const release = await acquireMutationLock(options.lockKey ?? fs.scope, options.signal);
278
- try {
279
- await gcAgentPatchTemps(fs, patch.operations.flatMap((operation) => operationTouchedPaths(operation).map((path) => {
280
- const separator = path.lastIndexOf("/");
281
- return separator < 0 ? "." : path.slice(0, separator);
282
- })), options.signal);
283
- emit("parsed");
284
- for (const [index, operation] of patch.operations.entries()) {
285
- if (rejectedIndices.has(index))
286
- continue;
287
- if (halt !== undefined) {
288
- markNotApplied(index, operation);
289
- continue;
290
- }
291
- if (options.signal?.aborted) {
292
- if (applied.length === 0 && unconfirmed.length === 0)
293
- throw options.signal.reason ?? new Error("aborted");
294
- halt = "not_applied";
295
- markNotApplied(index, operation);
296
- continue;
332
+ await gcAgentPatchTemps(fs, patch.operations.flatMap((operation) => operationTouchedPaths(operation).map((path) => {
333
+ const separator = path.lastIndexOf("/");
334
+ return separator < 0 ? "." : path.slice(0, separator);
335
+ })), options.signal);
336
+ emit("parsed");
337
+ for (const [index, operation] of patch.operations.entries()) {
338
+ if (rejectedIndices.has(index))
339
+ continue;
340
+ if (halt !== undefined) {
341
+ markNotApplied(index, operation);
342
+ continue;
343
+ }
344
+ if (options.signal?.aborted) {
345
+ if (applied.length === 0 && unconfirmed.length === 0)
346
+ throw options.signal.reason ?? new Error("aborted");
347
+ halt = "not_applied";
348
+ markNotApplied(index, operation);
349
+ continue;
350
+ }
351
+ try {
352
+ if (operation.kind === "add") {
353
+ const outcome = await applyAdd(fs, index, operation, options.signal);
354
+ applied.push(outcome);
355
+ setStatus(index, "applied", outcome);
297
356
  }
298
- try {
299
- if (operation.kind === "add") {
300
- const outcome = await applyAdd(fs, index, operation, options.signal);
301
- applied.push(outcome);
302
- setStatus(index, "applied", outcome);
357
+ else if (operation.kind === "delete") {
358
+ const deleted = await applyDelete(fs, index, operation, options.signal);
359
+ applied.push(deleted.outcome);
360
+ const current = progressOperations[index];
361
+ if (current !== undefined) {
362
+ progressOperations[index] = Object.freeze({
363
+ ...current,
364
+ ...plannedDelta(operation, deleted.before),
365
+ status: "applied",
366
+ });
303
367
  }
304
- else if (operation.kind === "delete") {
305
- const deleted = await applyDelete(fs, index, operation, options.signal);
306
- applied.push(deleted.outcome);
307
- const current = progressOperations[index];
308
- if (current !== undefined) {
309
- progressOperations[index] = Object.freeze({
310
- ...current,
311
- ...plannedDelta(operation, deleted.before),
312
- status: "applied",
313
- });
314
- }
315
- }
316
- else {
317
- const published = await applyUpdate(fs, index, operation, options.policy, options.signal);
318
- if (published.mode === "exact")
319
- exactUpdateCount += 1;
320
- if (published.mode === "fuzzy")
321
- fuzzyUpdateCount += 1;
322
- applied.push(published.outcome);
323
- const partialReason = published.rejectedHunks.length === 0
324
- ? undefined
325
- : published.rejectedHunks[0]?.kind === "context_not_found"
326
- ? "context not found"
327
- : "exact context is ambiguous";
328
- if (published.rejectedHunks.length > 0)
329
- rejected.push(rejection(index, operationTouchedPaths(operation), "One or more update hunks failed", published.rejectedHunks));
330
- if (published.sourceError !== undefined) {
331
- if (published.sourceUnknown === true) {
332
- unconfirmed.push(rejection(index, [operation.path], published.sourceError));
333
- setStatus(index, "unconfirmed", published.outcome, operation, undefined, published.prepared);
334
- halt = "unconfirmed";
335
- }
336
- else {
337
- rejected.push(rejection(index, [operation.path], published.sourceError));
338
- setStatus(index, "partial", published.outcome, operation, partialReason ?? String(published.sourceError), published.prepared);
339
- }
368
+ }
369
+ else {
370
+ const published = await applyUpdate(fs, index, operation, options.policy, options.signal);
371
+ if (published.mode === "exact")
372
+ exactUpdateCount += 1;
373
+ if (published.mode === "fuzzy")
374
+ fuzzyUpdateCount += 1;
375
+ applied.push(published.outcome);
376
+ const partialReason = published.rejectedHunks.length === 0
377
+ ? undefined
378
+ : published.rejectedHunks[0]?.kind === "context_not_found"
379
+ ? "context not found"
380
+ : "exact context is ambiguous";
381
+ if (published.rejectedHunks.length > 0)
382
+ rejected.push(rejection(index, operationTouchedPaths(operation), "One or more update hunks failed", published.rejectedHunks));
383
+ if (published.sourceError !== undefined) {
384
+ if (published.sourceUnknown === true) {
385
+ unconfirmed.push(rejection(index, [operation.path], published.sourceError));
386
+ setStatus(index, "unconfirmed", published.outcome, operation, undefined, published.prepared);
387
+ halt = "unconfirmed";
340
388
  }
341
389
  else {
342
- setStatus(index, partialReason === undefined
343
- ? published.mode === "fuzzy"
344
- ? "fuzzy"
345
- : "applied"
346
- : "partial", published.outcome, operation, partialReason, published.prepared);
390
+ rejected.push(rejection(index, [operation.path], published.sourceError));
391
+ setStatus(index, "partial", published.outcome, operation, partialReason ?? String(published.sourceError), published.prepared);
347
392
  }
348
393
  }
349
- }
350
- catch (error) {
351
- if (options.signal?.aborted && applied.length === 0 && unconfirmed.length === 0)
352
- throw options.signal.reason ?? error;
353
- if (error instanceof FsTransportError) {
354
- const bucket = error.phase === "write" ? notApplied : unconfirmed;
355
- const status = error.phase === "write" ? "not_applied" : "unconfirmed";
356
- bucket.push(rejection(index, operationTouchedPaths(operation), error));
357
- setStatus(index, status);
358
- halt = status;
359
- }
360
- else if (error instanceof PatchUpdateError) {
361
- rejected.push(rejection(index, operationTouchedPaths(operation), error, error.diagnostics));
362
- rejectedIndices.add(index);
363
- setStatus(index, "rejected");
364
- }
365
- else if (options.signal?.aborted) {
366
- notApplied.push(rejection(index, operationTouchedPaths(operation), error));
367
- setStatus(index, "not_applied");
368
- halt = "not_applied";
369
- }
370
394
  else {
371
- rejected.push(rejection(index, operationTouchedPaths(operation), error));
372
- rejectedIndices.add(index);
373
- setStatus(index, "rejected");
395
+ setStatus(index, partialReason === undefined
396
+ ? published.mode === "fuzzy"
397
+ ? "fuzzy"
398
+ : "applied"
399
+ : "partial", published.outcome, operation, partialReason, published.prepared);
374
400
  }
375
401
  }
376
- emit("publishing");
377
- await new Promise((resolve) => setImmediate(resolve));
378
402
  }
379
- if (halt !== undefined) {
380
- for (const [index, operation] of patch.operations.entries()) {
381
- if (progressOperations[index]?.status !== "pending")
382
- continue;
383
- markNotApplied(index, operation);
403
+ catch (error) {
404
+ if (options.signal?.aborted && applied.length === 0 && unconfirmed.length === 0)
405
+ throw options.signal.reason ?? error;
406
+ if (error instanceof FsTransportError) {
407
+ const bucket = error.phase === "write" ? notApplied : unconfirmed;
408
+ const status = error.phase === "write" ? "not_applied" : "unconfirmed";
409
+ bucket.push(rejection(index, operationTouchedPaths(operation), error));
410
+ setStatus(index, status);
411
+ halt = status;
412
+ }
413
+ else if (error instanceof PatchUpdateError) {
414
+ rejected.push(rejection(index, operationTouchedPaths(operation), error, error.diagnostics));
415
+ rejectedIndices.add(index);
416
+ setStatus(index, "rejected");
417
+ }
418
+ else if (options.signal?.aborted) {
419
+ notApplied.push(rejection(index, operationTouchedPaths(operation), error));
420
+ setStatus(index, "not_applied");
421
+ halt = "not_applied";
422
+ }
423
+ else {
424
+ rejected.push(rejection(index, operationTouchedPaths(operation), error));
425
+ rejectedIndices.add(index);
426
+ setStatus(index, "rejected");
384
427
  }
385
428
  }
386
- emit("done");
387
- const totals = progressTotals(progressOperations);
388
- return Object.freeze({
389
- changedPaths: Object.freeze([
390
- ...new Set(applied.flatMap((operation) => [...operation.paths])),
391
- ]),
392
- addedLines: totals.addedLines,
393
- removedLines: totals.removedLines,
394
- operations: Object.freeze([...progressOperations]),
395
- operationCount: patch.operations.length,
396
- exactUpdateCount,
397
- fuzzyUpdateCount,
398
- applied: Object.freeze(applied),
399
- rejected: Object.freeze(rejected),
400
- unconfirmed: Object.freeze(unconfirmed),
401
- notApplied: Object.freeze(notApplied),
402
- });
429
+ emit("publishing");
430
+ await new Promise((resolve) => setImmediate(resolve));
403
431
  }
404
- finally {
405
- await release();
432
+ if (halt !== undefined) {
433
+ for (const [index, operation] of patch.operations.entries()) {
434
+ if (progressOperations[index]?.status !== "pending")
435
+ continue;
436
+ markNotApplied(index, operation);
437
+ }
406
438
  }
439
+ await relocateRejections(fs, patch, rejected, applied, options.signal);
440
+ emit("done");
441
+ const totals = progressTotals(progressOperations);
442
+ return Object.freeze({
443
+ changedPaths: Object.freeze([...new Set(applied.flatMap((operation) => [...operation.paths]))]),
444
+ addedLines: totals.addedLines,
445
+ removedLines: totals.removedLines,
446
+ operations: Object.freeze([...progressOperations]),
447
+ operationCount: patch.operations.length,
448
+ exactUpdateCount,
449
+ fuzzyUpdateCount,
450
+ applied: Object.freeze(applied),
451
+ rejected: Object.freeze(rejected),
452
+ unconfirmed: Object.freeze(unconfirmed),
453
+ notApplied: Object.freeze(notApplied),
454
+ });
407
455
  }
@@ -2,5 +2,6 @@ export { type ApplyPatchInWorkspaceOptions, applyPatchInWorkspace, } from "./exe
2
2
  export { APPLY_PATCH_MAX_FILE_SIZE, createLocalPatchFs, FsTransportError, fileTooLarge, gcAgentPatchTemps, type PatchFs, publishPreparedFile, } from "./fs.js";
3
3
  export { MUTATION_GLYPH, MUTATION_TONE } from "./glyph.js";
4
4
  export type { ApplyPatchAppliedOperation, ApplyPatchHunkSnapshot, ApplyPatchInWorkspaceResult, ApplyPatchOperationProgress, ApplyPatchProgress, ApplyPatchRejection, PatchHunkOutcome, PreparedPatchUpdate, } from "./outcome.js";
5
+ export { CONFIRMED_STATUSES } from "./outcome.js";
5
6
  export { compileV4aUpdateToUnifiedDiff, createV4aPreviewCursor, findV4aPatchConflicts, MAX_V4A_HUNK_LINES, MAX_V4A_HUNKS_PER_UPDATE, MAX_V4A_OPERATIONS, MAX_V4A_PATCH_BYTES, MAX_V4A_PATH_BYTES, MAX_V4A_PATH_SEGMENT_BYTES, operationTouchedPaths, parseV4aPatch, parseV4aPatchProgressively, previewV4aPatchFileCount, previewV4aPatchPrefix, type V4aAddedLine, type V4aAddOperation, type V4aContextLine, type V4aDeleteOperation, type V4aPatch, type V4aPatchConflict, type V4aPatchOperation, type V4aPreviewCursor, type V4aPreviewOperation, type V4aRemovedLine, type V4aUpdateHunk, type V4aUpdateLine, type V4aUpdateOperation, } from "./parser.js";
6
7
  export { type ApplyPatchPolicy, DEFAULT_APPLY_PATCH_POLICY, type LoadApplyPatchPolicyOptions, loadApplyPatchPolicy, } from "./policy.js";
@@ -1,5 +1,6 @@
1
1
  export { applyPatchInWorkspace, } from "./executor.js";
2
2
  export { APPLY_PATCH_MAX_FILE_SIZE, createLocalPatchFs, FsTransportError, fileTooLarge, gcAgentPatchTemps, publishPreparedFile, } from "./fs.js";
3
3
  export { MUTATION_GLYPH, MUTATION_TONE } from "./glyph.js";
4
+ export { CONFIRMED_STATUSES } from "./outcome.js";
4
5
  export { compileV4aUpdateToUnifiedDiff, createV4aPreviewCursor, findV4aPatchConflicts, MAX_V4A_HUNK_LINES, MAX_V4A_HUNKS_PER_UPDATE, MAX_V4A_OPERATIONS, MAX_V4A_PATCH_BYTES, MAX_V4A_PATH_BYTES, MAX_V4A_PATH_SEGMENT_BYTES, operationTouchedPaths, parseV4aPatch, parseV4aPatchProgressively, previewV4aPatchFileCount, previewV4aPatchPrefix, } from "./parser.js";
5
6
  export { DEFAULT_APPLY_PATCH_POLICY, loadApplyPatchPolicy, } from "./policy.js";
@@ -1,4 +1,4 @@
1
- import type { PreparedPatchUpdate } from "./outcome.js";
1
+ import type { PreparedPatchUpdate, RejectedPatchHunk } from "./outcome.js";
2
2
  import type { V4aUpdateHunk } from "./parser.js";
3
3
  export interface JsDiffHunkInput {
4
4
  readonly unifiedDiff: string;
@@ -10,4 +10,11 @@ export interface JsDiffUpdateInput {
10
10
  readonly fuzzFactor: number;
11
11
  readonly snapshotPath: string;
12
12
  }
13
+ /**
14
+ * Why one hunk cannot land in `content`, with any line numbers taken from that same content. Hunks
15
+ * and operations run in order over one growing copy, so a diagnostic computed when the hunk was
16
+ * tried would name lines that later writes had already moved; resolving it against the final file is
17
+ * what keeps the numbers readable by the model that has to retry.
18
+ */
19
+ export declare function diagnoseRejectedHunk(content: Uint8Array, hunk: V4aUpdateHunk, hunkIndex: number): RejectedPatchHunk;
13
20
  export declare function prepareJsDiffUpdate(data: JsDiffUpdateInput): PreparedPatchUpdate;
@@ -364,6 +364,33 @@ function snapshotFromChange(path, hunkIndex, change, beforeLines, afterLines) {
364
364
  after: Object.freeze(afterLines.slice(afterStart, afterEnd)),
365
365
  });
366
366
  }
367
+ /**
368
+ * Why one hunk cannot land in `content`, with any line numbers taken from that same content. Hunks
369
+ * and operations run in order over one growing copy, so a diagnostic computed when the hunk was
370
+ * tried would name lines that later writes had already moved; resolving it against the final file is
371
+ * what keeps the numbers readable by the model that has to retry.
372
+ */
373
+ export function diagnoseRejectedHunk(content, hunk, hunkIndex) {
374
+ const lines = textLines(content);
375
+ const constraint = anchorConstraint(lines, hunk);
376
+ if ("kind" in constraint)
377
+ return { ...constraint, hunkIndex };
378
+ const oldTexts = oldSideTexts(hunk);
379
+ const isEof = constraint.endOfFile === true || hunk.endOfFile === true;
380
+ const anchored = lines.slice(constraint.prefixLines);
381
+ const eofLines = isEof ? Math.max(0, anchored.length - oldTexts.length) : 0;
382
+ const prefixLines = constraint.prefixLines + eofLines;
383
+ const searchable = lines.slice(prefixLines).map((line) => line.text);
384
+ const tiered = findTieredStarts(searchable, oldTexts);
385
+ if (oldTexts.length > 0 && tiered.tier === "ambiguous")
386
+ return {
387
+ kind: "ambiguous_exact",
388
+ hunkIndex,
389
+ candidateStartLines: tiered.starts.map((start) => start + prefixLines + 1),
390
+ };
391
+ const hint = findDiagnosticHint(searchable, oldTexts, prefixLines);
392
+ return { kind: "context_not_found", hunkIndex, ...(hint === undefined ? {} : { hint }) };
393
+ }
367
394
  function parseOneHunk(parsePatch, unifiedDiff, hunk) {
368
395
  let parsed;
369
396
  try {
@@ -392,7 +419,7 @@ export function prepareJsDiffUpdate(data) {
392
419
  if (data.before.length > MAX_FILE_SIZE)
393
420
  throw new Error(`file_too_large: ${data.before.length} bytes`);
394
421
  const outcomes = [];
395
- const rejected = [];
422
+ const pending = [];
396
423
  const snapshots = [];
397
424
  let addedLines = 0;
398
425
  let removedLines = 0;
@@ -412,7 +439,7 @@ export function prepareJsDiffUpdate(data) {
412
439
  }
413
440
  const constraint = anchorConstraint(original, hunk);
414
441
  if ("kind" in constraint) {
415
- rejected.push({ ...constraint, hunkIndex });
442
+ pending.push({ hunkIndex, hunk });
416
443
  continue;
417
444
  }
418
445
  const oldTexts = oldSideTexts(hunk);
@@ -432,11 +459,7 @@ export function prepareJsDiffUpdate(data) {
432
459
  const tieredMatch = findTieredStarts(searchable.map((line) => line.text), oldTexts);
433
460
  const matchedStarts = tieredMatch.starts.map((start) => start + prefixLines);
434
461
  if (oldLength > 0 && tieredMatch.tier === "ambiguous") {
435
- rejected.push({
436
- kind: "ambiguous_exact",
437
- hunkIndex,
438
- candidateStartLines: matchedStarts.map((start) => start + 1),
439
- });
462
+ pending.push({ hunkIndex, hunk });
440
463
  continue;
441
464
  }
442
465
  const sourceView = lfView(original);
@@ -514,17 +537,12 @@ export function prepareJsDiffUpdate(data) {
514
537
  parsed = parseOneHunk(parsePatch, input.unifiedDiff, hunk);
515
538
  }
516
539
  if ("kind" in parsed) {
517
- rejected.push({ ...parsed, hunkIndex });
540
+ pending.push({ hunkIndex, hunk });
518
541
  continue;
519
542
  }
520
543
  const parsedHunk = parsed.hunks[0];
521
544
  if (parsedHunk === undefined) {
522
- const hint = findDiagnosticHint(searchable.map((line) => line.text), oldTexts, prefixLines);
523
- rejected.push({
524
- kind: "context_not_found",
525
- hunkIndex,
526
- ...(hint !== undefined ? { hint } : {}),
527
- });
545
+ pending.push({ hunkIndex, hunk });
528
546
  continue;
529
547
  }
530
548
  if (oldLength === 0) {
@@ -549,12 +567,7 @@ export function prepareJsDiffUpdate(data) {
549
567
  match = "fuzzy";
550
568
  }
551
569
  if (applied === false) {
552
- const hint = findDiagnosticHint(searchable.map((line) => line.text), oldTexts, prefixLines);
553
- rejected.push({
554
- kind: "context_not_found",
555
- hunkIndex,
556
- ...(hint !== undefined ? { hint } : {}),
557
- });
570
+ pending.push({ hunkIndex, hunk });
558
571
  continue;
559
572
  }
560
573
  const changes = structuredPatch("before", "after", sourceView, applied, undefined, undefined, {
@@ -601,6 +614,7 @@ export function prepareJsDiffUpdate(data) {
601
614
  mode ??= "exact";
602
615
  after = rebuilt;
603
616
  }
617
+ const rejected = pending.map((item) => diagnoseRejectedHunk(after, item.hunk, item.hunkIndex));
604
618
  return {
605
619
  after,
606
620
  mode,
@@ -6,4 +6,4 @@ interface RunJsDiffUpdateOptions {
6
6
  readonly signal?: AbortSignal;
7
7
  }
8
8
  export declare function runJsDiffUpdate(options: RunJsDiffUpdateOptions): Promise<PreparedPatchUpdate>;
9
- export {};
9
+ export { diagnoseRejectedHunk } from "./jsdiff-matching.js";
@@ -217,3 +217,4 @@ export async function runJsDiffUpdate(options) {
217
217
  }
218
218
  }
219
219
  }
220
+ export { diagnoseRejectedHunk } from "./jsdiff-matching.js";
@@ -51,6 +51,12 @@ export interface ApplyPatchRejection {
51
51
  }
52
52
  export type ApplyPatchProgressStage = "parsed" | "publishing" | "done";
53
53
  export type ApplyPatchOperationStatus = "pending" | "applied" | "partial" | "fuzzy" | "rejected" | "unconfirmed" | "not_applied";
54
+ /**
55
+ * Rows whose delta is a confirmed write. `changedPaths` comes from these rows only, so the model
56
+ * never reads a path as changed before the Publish that changed it, and the header totals count
57
+ * these rows plus the pending preview.
58
+ */
59
+ export declare const CONFIRMED_STATUSES: ReadonlySet<ApplyPatchOperationStatus>;
54
60
  export type ApplyPatchOperationProgress = {
55
61
  readonly operationIndex: number;
56
62
  readonly kind: "add" | "delete" | "update";
@@ -64,7 +70,6 @@ export type ApplyPatchOperationProgress = {
64
70
  };
65
71
  export interface ApplyPatchProgress {
66
72
  readonly stage: ApplyPatchProgressStage;
67
- readonly files: number;
68
73
  readonly addedLines: number;
69
74
  readonly removedLines: number;
70
75
  readonly operations: readonly ApplyPatchOperationProgress[];
@@ -1 +1,10 @@
1
- export {};
1
+ /**
2
+ * Rows whose delta is a confirmed write. `changedPaths` comes from these rows only, so the model
3
+ * never reads a path as changed before the Publish that changed it, and the header totals count
4
+ * these rows plus the pending preview.
5
+ */
6
+ export const CONFIRMED_STATUSES = new Set([
7
+ "applied",
8
+ "fuzzy",
9
+ "partial",
10
+ ]);
@@ -1,7 +1,7 @@
1
1
  import { stripTerminalSequences } from "@earendil-works/pi-tui";
2
2
  import { agentResultText, isRecord, } from "@hheei/pi-ext-core";
3
3
  import { createV4aPreviewCursor, previewV4aPatchPrefix } from "./parser.js";
4
- import { applyPatchResultFacts, applyPatchSummary, formatApplyPatchOperationRow, formatApplyPatchRejectionRows, formatApplyPatchSnapshotRows, } from "./view.js";
4
+ import { applyPatchFilesInPlay, applyPatchResultFacts, applyPatchSummary, formatApplyPatchOperationRow, formatApplyPatchRejectionRows, formatApplyPatchSnapshotRows, } from "./view.js";
5
5
  /**
6
6
  * Preview cursors live in this module's own map, keyed by the row's shared render state: the cursor
7
7
  * is display-only progress, so it is never persisted and never read back from the session.
@@ -63,7 +63,9 @@ function resultStatus(facts, isError, isPartial) {
63
63
  return "error";
64
64
  if (facts.status === "partial")
65
65
  return "warn";
66
- return "none";
66
+ // Live progress carries no verdict of its own, so a streamed success still reads as running
67
+ // until the result replaces it.
68
+ return isPartial ? "running" : "none";
67
69
  }
68
70
  /** Result sections: operation rows, one row block per applied snapshot, then the failure rows. */
69
71
  function resultSections(facts, theme) {
@@ -115,8 +117,12 @@ export const applyPatchPresentation = (args, result, theme, context) => {
115
117
  };
116
118
  }
117
119
  const rows = operationRows(facts.operations, theme);
120
+ // A live frame has no confirmed paths yet, so its file count comes from the rows it shows.
121
+ const files = context.isPartial
122
+ ? applyPatchFilesInPlay(facts.operations)
123
+ : new Set(facts.changedPaths).size;
118
124
  return {
119
- summary: applyPatchSummary(new Set(facts.changedPaths).size, facts.addedLines, facts.removedLines),
125
+ summary: applyPatchSummary(files, facts.addedLines, facts.removedLines),
120
126
  status: resultStatus(facts, isError, context.isPartial),
121
127
  replaceCallOnPartial: true,
122
128
  ...(output === "" ? {} : { output }),
@@ -1,6 +1,6 @@
1
- import { createOptionalToolRenderers, createToolPresentationRenderers, defaultExtensionSettingsPaths, errorMessage, textToolResult, } from "@hheei/pi-ext-core";
1
+ import { createOptionalToolRenderers, createRenderThrottle, createToolPresentationRenderers, defaultExtensionSettingsPaths, errorMessage, textToolResult, } from "@hheei/pi-ext-core";
2
2
  import { Type } from "typebox";
3
- import { applyPatchInWorkspace, loadApplyPatchPolicy, } from "./index.js";
3
+ import { applyPatchInWorkspace, CONFIRMED_STATUSES, loadApplyPatchPolicy, } from "./index.js";
4
4
  import { isPatchPathOutsideWorkspace } from "./paths.js";
5
5
  import { applyPatchPresentation } from "./presentation.js";
6
6
  const MAX_CANDIDATES = 6;
@@ -108,7 +108,10 @@ function externalPathWarning(workspaceRoot, result) {
108
108
  }
109
109
  function progressDetails(progress) {
110
110
  return {
111
- changedPaths: [],
111
+ // Live details report confirmed writes only, the same rows the header totals count.
112
+ changedPaths: progress.operations
113
+ .filter((operation) => CONFIRMED_STATUSES.has(operation.status))
114
+ .map((operation) => operation.path),
112
115
  addedLines: progress.addedLines,
113
116
  removedLines: progress.removedLines,
114
117
  operations: progress.operations,
@@ -134,6 +137,16 @@ export function createApplyPatchTool() {
134
137
  executionMode: "sequential",
135
138
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
136
139
  const { patch } = parseApplyPatchParameters(params);
140
+ // One operation per repaint used to rebuild the whole operation table. A patch with many
141
+ // operations now repaints on the shared streaming window; the returned result stays the
142
+ // only authority for the finished call.
143
+ let latestProgress;
144
+ const repaint = createRenderThrottle(() => {
145
+ const progress = latestProgress;
146
+ if (progress === undefined)
147
+ return;
148
+ onUpdate?.({ content: [], details: progressDetails(progress) });
149
+ });
137
150
  try {
138
151
  const policy = await loadApplyPatchPolicy({
139
152
  paths: defaultExtensionSettingsPaths(ctx.cwd),
@@ -144,10 +157,10 @@ export function createApplyPatchTool() {
144
157
  patch,
145
158
  policy,
146
159
  ...(signal === undefined ? {} : { signal }),
147
- onProgress: (progress) => onUpdate?.({
148
- content: [],
149
- details: progressDetails(progress),
150
- }),
160
+ onProgress: (progress) => {
161
+ latestProgress = progress;
162
+ repaint.request();
163
+ },
151
164
  });
152
165
  const warning = externalPathWarning(ctx.cwd, result);
153
166
  const status = statusFor(result);
@@ -164,6 +177,11 @@ export function createApplyPatchTool() {
164
177
  const message = errorMessage(error);
165
178
  throw new Error(`apply_patch failed: ${message}`);
166
179
  }
180
+ finally {
181
+ // The result below is the only authority for the finished call, so the window closes
182
+ // here: a repaint after it is built would rewrite a settled body.
183
+ repaint.dispose();
184
+ }
167
185
  },
168
186
  };
169
187
  }
@@ -20,6 +20,12 @@ export interface ApplyPatchResultFacts {
20
20
  export declare function applyPatchResultFacts(details: unknown): ApplyPatchResultFacts | undefined;
21
21
  /** How one mutation outcome is drawn. One outcome never draws two symbols across the tools. */
22
22
  export declare function applyPatchGlyph(status: string, theme: Theme): string;
23
+ /**
24
+ * Distinct paths a live frame still has in play: a pending row previews the write it is about to
25
+ * make, and a row that certainly wrote nothing is not counted. The finished result counts
26
+ * `changedPaths` instead, so a preview never claims a path was changed.
27
+ */
28
+ export declare function applyPatchFilesInPlay(operations: readonly ApplyPatchOperationProgress[]): number;
23
29
  /** The header summary every apply_patch row shares, including nested calls. */
24
30
  export declare function applyPatchSummary(files: number, added: number, removed: number): string;
25
31
  /** One operation row: glyph, verb, path, delta, fuzzy marker, hunk summary. */
@@ -165,6 +165,16 @@ export function applyPatchGlyph(status, theme) {
165
165
  const tone = MUTATION_TONE[status] ?? "error";
166
166
  return theme.fg(tone, glyph);
167
167
  }
168
+ /**
169
+ * Distinct paths a live frame still has in play: a pending row previews the write it is about to
170
+ * make, and a row that certainly wrote nothing is not counted. The finished result counts
171
+ * `changedPaths` instead, so a preview never claims a path was changed.
172
+ */
173
+ export function applyPatchFilesInPlay(operations) {
174
+ return new Set(operations
175
+ .filter((operation) => operation.status !== "rejected" && operation.status !== "not_applied")
176
+ .map((operation) => operation.path)).size;
177
+ }
168
178
  /** The header summary every apply_patch row shares, including nested calls. */
169
179
  export function applyPatchSummary(files, added, removed) {
170
180
  return `modify ${String(files)}${files === 1 ? " file" : " files"} (+${String(added)} -${String(removed)})`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hheei/pi-apply-patch",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "V4A apply_patch editing tool with worker matching and per-path publishing.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,7 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@hheei/pi-ext-core": "^0.5.0",
39
+ "@hheei/pi-ext-core": "^0.6.1",
40
40
  "diff": "^9.0.0"
41
41
  },
42
42
  "peerDependencies": {