@gajae-code/coding-agent 0.12.4 → 0.12.6

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.
Files changed (139) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -1
  3. package/dist/types/async/job-manager.d.ts +9 -1
  4. package/dist/types/eval/backend.d.ts +2 -2
  5. package/dist/types/exec/bash-executor.d.ts +10 -8
  6. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  7. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  8. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  9. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  10. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  11. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  12. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  14. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  15. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  16. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  17. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
  19. package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
  20. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  21. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  23. package/dist/types/sdk/broker/broker.d.ts +18 -0
  24. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  25. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  26. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -2
  27. package/dist/types/sdk/bus/index.d.ts +4 -0
  28. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -3
  29. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  30. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  31. package/dist/types/sdk/host/host.d.ts +23 -2
  32. package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
  33. package/dist/types/session/agent-session.d.ts +5 -4
  34. package/dist/types/session/artifacts.d.ts +8 -5
  35. package/dist/types/session/attempt-record-store.d.ts +19 -0
  36. package/dist/types/session/client-bridge.d.ts +14 -0
  37. package/dist/types/session/internal/managed-session-storage.d.ts +7 -23
  38. package/dist/types/session/messages.d.ts +4 -1
  39. package/dist/types/session/session-manager.d.ts +7 -5
  40. package/dist/types/session/session-storage.d.ts +8 -0
  41. package/dist/types/session/streaming-output.d.ts +32 -0
  42. package/dist/types/task/executor.d.ts +1 -1
  43. package/dist/types/task/index.d.ts +2 -1
  44. package/dist/types/task/receipt.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +194 -0
  46. package/dist/types/task/worktree.d.ts +13 -6
  47. package/dist/types/tools/bash.d.ts +1 -1
  48. package/dist/types/tools/fetch.d.ts +3 -0
  49. package/dist/types/tools/output-meta.d.ts +37 -5
  50. package/dist/types/tools/subagent.d.ts +2 -0
  51. package/dist/types/tools/tool-result.d.ts +1 -0
  52. package/dist/types/utils/artifact-id.d.ts +2 -0
  53. package/dist/types/utils/git.d.ts +1 -0
  54. package/package.json +8 -8
  55. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  56. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  57. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  58. package/src/async/job-manager.ts +13 -5
  59. package/src/config/model-profiles.ts +8 -0
  60. package/src/config/settings-schema.ts +17 -3
  61. package/src/cursor.ts +20 -13
  62. package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
  63. package/src/eval/backend.ts +2 -2
  64. package/src/exec/bash-executor.ts +425 -151
  65. package/src/extensibility/extensions/runner.ts +80 -6
  66. package/src/extensibility/extensions/wrapper.ts +22 -15
  67. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  68. package/src/gjc-runtime/launch-tmux.ts +309 -293
  69. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  70. package/src/gjc-runtime/psmux-detect.ts +52 -6
  71. package/src/gjc-runtime/team-launch.ts +50 -2
  72. package/src/gjc-runtime/team-runtime.ts +716 -251
  73. package/src/gjc-runtime/team-store.ts +25 -7
  74. package/src/gjc-runtime/team-workers.ts +161 -1
  75. package/src/gjc-runtime/tmux-common.ts +16 -4
  76. package/src/gjc-runtime/tmux-gc.ts +37 -5
  77. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  78. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  79. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  80. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  81. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  82. package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
  83. package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
  84. package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
  85. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  86. package/src/internal-urls/agent-protocol.ts +98 -38
  87. package/src/internal-urls/artifact-protocol.ts +28 -17
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/internal-urls/local-protocol.ts +39 -18
  90. package/src/internal-urls/registry-helpers.ts +62 -0
  91. package/src/modes/acp/acp-agent.ts +51 -13
  92. package/src/modes/acp/acp-client-bridge.ts +154 -11
  93. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  94. package/src/modes/components/plugin-settings.ts +5 -0
  95. package/src/modes/components/settings-selector.ts +4 -0
  96. package/src/modes/controllers/selector-controller.ts +1 -0
  97. package/src/prompts/tools/bash.md +2 -2
  98. package/src/prompts/tools/monitor.md +4 -4
  99. package/src/sdk/acp/adapter.ts +200 -9
  100. package/src/sdk/broker/broker.ts +93 -10
  101. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  102. package/src/sdk/broker/lifecycle.ts +692 -98
  103. package/src/sdk/bus/chat-daemon-control.ts +33 -5
  104. package/src/sdk/bus/index.ts +347 -14
  105. package/src/sdk/bus/telegram-daemon-contract.ts +7 -3
  106. package/src/sdk/bus/telegram-daemon.ts +283 -44
  107. package/src/sdk/bus/topic-registry.ts +133 -10
  108. package/src/sdk/host/host.ts +187 -9
  109. package/src/sdk/host/reverse-leases.ts +249 -42
  110. package/src/sdk/session.ts +8 -6
  111. package/src/session/agent-session.ts +930 -354
  112. package/src/session/artifacts.ts +211 -74
  113. package/src/session/attempt-record-store.ts +106 -0
  114. package/src/session/client-bridge.ts +15 -0
  115. package/src/session/internal/managed-session-scope.ts +332 -198
  116. package/src/session/internal/managed-session-storage.ts +162 -450
  117. package/src/session/messages.ts +32 -7
  118. package/src/session/session-manager.ts +31 -28
  119. package/src/session/session-storage.ts +190 -53
  120. package/src/session/streaming-output.ts +198 -40
  121. package/src/task/executor.ts +5 -2
  122. package/src/task/index.ts +277 -64
  123. package/src/task/provider-retry-status.ts +4 -1
  124. package/src/task/receipt.ts +26 -2
  125. package/src/task/render.ts +42 -1
  126. package/src/task/types.ts +52 -0
  127. package/src/task/worktree.ts +122 -43
  128. package/src/tools/bash.ts +818 -239
  129. package/src/tools/eval.ts +99 -4
  130. package/src/tools/fetch.ts +77 -15
  131. package/src/tools/find.ts +7 -3
  132. package/src/tools/monitor.ts +7 -1
  133. package/src/tools/output-meta.ts +706 -116
  134. package/src/tools/search.ts +9 -4
  135. package/src/tools/subagent-render.ts +5 -2
  136. package/src/tools/subagent.ts +8 -0
  137. package/src/tools/tool-result.ts +1 -0
  138. package/src/utils/artifact-id.ts +6 -0
  139. package/src/utils/git.ts +2 -0
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { Component } from "@gajae-code/tui";
9
9
  import { Text } from "@gajae-code/tui";
10
- import { formatNumber } from "@gajae-code/utils";
10
+ import { formatNumber, sanitizeText } from "@gajae-code/utils";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
12
  import type { Theme } from "../modes/theme/theme";
13
13
  import {
@@ -916,6 +916,47 @@ function renderAgentResult(result: TaskResultReceipt, isLast: boolean, expanded:
916
916
  if (result.roi?.lowRoi) {
917
917
  lines.push(`${continuePrefix}${theme.fg("warning", "low ROI: produced no material contribution")}`);
918
918
  }
919
+ if (result.reviewSource?.disposition === "stale_review_delivery") {
920
+ lines.push(
921
+ `${continuePrefix}${theme.fg(
922
+ "warning",
923
+ truncateToWidth(
924
+ replaceTabs(
925
+ sanitizeText(
926
+ `STALE REVIEW: source advanced after dispatch; excluded from review gates. Rerun: ${result.reviewSource.rerunCommand}`,
927
+ ),
928
+ ),
929
+
930
+ 100,
931
+ ),
932
+ )}`,
933
+ );
934
+ } else if (result.reviewSource?.disposition === "invalid_provenance") {
935
+ lines.push(
936
+ `${continuePrefix}${theme.fg(
937
+ "error",
938
+ truncateToWidth(
939
+ replaceTabs(
940
+ sanitizeText(
941
+ `INVALID REVIEW PROVENANCE: receipt cannot satisfy review gates. Rerun: ${result.reviewSource.rerunCommand}`,
942
+ ),
943
+ ),
944
+ 100,
945
+ ),
946
+ )}`,
947
+ );
948
+ }
949
+
950
+ if (result.persistence?.recoveryRef) {
951
+ const label = result.persistence.ownerWorktreeApplied ? "Recovery patch" : "Unapplied recovery patch";
952
+ const recoveryUri = truncateToWidth(replaceTabs(sanitizeText(result.persistence.recoveryRef.uri)), 80);
953
+ lines.push(
954
+ `${continuePrefix}${theme.fg(
955
+ result.persistence.ownerWorktreeApplied ? "dim" : "warning",
956
+ `${label}: ${recoveryUri} (${formatBytes(result.persistence.recoveryRef.sizeBytes)})`,
957
+ )}`,
958
+ );
959
+ }
919
960
 
920
961
  if (result.outputRef) {
921
962
  lines.push(
package/src/task/types.ts CHANGED
@@ -2,6 +2,7 @@ import type { ThinkingLevel } from "@gajae-code/agent-core";
2
2
  import type { Usage } from "@gajae-code/ai";
3
3
  import { $env } from "@gajae-code/utils";
4
4
  import * as z from "zod/v4";
5
+ import type { ReviewDeliveryDisposition, ReviewSourceLane } from "../gjc-runtime/ultragoal-review-source";
5
6
  import { isValidTaskId, TASK_ID_DESCRIPTION } from "./id";
6
7
  import type { TaskResultReceipt } from "./receipt";
7
8
  import type { SpawnRoiReconciliation } from "./roi-reconciliation";
@@ -114,8 +115,26 @@ const createTaskItemSchema = (_contextEnabled: boolean) =>
114
115
  .describe(
115
116
  "authoritative repository identity; omitted items are stamped from session cwd before discovery/spawn and still fail closed on sibling drift",
116
117
  ),
118
+ reviewSource: reviewSourceSchema.optional(),
117
119
  });
118
120
 
121
+ const reviewSourceSchema = z
122
+ .object({
123
+ schema: z.literal("gjc.review_source_dispatch.v1"),
124
+ cohortId: z.string().min(1),
125
+ generation: z.number().int().min(1),
126
+ lane: z.enum(["cleaner", "architect", "qa", "critic"]),
127
+ snapshotId: z.string().startsWith("sha256:"),
128
+ repositoryBindingDigest: z.string().startsWith("sha256:"),
129
+ stateRevision: z.number().int().min(0),
130
+ dispatchId: z.string().min(1),
131
+ rerunCommand: z.string().min(1),
132
+ taskId: z.string().min(1),
133
+ createdAt: z.string().min(1),
134
+ })
135
+ .strict()
136
+ .describe("leader-issued immutable review source identity; review lanes only");
137
+
119
138
  /** Single task item for parallel execution (default shape with context enabled). */
120
139
  export const taskItemSchema = createTaskItemSchema(true);
121
140
  export type TaskItem = z.infer<typeof taskItemSchema>;
@@ -425,6 +444,20 @@ export function createSetupFailureSummary(error: unknown): SetupFailureSummary {
425
444
  };
426
445
  }
427
446
 
447
+ export interface TaskRecoveryArtifactRef {
448
+ uri: string;
449
+ sizeBytes: number;
450
+ sha256: string;
451
+ /** Recovery artifacts remain readable for the parent session lifetime. */
452
+ durability: "session";
453
+ }
454
+
455
+ export interface TaskPersistenceResult {
456
+ outcome: "applied" | "no_changes" | "recovery_available";
457
+ ownerWorktreeApplied: boolean;
458
+ recoveryRef?: TaskRecoveryArtifactRef;
459
+ }
460
+
428
461
  /** Result from a single agent execution */
429
462
  export interface SingleResult {
430
463
  index: number;
@@ -470,6 +503,10 @@ export interface SingleResult {
470
503
  nestedPatches?: NestedRepoPatch[];
471
504
  /** Whether isolated execution produced a non-empty root or nested patch. */
472
505
  producedChanges?: boolean;
506
+ /** Receipt-safe owner-worktree persistence result for isolated execution. */
507
+ persistence?: TaskPersistenceResult;
508
+ /** Identity-bound patch artifact captured before isolation cleanup. */
509
+ recoveryRef?: TaskRecoveryArtifactRef;
473
510
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
474
511
  extractedToolData?: Record<string, unknown[]>;
475
512
  /** Full wrapper-owned review evidence, kept separate from caller completion data. */
@@ -506,6 +543,21 @@ export interface SingleResult {
506
543
  head?: string;
507
544
  branch?: string;
508
545
  };
546
+ /** Leader-issued immutable source identity for source-aware review lanes (#3469). */
547
+ reviewSource?: {
548
+ schema: "gjc.review_source_dispatch.v1";
549
+ cohortId: string;
550
+ generation: number;
551
+ lane: ReviewSourceLane;
552
+ snapshotId: string;
553
+ repositoryBindingDigest: string;
554
+ stateRevision: number;
555
+ dispatchId: string;
556
+ rerunCommand: string;
557
+ disposition: ReviewDeliveryDisposition;
558
+ currentSnapshotId?: string;
559
+ deliveryId?: string;
560
+ };
509
561
  }
510
562
 
511
563
  /** True only for complete, factual five-bucket cost accounting. */
@@ -83,13 +83,19 @@ async function captureUntrackedPatch(repoRoot: string, untracked: readonly strin
83
83
  if (untracked.length === 0) return "";
84
84
  const nullPath = getGitNoIndexNullPath();
85
85
  const untrackedDiffs = await Promise.all(
86
- untracked.map(entry =>
87
- git.diff(repoRoot, {
86
+ untracked.map(async entry => {
87
+ const absolutePath = path.join(repoRoot, entry);
88
+ const stat = await fs.lstat(absolutePath);
89
+ if (stat.isFile()) {
90
+ const handle = await fs.open(absolutePath, "r");
91
+ await handle.close();
92
+ }
93
+ return await git.diff(repoRoot, {
88
94
  allowFailure: true,
89
95
  binary: true,
90
96
  noIndex: { left: nullPath, right: entry },
91
- }),
92
- ),
97
+ });
98
+ }),
93
99
  );
94
100
  return untrackedDiffs.filter(diff => diff.trim()).join("\n");
95
101
  }
@@ -135,26 +141,40 @@ export async function captureBaseline(repoRoot: string): Promise<WorktreeBaselin
135
141
  return { root, nested };
136
142
  }
137
143
 
138
- async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise<string> {
144
+ async function captureRepoCurrentTree(repoDir: string): Promise<string> {
139
145
  const currentHead = (await git.head.sha(repoDir)) ?? "";
140
146
  const currentStaged = await git.diff(repoDir, { binary: true, cached: true });
141
147
  const currentUnstaged = await git.diff(repoDir, { binary: true });
142
148
  const currentUntracked = await git.ls.untracked(repoDir);
143
149
  const currentUntrackedPatch = await captureUntrackedPatch(repoDir, currentUntracked);
150
+ return await writeSyntheticTree(repoDir, currentHead, [currentStaged, currentUnstaged, currentUntrackedPatch]);
151
+ }
144
152
 
153
+ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise<string> {
145
154
  const baselineTree = await writeSyntheticTree(repoDir, rb.headCommit, [rb.staged, rb.unstaged, rb.untrackedPatch]);
146
- const currentTree = await writeSyntheticTree(repoDir, currentHead, [
147
- currentStaged,
148
- currentUnstaged,
149
- currentUntrackedPatch,
150
- ]);
155
+ const currentTree = await captureRepoCurrentTree(repoDir);
151
156
 
152
157
  return git.diff.tree(repoDir, baselineTree, currentTree, {
153
- allowFailure: true,
154
158
  binary: true,
155
159
  });
156
160
  }
157
161
 
162
+ /** Confirm that applying patches to the captured owner baseline exactly matches the current owner worktree. */
163
+ export async function verifyRootPatchesApplied(
164
+ repoRoot: string,
165
+ baseline: WorktreeBaseline,
166
+ patches: readonly string[],
167
+ ): Promise<boolean> {
168
+ const baselineTree = await writeSyntheticTree(repoRoot, baseline.root.headCommit, [
169
+ baseline.root.staged,
170
+ baseline.root.unstaged,
171
+ baseline.root.untrackedPatch,
172
+ ]);
173
+ const expectedTree = await writeSyntheticTree(repoRoot, baselineTree, patches);
174
+ const currentTree = await captureRepoCurrentTree(repoRoot);
175
+ return expectedTree === currentTree;
176
+ }
177
+
158
178
  export interface NestedRepoPatch {
159
179
  relativePath: string;
160
180
  patch: string;
@@ -163,64 +183,123 @@ export interface NestedRepoPatch {
163
183
  export interface DeltaPatchResult {
164
184
  rootPatch: string;
165
185
  nestedPatches: NestedRepoPatch[];
186
+ captureErrors?: string[];
187
+ }
188
+
189
+ export interface RecoveryPatchBundle {
190
+ version: 1;
191
+ rootPatch: string;
192
+ nestedPatches: NestedRepoPatch[];
193
+ captureErrors?: string[];
194
+ }
195
+
196
+ function captureErrorCode(error: unknown): string {
197
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
198
+ return typeof code === "string" && code.length > 0 ? code : "unknown_error";
199
+ }
200
+
201
+ export function serializeRecoveryPatchBundle(delta: DeltaPatchResult): string {
202
+ if (delta.nestedPatches.length === 0 && !delta.captureErrors?.length) return delta.rootPatch;
203
+ return `${JSON.stringify({
204
+ version: 1,
205
+ rootPatch: delta.rootPatch,
206
+ nestedPatches: delta.nestedPatches,
207
+ ...(delta.captureErrors?.length ? { captureErrors: delta.captureErrors } : {}),
208
+ } satisfies RecoveryPatchBundle)}\n`;
209
+ }
210
+
211
+ export async function verifyNestedPatchesApplied(
212
+ repoRoot: string,
213
+ baseline: WorktreeBaseline,
214
+ patches: readonly NestedRepoPatch[],
215
+ ): Promise<boolean> {
216
+ const grouped = new Map<string, string[]>();
217
+ for (const patch of patches) {
218
+ const current = grouped.get(patch.relativePath) ?? [];
219
+ current.push(patch.patch);
220
+ grouped.set(patch.relativePath, current);
221
+ }
222
+ for (const [relativePath, repoPatches] of grouped) {
223
+ const nestedBaseline = baseline.nested.find(entry => entry.relativePath === relativePath)?.baseline;
224
+ if (!nestedBaseline) return false;
225
+ const nestedDir = path.join(repoRoot, relativePath);
226
+ const baselineTree = await writeSyntheticTree(nestedDir, nestedBaseline.headCommit, [
227
+ nestedBaseline.staged,
228
+ nestedBaseline.unstaged,
229
+ nestedBaseline.untrackedPatch,
230
+ ]);
231
+ const expectedTree = await writeSyntheticTree(nestedDir, baselineTree, repoPatches);
232
+ if ((await captureRepoCurrentTree(nestedDir)) !== expectedTree) return false;
233
+ }
234
+ return true;
166
235
  }
167
236
 
168
237
  export async function captureDeltaPatch(isolationDir: string, baseline: WorktreeBaseline): Promise<DeltaPatchResult> {
169
238
  const rootPatch = await captureRepoDeltaPatch(isolationDir, baseline.root);
170
239
  const nestedPatches: NestedRepoPatch[] = [];
240
+ const captureErrors: string[] = [];
171
241
 
172
242
  for (const { relativePath, baseline: nb } of baseline.nested) {
173
243
  const nestedDir = path.join(isolationDir, relativePath);
174
244
  try {
175
245
  await fs.access(path.join(nestedDir, ".git"));
176
- } catch {
177
- continue;
246
+ const patch = await captureRepoDeltaPatch(nestedDir, nb);
247
+ if (patch.trim()) nestedPatches.push({ relativePath, patch });
248
+ } catch (error) {
249
+ captureErrors.push(`Nested repository capture failed (${relativePath}): ${captureErrorCode(error)}`);
178
250
  }
179
- const patch = await captureRepoDeltaPatch(nestedDir, nb);
180
- if (patch.trim()) nestedPatches.push({ relativePath, patch });
181
251
  }
182
252
 
183
- return { rootPatch, nestedPatches };
253
+ return { rootPatch, nestedPatches, ...(captureErrors.length ? { captureErrors } : {}) };
184
254
  }
185
255
 
186
- /**
187
- * Apply nested repo patches directly to their working directories after parent merge.
188
- * @param commitMessage Optional async function to generate a commit message from the combined diff.
189
- * If omitted or returns null, falls back to a generic message.
190
- */
191
- export async function applyNestedPatches(
192
- repoRoot: string,
193
- patches: NestedRepoPatch[],
194
- commitMessage?: (diff: string) => Promise<string | null>,
195
- ): Promise<void> {
196
- // Group patches by target repo to apply all at once and commit
256
+ /** Preflight every nested delta, then roll back earlier repositories if a later apply fails. */
257
+ export async function applyNestedPatches(repoRoot: string, patches: NestedRepoPatch[]): Promise<void> {
197
258
  const byRepo = new Map<string, NestedRepoPatch[]>();
198
- for (const p of patches) {
199
- if (!p.patch.trim()) continue;
200
- const group = byRepo.get(p.relativePath) ?? [];
201
- group.push(p);
202
- byRepo.set(p.relativePath, group);
259
+ for (const patch of patches) {
260
+ if (!patch.patch.trim()) continue;
261
+ const group = byRepo.get(patch.relativePath) ?? [];
262
+ group.push(patch);
263
+ byRepo.set(patch.relativePath, group);
203
264
  }
204
265
 
205
- for (const [relativePath, repoPatches] of byRepo) {
266
+ const prepared: Array<{ relativePath: string; nestedDir: string; combinedPatch: string }> = [];
267
+ for (const [relativePath, repoPatches] of [...byRepo.entries()].sort(([left], [right]) =>
268
+ left.localeCompare(right),
269
+ )) {
206
270
  const nestedDir = path.join(repoRoot, relativePath);
207
271
  try {
208
272
  await fs.access(path.join(nestedDir, ".git"));
209
273
  } catch {
210
- continue;
274
+ throw new Error(`Nested repository is unavailable: ${relativePath}`);
211
275
  }
212
-
213
- const combinedDiff = repoPatches.map(p => p.patch).join("\n");
214
- for (const { patch } of repoPatches) {
215
- await git.patch.applyText(nestedDir, patch);
276
+ const combinedPatch = git.patch.join(repoPatches.map(entry => entry.patch));
277
+ if (!(await git.patch.canApplyText(nestedDir, combinedPatch))) {
278
+ throw new Error(`Nested repository patch does not apply cleanly: ${relativePath}`);
216
279
  }
280
+ prepared.push({ relativePath, nestedDir, combinedPatch });
281
+ }
217
282
 
218
- // Commit so nested repo history reflects the task changes
219
- if ((await git.status(nestedDir)).trim().length > 0) {
220
- const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
221
- await git.stage.files(nestedDir);
222
- await git.commit(nestedDir, msg);
283
+ const applied: typeof prepared = [];
284
+ try {
285
+ for (const entry of prepared) {
286
+ await git.patch.applyText(entry.nestedDir, entry.combinedPatch);
287
+ applied.push(entry);
288
+ }
289
+ } catch (error) {
290
+ const rollbackFailures: string[] = [];
291
+ for (const entry of applied.reverse()) {
292
+ try {
293
+ await git.patch.applyText(entry.nestedDir, entry.combinedPatch, { reverse: true });
294
+ } catch {
295
+ rollbackFailures.push(entry.relativePath);
296
+ }
223
297
  }
298
+ const message = error instanceof Error ? error.message : String(error);
299
+ const rollback = rollbackFailures.length
300
+ ? `; rollback failed for: ${rollbackFailures.join(", ")}`
301
+ : "; earlier nested patches were rolled back";
302
+ throw new Error(`Nested repository patch application failed: ${message}${rollback}`);
224
303
  }
225
304
  }
226
305