pi-crew 0.2.2 → 0.2.4

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 (354) hide show
  1. package/AGENTS.md +57 -32
  2. package/CHANGELOG.md +466 -413
  3. package/LICENSE +21 -21
  4. package/NOTICE.md +16 -16
  5. package/README.md +323 -323
  6. package/docs/FEATURE_INTAKE.md +126 -0
  7. package/docs/HARNESS.md +86 -0
  8. package/docs/HARNESS_BACKLOG.md +41 -0
  9. package/docs/TEST_MATRIX.md +49 -0
  10. package/docs/actions-reference.md +595 -595
  11. package/docs/architecture.md +180 -180
  12. package/docs/code-review-2026-05-11.md +592 -0
  13. package/docs/commands-reference.md +347 -347
  14. package/docs/comparison-pi-subagents-vs-pi-crew.md +303 -0
  15. package/docs/decisions/0001-durable-state.md +41 -0
  16. package/docs/decisions/0002-child-process-for-async.md +42 -0
  17. package/docs/decisions/0003-depth-guard.md +36 -0
  18. package/docs/decisions/0004-execfile-over-exec.md +34 -0
  19. package/docs/decisions/0005-no-parameter-properties.md +49 -0
  20. package/docs/decisions/0006-publish-bundled-esm.md +63 -0
  21. package/docs/decisions/0007-active-run-binary-index.md +54 -0
  22. package/docs/decisions/0008-child-pi-warm-pool.md +61 -0
  23. package/docs/decisions/README.md +23 -0
  24. package/docs/followup-plan-2026-05-12.md +463 -0
  25. package/docs/followup-review-2026-05-12.md +297 -0
  26. package/docs/followup-review-round3-2026-05-12.md +342 -0
  27. package/docs/followup-review-round4-2026-05-13.md +107 -0
  28. package/docs/implementation-plan-top3.md +333 -0
  29. package/docs/live-mailbox-runtime.md +36 -36
  30. package/docs/next-upgrade-roadmap.md +808 -808
  31. package/docs/oh-my-pi-research.md +509 -0
  32. package/docs/perf/baseline-2026-05.md +113 -0
  33. package/docs/perf/final-report-2026-05.md +206 -0
  34. package/docs/perf/sprint-1-report.md +71 -0
  35. package/docs/perf/sprint-2-report.md +81 -0
  36. package/docs/perf/sprint-2.5-report.md +53 -0
  37. package/docs/perf/sprint-3-report.md +36 -0
  38. package/docs/perf/sprint-4-report.md +47 -0
  39. package/docs/perf/sprint-5-report.md +51 -0
  40. package/docs/perf/sprint-6-report.md +94 -0
  41. package/docs/perf/sprint-7-report.md +74 -0
  42. package/docs/perf/upgrade-plan-2026-05.md +147 -0
  43. package/docs/pi-subagents3-deep-analysis.md +508 -0
  44. package/docs/product/README.md +31 -0
  45. package/docs/product/platform.md +27 -0
  46. package/docs/product/runtime-safety.md +37 -0
  47. package/docs/product/team-run.md +39 -0
  48. package/docs/product/team-tool.md +37 -0
  49. package/docs/publishing.md +65 -65
  50. package/docs/resource-formats.md +134 -134
  51. package/docs/runtime-analysis-child-vs-live.md +171 -0
  52. package/docs/runtime-flow.md +148 -148
  53. package/docs/runtime-migration-in-process-analysis.md +250 -0
  54. package/docs/stories/README.md +30 -0
  55. package/docs/stories/backlog.md +36 -0
  56. package/docs/templates/decision.md +27 -0
  57. package/docs/templates/story.md +44 -0
  58. package/docs/templates/validation-report.md +32 -0
  59. package/docs/usage.md +238 -238
  60. package/index.ts +7 -6
  61. package/install.mjs +65 -65
  62. package/package.json +107 -99
  63. package/schema.json +222 -222
  64. package/skills/child-pi-spawning/SKILL.md +213 -0
  65. package/skills/context-artifact-hygiene/SKILL.md +32 -0
  66. package/skills/event-log-tracing/SKILL.md +299 -0
  67. package/skills/git-master/SKILL.md +225 -24
  68. package/skills/live-agent-lifecycle/SKILL.md +192 -0
  69. package/skills/mailbox-interactive/SKILL.md +300 -19
  70. package/skills/model-routing-context/SKILL.md +94 -0
  71. package/skills/multi-perspective-review/SKILL.md +88 -0
  72. package/skills/read-only-explorer/SKILL.md +250 -26
  73. package/skills/safe-bash/SKILL.md +307 -21
  74. package/skills/verification-before-done/SKILL.md +11 -2
  75. package/skills/widget-rendering/SKILL.md +258 -0
  76. package/skills/workspace-isolation/SKILL.md +202 -0
  77. package/skills/worktree-isolation/SKILL.md +202 -18
  78. package/src/adapters/claude-adapter.ts +25 -25
  79. package/src/adapters/codex-adapter.ts +21 -21
  80. package/src/adapters/cursor-adapter.ts +17 -17
  81. package/src/adapters/export-util.ts +137 -137
  82. package/src/adapters/index.ts +15 -15
  83. package/src/adapters/registry.ts +18 -18
  84. package/src/adapters/types.ts +23 -23
  85. package/src/agents/agent-config.ts +38 -38
  86. package/src/agents/agent-serializer.ts +38 -38
  87. package/src/agents/discover-agents.ts +121 -118
  88. package/src/config/config.ts +740 -858
  89. package/src/config/defaults.ts +96 -96
  90. package/src/config/drift-detector.ts +211 -211
  91. package/src/config/markers.ts +327 -327
  92. package/src/config/resilient-parser.ts +109 -108
  93. package/src/config/suggestions.ts +74 -74
  94. package/src/config/types.ts +199 -0
  95. package/src/extension/async-notifier.ts +123 -89
  96. package/src/extension/autonomous-policy.ts +169 -169
  97. package/src/extension/cross-extension-rpc.ts +104 -103
  98. package/src/extension/help.ts +47 -47
  99. package/src/extension/import-index.ts +69 -69
  100. package/src/extension/management.ts +395 -382
  101. package/src/extension/notification-router.ts +116 -116
  102. package/src/extension/notification-sink.ts +51 -51
  103. package/src/extension/project-init.ts +168 -168
  104. package/src/extension/register.ts +859 -668
  105. package/src/extension/registration/artifact-cleanup.ts +15 -15
  106. package/src/extension/registration/command-utils.ts +54 -54
  107. package/src/extension/registration/commands.ts +559 -452
  108. package/src/extension/registration/compaction-guard.ts +125 -125
  109. package/src/extension/registration/subagent-helpers.ts +102 -102
  110. package/src/extension/registration/subagent-tools.ts +220 -158
  111. package/src/extension/registration/team-tool.ts +159 -98
  112. package/src/extension/registration/viewers.ts +29 -0
  113. package/src/extension/result-watcher.ts +128 -128
  114. package/src/extension/run-bundle-schema.ts +89 -89
  115. package/src/extension/run-export.ts +73 -73
  116. package/src/extension/run-import.ts +84 -84
  117. package/src/extension/run-index.ts +94 -94
  118. package/src/extension/run-maintenance.ts +142 -142
  119. package/src/extension/session-summary.ts +8 -8
  120. package/src/extension/team-manager-command.ts +96 -95
  121. package/src/extension/team-recommendation.ts +188 -188
  122. package/src/extension/team-tool/api.ts +5 -2
  123. package/src/extension/team-tool/cancel.ts +224 -209
  124. package/src/extension/team-tool/config-patch.ts +36 -36
  125. package/src/extension/team-tool/context.ts +60 -60
  126. package/src/extension/team-tool/doctor.ts +242 -242
  127. package/src/extension/team-tool/handle-settings.ts +421 -195
  128. package/src/extension/team-tool/inspect.ts +41 -41
  129. package/src/extension/team-tool/lifecycle-actions.ts +139 -139
  130. package/src/extension/team-tool/parallel-dispatch.ts +156 -156
  131. package/src/extension/team-tool/plan.ts +19 -19
  132. package/src/extension/team-tool/respond.ts +112 -111
  133. package/src/extension/team-tool/run.ts +246 -228
  134. package/src/extension/team-tool/status.ts +110 -110
  135. package/src/extension/team-tool-types.ts +13 -13
  136. package/src/extension/team-tool.ts +16 -4
  137. package/src/extension/tool-result.ts +16 -16
  138. package/src/extension/validate-resources.ts +77 -77
  139. package/src/hooks/registry.ts +61 -61
  140. package/src/hooks/types.ts +40 -40
  141. package/src/i18n.ts +184 -184
  142. package/src/observability/correlation.ts +35 -35
  143. package/src/observability/event-to-metric.ts +68 -68
  144. package/src/observability/exporters/adapter.ts +30 -30
  145. package/src/observability/exporters/otlp-exporter.ts +106 -92
  146. package/src/observability/exporters/prometheus-exporter.ts +54 -54
  147. package/src/observability/metric-registry.ts +87 -87
  148. package/src/observability/metric-retention.ts +54 -54
  149. package/src/observability/metric-sink.ts +81 -56
  150. package/src/observability/metrics-primitives.ts +167 -167
  151. package/src/prompt/prompt-runtime.ts +72 -72
  152. package/src/runtime/adaptive-plan.ts +338 -0
  153. package/src/runtime/agent-control.ts +169 -169
  154. package/src/runtime/agent-memory.ts +72 -72
  155. package/src/runtime/agent-observability.ts +114 -114
  156. package/src/runtime/async-marker.ts +26 -26
  157. package/src/runtime/async-runner.ts +153 -79
  158. package/src/runtime/attention-events.ts +28 -28
  159. package/src/runtime/auto-resume.ts +100 -100
  160. package/src/runtime/background-runner.ts +122 -88
  161. package/src/runtime/cancellation.ts +61 -61
  162. package/src/runtime/capability-inventory.ts +116 -116
  163. package/src/runtime/child-pi-pool.ts +68 -0
  164. package/src/runtime/child-pi.ts +541 -463
  165. package/src/runtime/code-summary.ts +247 -247
  166. package/src/runtime/compaction-summary.ts +271 -271
  167. package/src/runtime/concurrency.ts +58 -58
  168. package/src/runtime/crash-recovery.ts +317 -301
  169. package/src/runtime/crew-agent-records.ts +379 -281
  170. package/src/runtime/crew-agent-runtime.ts +60 -60
  171. package/src/runtime/cross-extension-rpc.ts +72 -0
  172. package/src/runtime/custom-tools/irc-tool.ts +201 -201
  173. package/src/runtime/custom-tools/submit-result-tool.ts +90 -90
  174. package/src/runtime/deadletter.ts +47 -47
  175. package/src/runtime/delivery-coordinator.ts +176 -176
  176. package/src/runtime/delta-conflict.ts +360 -360
  177. package/src/runtime/diagnostic-export.ts +102 -102
  178. package/src/runtime/direct-run.ts +35 -35
  179. package/src/runtime/effectiveness.ts +82 -81
  180. package/src/runtime/errors/crew-errors.ts +166 -0
  181. package/src/runtime/event-stream-bridge.ts +92 -92
  182. package/src/runtime/foreground-control.ts +82 -82
  183. package/src/runtime/green-contract.ts +46 -46
  184. package/src/runtime/group-join.ts +234 -106
  185. package/src/runtime/heartbeat-watcher.ts +145 -124
  186. package/src/runtime/iteration-hooks.ts +267 -264
  187. package/src/runtime/live-agent-control.ts +88 -88
  188. package/src/runtime/live-agent-manager.ts +377 -179
  189. package/src/runtime/live-control-realtime.ts +36 -36
  190. package/src/runtime/live-session-runtime.ts +676 -599
  191. package/src/runtime/loop-gates.ts +129 -129
  192. package/src/runtime/manifest-cache.ts +263 -263
  193. package/src/runtime/mcp-proxy.ts +113 -113
  194. package/src/runtime/metric-parser.ts +40 -40
  195. package/src/runtime/model-fallback.ts +282 -274
  196. package/src/runtime/model-resolver.ts +118 -0
  197. package/src/runtime/output-validator.ts +187 -187
  198. package/src/runtime/overflow-recovery.ts +175 -175
  199. package/src/runtime/parallel-research.ts +44 -44
  200. package/src/runtime/parallel-utils.ts +156 -156
  201. package/src/runtime/parent-guard.ts +80 -80
  202. package/src/runtime/phase-progress.ts +217 -217
  203. package/src/runtime/pi-args.ts +165 -165
  204. package/src/runtime/pi-json-output.ts +111 -111
  205. package/src/runtime/pi-spawn.ts +167 -167
  206. package/src/runtime/policy-engine.ts +79 -79
  207. package/src/runtime/post-checks.ts +125 -122
  208. package/src/runtime/post-exit-stdio-guard.ts +86 -86
  209. package/src/runtime/process-status.ts +97 -73
  210. package/src/runtime/progress-event-coalescer.ts +43 -43
  211. package/src/runtime/recovery-recipes.ts +74 -74
  212. package/src/runtime/retry-executor.ts +81 -81
  213. package/src/runtime/role-permission.ts +39 -39
  214. package/src/runtime/run-tracker.ts +99 -0
  215. package/src/runtime/runtime-policy.ts +21 -0
  216. package/src/runtime/runtime-resolver.ts +94 -90
  217. package/src/runtime/scheduler.ts +294 -0
  218. package/src/runtime/semaphore.ts +131 -131
  219. package/src/runtime/sensitive-paths.ts +92 -92
  220. package/src/runtime/session-usage.ts +79 -79
  221. package/src/runtime/settings-store.ts +103 -0
  222. package/src/runtime/sidechain-output.ts +29 -29
  223. package/src/runtime/skill-instructions.ts +222 -222
  224. package/src/runtime/stale-reconciler.ts +198 -189
  225. package/src/runtime/streaming-output.ts +47 -0
  226. package/src/runtime/subagent-manager.ts +404 -395
  227. package/src/runtime/subprocess-tool-registry.ts +67 -67
  228. package/src/runtime/task-display.ts +38 -38
  229. package/src/runtime/task-graph-scheduler.ts +122 -122
  230. package/src/runtime/task-graph.ts +207 -207
  231. package/src/runtime/task-output-context.ts +177 -177
  232. package/src/runtime/task-packet.ts +93 -93
  233. package/src/runtime/task-quality.ts +207 -207
  234. package/src/runtime/task-runner/capabilities.ts +78 -78
  235. package/src/runtime/task-runner/live-executor.ts +131 -113
  236. package/src/runtime/task-runner/progress.ts +119 -119
  237. package/src/runtime/task-runner/prompt-builder.ts +139 -139
  238. package/src/runtime/task-runner/prompt-pipeline.ts +64 -64
  239. package/src/runtime/task-runner/result-utils.ts +14 -14
  240. package/src/runtime/task-runner/run-projection.ts +103 -103
  241. package/src/runtime/task-runner/state-helpers.ts +22 -22
  242. package/src/runtime/task-runner.ts +469 -458
  243. package/src/runtime/team-runner.ts +693 -945
  244. package/src/runtime/usage-tracker.ts +71 -0
  245. package/src/runtime/worker-heartbeat.ts +21 -21
  246. package/src/runtime/worker-startup.ts +57 -57
  247. package/src/runtime/workflow-state.ts +187 -187
  248. package/src/runtime/yield-handler.ts +190 -189
  249. package/src/schema/config-schema.ts +172 -168
  250. package/src/schema/team-tool-schema.ts +126 -125
  251. package/src/schema/validation-types.ts +151 -148
  252. package/src/skills/discover-skills.ts +67 -67
  253. package/src/skills/skill-templates.ts +374 -374
  254. package/src/state/active-run-registry.ts +227 -191
  255. package/src/state/artifact-store.ts +130 -129
  256. package/src/state/atomic-write.ts +262 -178
  257. package/src/state/blob-store.ts +116 -116
  258. package/src/state/contracts.ts +111 -111
  259. package/src/state/event-log-rotation.ts +161 -158
  260. package/src/state/event-log.ts +383 -240
  261. package/src/state/event-reconstructor.ts +217 -217
  262. package/src/state/jsonl-writer.ts +82 -82
  263. package/src/state/locks.ts +146 -148
  264. package/src/state/mailbox.ts +446 -405
  265. package/src/state/state-store.ts +364 -351
  266. package/src/state/task-claims.ts +44 -44
  267. package/src/state/types.ts +285 -285
  268. package/src/state/usage.ts +29 -29
  269. package/src/subagents/async-entry.ts +1 -1
  270. package/src/subagents/index.ts +3 -3
  271. package/src/subagents/live/control.ts +1 -1
  272. package/src/subagents/live/manager.ts +1 -1
  273. package/src/subagents/live/realtime.ts +1 -1
  274. package/src/subagents/live/session-runtime.ts +1 -1
  275. package/src/subagents/manager.ts +1 -1
  276. package/src/subagents/spawn.ts +1 -1
  277. package/src/teams/discover-teams.ts +116 -116
  278. package/src/teams/team-config.ts +27 -27
  279. package/src/teams/team-serializer.ts +38 -38
  280. package/src/types/diff.d.ts +18 -18
  281. package/src/ui/agent-management-overlay.ts +144 -144
  282. package/src/ui/crew-widget.ts +487 -370
  283. package/src/ui/dashboard-panes/agents-pane.ts +109 -28
  284. package/src/ui/dashboard-panes/cancellation-pane.ts +42 -42
  285. package/src/ui/dashboard-panes/capability-pane.ts +59 -59
  286. package/src/ui/dashboard-panes/health-pane.ts +30 -30
  287. package/src/ui/dashboard-panes/mailbox-pane.ts +35 -35
  288. package/src/ui/dashboard-panes/progress-pane.ts +30 -30
  289. package/src/ui/dashboard-panes/transcript-pane.ts +10 -10
  290. package/src/ui/heartbeat-aggregator.ts +63 -63
  291. package/src/ui/keybinding-map.ts +97 -94
  292. package/src/ui/live-conversation-overlay.ts +152 -0
  293. package/src/ui/live-run-sidebar.ts +180 -180
  294. package/src/ui/mascot.ts +442 -442
  295. package/src/ui/overlays/agent-picker-overlay.ts +57 -57
  296. package/src/ui/overlays/confirm-overlay.ts +58 -58
  297. package/src/ui/overlays/mailbox-compose-overlay.ts +144 -144
  298. package/src/ui/overlays/mailbox-compose-preview.ts +63 -63
  299. package/src/ui/overlays/mailbox-detail-overlay.ts +122 -122
  300. package/src/ui/pi-ui-compat.ts +57 -57
  301. package/src/ui/powerbar-publisher.ts +221 -197
  302. package/src/ui/render-scheduler.ts +216 -143
  303. package/src/ui/run-action-dispatcher.ts +118 -117
  304. package/src/ui/run-dashboard.ts +526 -464
  305. package/src/ui/run-event-bus.ts +208 -208
  306. package/src/ui/run-snapshot-cache.ts +826 -777
  307. package/src/ui/settings-overlay.ts +721 -0
  308. package/src/ui/snapshot-types.ts +86 -70
  309. package/src/ui/theme-adapter.ts +190 -190
  310. package/src/ui/tool-progress-formatter.ts +89 -0
  311. package/src/ui/transcript-cache.ts +94 -94
  312. package/src/ui/transcript-viewer.ts +335 -335
  313. package/src/utils/conflict-detect.ts +662 -0
  314. package/src/utils/env-filter.ts +30 -0
  315. package/src/utils/file-coalescer.ts +86 -86
  316. package/src/utils/frontmatter.ts +68 -68
  317. package/src/utils/fs-watch.ts +88 -31
  318. package/src/utils/gh-protocol.ts +479 -0
  319. package/src/utils/ids.ts +17 -17
  320. package/src/utils/incremental-reader.ts +104 -104
  321. package/src/utils/internal-error.ts +6 -6
  322. package/src/utils/names.ts +27 -27
  323. package/src/utils/paths.ts +102 -63
  324. package/src/utils/redaction.ts +44 -44
  325. package/src/utils/resolve-shell.ts +34 -0
  326. package/src/utils/safe-paths.ts +47 -47
  327. package/src/utils/scan-cache.ts +136 -136
  328. package/src/utils/sleep.ts +2 -1
  329. package/src/utils/sse-parser.ts +134 -134
  330. package/src/utils/task-name-generator.ts +337 -337
  331. package/src/utils/timings.ts +33 -33
  332. package/src/utils/visual.ts +243 -198
  333. package/src/workflows/discover-workflows.ts +139 -139
  334. package/src/workflows/validate-workflow.ts +40 -40
  335. package/src/workflows/workflow-config.ts +26 -26
  336. package/src/workflows/workflow-serializer.ts +32 -32
  337. package/src/worktree/branch-freshness.ts +45 -45
  338. package/src/worktree/cleanup.ts +75 -72
  339. package/src/worktree/worktree-manager.ts +188 -146
  340. package/teams/default.team.md +12 -12
  341. package/teams/fast-fix.team.md +11 -11
  342. package/teams/implementation.team.md +18 -18
  343. package/teams/parallel-research.team.md +14 -14
  344. package/teams/research.team.md +11 -11
  345. package/teams/review.team.md +12 -12
  346. package/tsconfig.json +19 -19
  347. package/workflows/default.workflow.md +30 -30
  348. package/workflows/fast-fix.workflow.md +23 -23
  349. package/workflows/implementation.workflow.md +43 -43
  350. package/workflows/parallel-research.workflow.md +46 -46
  351. package/workflows/research.workflow.md +22 -22
  352. package/workflows/review.workflow.md +30 -30
  353. package/skills/task-packet/SKILL.md +0 -28
  354. package/skills/verify-evidence/SKILL.md +0 -27
@@ -0,0 +1,662 @@
1
+ /**
2
+ * conflict-detect.ts — Detect and resolve git merge conflicts.
3
+ *
4
+ * Forked from oh-my-pi packages/coding-agent/src/tools/conflict-detect.ts
5
+ * with adaptations for pi-crew's needs.
6
+ *
7
+ * Workflow:
8
+ * 1. `read` collects lines from disk as usual.
9
+ * 2. `scanConflictLines` inspects those lines (no extra I/O) for
10
+ * well-formed `<<<<<<<` / `=======` / `>>>>>>>` blocks.
11
+ * 3. Each completed block is registered with the `ConflictHistory`,
12
+ * which assigns it a stable id.
13
+ * 4. The read output is returned verbatim with a short footer naming
14
+ * every conflict id surfaced, and the agent calls
15
+ * `write({ path: "conflict://<id>", content })` to splice the
16
+ * recorded region with the chosen content.
17
+ *
18
+ * Marker shape is strict: only column-0 markers of the exact prefix length
19
+ * followed by either EOL or a single space + label. Lines that
20
+ * merely start with `<` or `=` never match.
21
+ */
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+
25
+ const OURS_PREFIX = "<<<<<<<";
26
+ const BASE_PREFIX = "|||||||";
27
+ const SEPARATOR = "=======";
28
+ const THEIRS_PREFIX = ">>>>>>>";
29
+
30
+ export interface ConflictBlock {
31
+ /** 1-indexed line of the `<<<<<<<` marker. */
32
+ startLine: number;
33
+ /** 1-indexed line of the `=======` separator. */
34
+ separatorLine: number;
35
+ /** 1-indexed line of `>>>>>>>` marker. */
36
+ endLine: number;
37
+ /** 1-indexed line of `|||||||` base marker (diff3 only). */
38
+ baseLine?: number;
39
+ oursLabel?: string;
40
+ baseLabel?: string;
41
+ theirsLabel?: string;
42
+ oursLines: string[];
43
+ baseLines?: string[];
44
+ theirsLines: string[];
45
+ }
46
+
47
+ /**
48
+ * Scan an already-collected array of file lines for completed conflict
49
+ * blocks. `firstLineNumber` is the 1-indexed line number of `lines[0]`
50
+ * (so a windowed read starting at line 200 passes `firstLineNumber: 200`).
51
+ *
52
+ * Only fully-closed blocks (opener + separator + closer all present in
53
+ * the window) are returned. A block whose closer is past the window's
54
+ * tail is dropped — the agent will see the open marker and can widen
55
+ * the read.
56
+ */
57
+ export function scanConflictLines(lines: readonly string[], firstLineNumber: number): ConflictBlock[] {
58
+ const blocks: ConflictBlock[] = [];
59
+ let phase: "idle" | "ours" | "base" | "theirs" = "idle";
60
+ let partial: {
61
+ startLine: number;
62
+ oursLabel?: string;
63
+ oursLines: string[];
64
+ baseLine?: number;
65
+ baseLabel?: string;
66
+ baseLines?: string[];
67
+ separatorLine?: number;
68
+ theirsLines?: string[];
69
+ } | null = null;
70
+
71
+ for (let i = 0; i < lines.length; i++) {
72
+ const line = lines[i];
73
+ const ln = firstLineNumber + i;
74
+
75
+ const oursLabel = matchMarker(line, OURS_PREFIX);
76
+ if (oursLabel !== null) {
77
+ partial = { startLine: ln, oursLabel: oursLabel || undefined, oursLines: [] };
78
+ phase = "ours";
79
+ continue;
80
+ }
81
+
82
+ if (phase === "idle" || partial === null) continue;
83
+
84
+ const baseLabel = matchMarker(line, BASE_PREFIX);
85
+ if (baseLabel !== null) {
86
+ if (phase !== "ours") {
87
+ partial = null;
88
+ phase = "idle";
89
+ continue;
90
+ }
91
+ partial.baseLine = ln;
92
+ partial.baseLabel = baseLabel || undefined;
93
+ partial.baseLines = [];
94
+ phase = "base";
95
+ continue;
96
+ }
97
+
98
+ if (line === SEPARATOR) {
99
+ if (phase === "ours" || phase === "base") {
100
+ partial.separatorLine = ln;
101
+ partial.theirsLines = [];
102
+ phase = "theirs";
103
+ } else if (phase === "theirs" && partial?.theirsLines) {
104
+ // ======= inside theirs content — treat as content, not marker
105
+ partial.theirsLines.push(line);
106
+ } else {
107
+ partial = null;
108
+ phase = "idle";
109
+ }
110
+ continue;
111
+ }
112
+
113
+ const theirsLabel = matchMarker(line, THEIRS_PREFIX);
114
+ if (theirsLabel !== null) {
115
+ if (phase === "theirs" && partial.separatorLine !== undefined && partial.theirsLines) {
116
+ blocks.push({
117
+ startLine: partial.startLine,
118
+ separatorLine: partial.separatorLine,
119
+ endLine: ln,
120
+ baseLine: partial.baseLine,
121
+ oursLabel: partial.oursLabel,
122
+ baseLabel: partial.baseLabel,
123
+ theirsLabel: theirsLabel || undefined,
124
+ oursLines: partial.oursLines,
125
+ baseLines: partial.baseLines,
126
+ theirsLines: partial.theirsLines,
127
+ });
128
+ }
129
+ partial = null;
130
+ phase = "idle";
131
+ continue;
132
+ }
133
+
134
+ if (phase === "ours") partial.oursLines.push(line);
135
+ else if (phase === "base" && partial.baseLines) partial.baseLines.push(line);
136
+ else if (phase === "theirs" && partial.theirsLines) partial.theirsLines.push(line);
137
+ }
138
+
139
+ return blocks;
140
+ }
141
+
142
+ const SCAN_FILE_DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
143
+
144
+ /**
145
+ * Scan a whole file for unresolved conflict blocks.
146
+ *
147
+ * Reads at most `maxBytes` (default 10 MB) so this stays cheap on
148
+ * pathological files. Files truncated by the cap report
149
+ * `scanTruncated: true`; only complete blocks within the scanned prefix
150
+ * are returned, so trailing partial markers never invent fake blocks.
151
+ */
152
+ export async function scanFileForConflicts(
153
+ absolutePath: string,
154
+ options: { maxBytes?: number } = {},
155
+ ): Promise<{ blocks: ConflictBlock[]; scanTruncated: boolean }> {
156
+ return scanFileForConflictsSync(absolutePath, options);
157
+ }
158
+
159
+ /**
160
+ * Synchronous version of scanFileForConflicts.
161
+ */
162
+ export function scanFileForConflictsSync(
163
+ absolutePath: string,
164
+ options: { maxBytes?: number } = {},
165
+ ): { blocks: ConflictBlock[]; scanTruncated: boolean } {
166
+ const maxBytes = options.maxBytes ?? SCAN_FILE_DEFAULT_MAX_BYTES;
167
+ let text: string;
168
+ let scanTruncated = false;
169
+ try {
170
+ const stat = fs.statSync(absolutePath);
171
+ if (stat.size > maxBytes) {
172
+ scanTruncated = true;
173
+ const fd = fs.openSync(absolutePath, "r");
174
+ try {
175
+ const buf = Buffer.alloc(maxBytes);
176
+ fs.readSync(fd, buf, 0, maxBytes, 0);
177
+ text = new TextDecoder("utf-8", { fatal: false }).decode(buf);
178
+ } finally {
179
+ fs.closeSync(fd);
180
+ }
181
+ } else {
182
+ text = fs.readFileSync(absolutePath, "utf-8");
183
+ }
184
+ } catch {
185
+ return { blocks: [], scanTruncated: false };
186
+ }
187
+ const lines = text.split("\n");
188
+ return { blocks: scanConflictLines(lines, 1), scanTruncated };
189
+ }
190
+
191
+ /**
192
+ * Return the label after a marker prefix when the line is a valid
193
+ * column-0 marker, or `null` when it isn't. Strict shape: prefix alone,
194
+ * or prefix + single space + label.
195
+ */
196
+ function matchMarker(line: string, prefix: string): string | null {
197
+ if (!line.startsWith(prefix)) return null;
198
+ if (line.length === prefix.length) return "";
199
+ if (line.charCodeAt(prefix.length) !== 32 /* space */) return null;
200
+ return line.slice(prefix.length + 1);
201
+ }
202
+
203
+ /**
204
+ * Recorded conflict block keyed by a session-stable id. The history is
205
+ * append-only; ids stay valid even after later writes resolve other
206
+ * blocks in the same file, so retries don't depend on re-reading.
207
+ */
208
+ export interface ConflictEntry extends ConflictBlock {
209
+ id: number;
210
+ absolutePath: string;
211
+ displayPath: string;
212
+ }
213
+
214
+ /** Per-session log of conflict regions surfaced by `read`. */
215
+ export class ConflictHistory {
216
+ #nextId = 1;
217
+ #entries = new Map<number, ConflictEntry>();
218
+
219
+ /**
220
+ * Register a conflict block. Returns the (possibly pre-existing) entry
221
+ * — if the same `absolutePath`+`startLine` was registered before, the
222
+ * earlier id is reused so a re-read does not inflate the counter or
223
+ * orphan the prior id. The recorded region is overwritten on re-read
224
+ * so the splice always reflects the current marker positions on disk.
225
+ */
226
+ register(input: Omit<ConflictEntry, "id">): ConflictEntry {
227
+ for (const existing of this.#entries.values()) {
228
+ if (existing.absolutePath === input.absolutePath && existing.startLine === input.startLine) {
229
+ const merged: ConflictEntry = { ...input, id: existing.id };
230
+ this.#entries.set(existing.id, merged);
231
+ return merged;
232
+ }
233
+ }
234
+ const id = this.#nextId++;
235
+ const entry: ConflictEntry = { ...input, id };
236
+ this.#entries.set(id, entry);
237
+ return entry;
238
+ }
239
+
240
+ get(id: number): ConflictEntry | undefined {
241
+ return this.#entries.get(id);
242
+ }
243
+
244
+ /** Snapshot every registered entry in insertion (id) order. */
245
+ entries(): ConflictEntry[] {
246
+ return [...this.#entries.values()];
247
+ }
248
+
249
+ /** Drop a single entry by id. Used after a successful resolve. */
250
+ invalidate(id: number): void {
251
+ this.#entries.delete(id);
252
+ }
253
+
254
+ /** Drop every entry referencing `absolutePath`. Used after a successful resolve. */
255
+ invalidatePath(absolutePath: string): void {
256
+ for (const [id, entry] of this.#entries) {
257
+ if (entry.absolutePath === absolutePath) {
258
+ this.#entries.delete(id);
259
+ }
260
+ }
261
+ }
262
+
263
+ /** Number of registered conflicts. */
264
+ get size(): number {
265
+ return this.#entries.size;
266
+ }
267
+ }
268
+
269
+ /** A side of a conflict block that the `read` tool can render via `conflict://N/<scope>`. */
270
+ export type ConflictScope = "ours" | "theirs" | "base";
271
+
272
+ const CONFLICT_SCOPES = new Set<ConflictScope>(["ours", "theirs", "base"]);
273
+
274
+ /** Parsed `conflict://<N>` / `conflict://<N>/<scope>` / `conflict://*` URI. */
275
+ export interface ParsedConflictUri {
276
+ /** `"*"` selects every currently-registered conflict (bulk write only). */
277
+ id: number | "*";
278
+ scope?: ConflictScope;
279
+ }
280
+
281
+ const CONFLICT_URI_RE = /^conflict:\/\/(.+)$/;
282
+
283
+ /**
284
+ * Parse a `conflict://<N>`, `conflict://<N>/<scope>`, or `conflict://*` URI.
285
+ *
286
+ * Returns `null` for non-conflict paths; throws for a well-formed scheme
287
+ * with an invalid id or scope.
288
+ *
289
+ * `*` is the bulk-write wildcard — only valid as `conflict://*` (no
290
+ * scope segment).
291
+ */
292
+ export function parseConflictUri(raw: string): ParsedConflictUri | null {
293
+ const match = raw.match(CONFLICT_URI_RE);
294
+ if (!match) return null;
295
+ const tail = match[1];
296
+ const slashIdx = tail.indexOf("/");
297
+ const idPart = slashIdx === -1 ? tail : tail.slice(0, slashIdx);
298
+ const scopePart = slashIdx === -1 ? undefined : tail.slice(slashIdx + 1);
299
+
300
+ if (idPart === "*") {
301
+ if (scopePart !== undefined) {
302
+ throw new Error(
303
+ `Invalid conflict URI '${raw}': wildcard 'conflict://*' does not accept a scope segment. Drop '/${scopePart}' or use a numeric id.`,
304
+ );
305
+ }
306
+ return { id: "*" };
307
+ }
308
+
309
+ if (!/^\d+$/.test(idPart)) {
310
+ throw new Error(
311
+ `Invalid conflict URI '${raw}': must be 'conflict://<N>', 'conflict://<N>/<scope>', or 'conflict://*' where N is a positive integer surfaced by a prior read.`,
312
+ );
313
+ }
314
+ const id = Number.parseInt(idPart, 10);
315
+ if (!Number.isFinite(id) || id < 1) {
316
+ throw new Error(`Invalid conflict URI '${raw}': id must be ≥ 1.`);
317
+ }
318
+
319
+ let scope: ConflictScope | undefined;
320
+ if (scopePart !== undefined) {
321
+ if (!CONFLICT_SCOPES.has(scopePart as ConflictScope)) {
322
+ throw new Error(
323
+ `Invalid conflict URI '${raw}': scope must be one of 'ours', 'theirs', 'base', or omitted.`,
324
+ );
325
+ }
326
+ scope = scopePart as ConflictScope;
327
+ }
328
+
329
+ return { id, scope };
330
+ }
331
+
332
+ /**
333
+ * Splice the conflict region recorded in `entry` out of `originalText`
334
+ * and replace it with `replacement` (markers and all sides included).
335
+ *
336
+ * Works by locating the recorded marker block by content (anchored to
337
+ * `entry.startLine` as the preferred match), so out-of-band edits earlier
338
+ * in the file that shift line numbers don't break resolution.
339
+ */
340
+ export function spliceConflict(originalText: string, entry: ConflictEntry, replacement: string): string {
341
+ const lines = originalText.split("\n");
342
+ const expected = buildRecordedRegion(entry);
343
+ const match = locateRegion(lines, expected, entry.startLine - 1);
344
+ if (!match) {
345
+ throw new Error(
346
+ `Conflict #${entry.id} no longer present in '${entry.displayPath}': the recorded marker block can't be located. The file changed since the conflict was registered — re-read it to re-register conflicts.`,
347
+ );
348
+ }
349
+
350
+ const trimmed = normalizeTrailingNewline(replacement);
351
+ const replacementLines = trimmed.split("\n");
352
+ const next = [...lines.slice(0, match.startIdx), ...replacementLines, ...lines.slice(match.endIdx + 1)];
353
+ return next.join("\n");
354
+ }
355
+
356
+ /** Reconstruct the recorded marker block as it should appear in the file. */
357
+ function buildRecordedRegion(entry: ConflictEntry): string[] {
358
+ const out: string[] = [];
359
+ out.push(entry.oursLabel ? `${OURS_PREFIX} ${entry.oursLabel}` : OURS_PREFIX);
360
+ out.push(...entry.oursLines);
361
+ if (entry.baseLines !== undefined) {
362
+ out.push(entry.baseLabel ? `${BASE_PREFIX} ${entry.baseLabel}` : BASE_PREFIX);
363
+ out.push(...entry.baseLines);
364
+ }
365
+ out.push(SEPARATOR);
366
+ out.push(...entry.theirsLines);
367
+ out.push(entry.theirsLabel ? `${THEIRS_PREFIX} ${entry.theirsLabel}` : THEIRS_PREFIX);
368
+ return out;
369
+ }
370
+
371
+ function locateRegion(
372
+ lines: readonly string[],
373
+ expected: readonly string[],
374
+ preferredIdx: number,
375
+ ): { startIdx: number; endIdx: number } | null {
376
+ if (expected.length === 0 || expected.length > lines.length) return null;
377
+ // Fast path: try the recorded position first.
378
+ if (preferredIdx >= 0 && matchesAt(lines, preferredIdx, expected)) {
379
+ return { startIdx: preferredIdx, endIdx: preferredIdx + expected.length - 1 };
380
+ }
381
+ let best: number | null = null;
382
+ let bestDist = Number.POSITIVE_INFINITY;
383
+ const limit = lines.length - expected.length;
384
+ for (let i = 0; i <= limit; i++) {
385
+ if (!matchesAt(lines, i, expected)) continue;
386
+ const dist = Math.abs(i - preferredIdx);
387
+ if (dist < bestDist) {
388
+ best = i;
389
+ bestDist = dist;
390
+ }
391
+ }
392
+ if (best === null) return null;
393
+ return { startIdx: best, endIdx: best + expected.length - 1 };
394
+ }
395
+
396
+ function matchesAt(lines: readonly string[], startIdx: number, expected: readonly string[]): boolean {
397
+ if (startIdx < 0 || startIdx + expected.length > lines.length) return false;
398
+ for (let i = 0; i < expected.length; i++) {
399
+ if (lines[startIdx + i] !== expected[i]) return false;
400
+ }
401
+ return true;
402
+ }
403
+
404
+ function normalizeTrailingNewline(replacement: string): string {
405
+ if (replacement.endsWith("\r\n")) return replacement.slice(0, -2);
406
+ if (replacement.endsWith("\n")) return replacement.slice(0, -1);
407
+ return replacement;
408
+ }
409
+
410
+ /**
411
+ * Expand `@ours` / `@theirs` / `@base` / `@both` line tokens against the
412
+ * recorded sections of `entry`. A token only triggers when it is the
413
+ * entire content of a line (after CRLF normalisation), so `@ours` inside
414
+ * actual code is left alone. Other lines pass through verbatim.
415
+ *
416
+ * - `@ours` → expands to the recorded `oursLines`
417
+ * - `@theirs` → expands to the recorded `theirsLines`
418
+ * - `@base` → expands to `baseLines`; throws if no base section
419
+ * - `@both` → expands to `oursLines` then `theirsLines`
420
+ */
421
+ export function expandContentTokens(content: string, entry: ConflictEntry): string {
422
+ const inputLines = content.split("\n");
423
+ const out: string[] = [];
424
+ for (const rawLine of inputLines) {
425
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
426
+ switch (line) {
427
+ case "@ours":
428
+ out.push(...entry.oursLines);
429
+ break;
430
+ case "@theirs":
431
+ out.push(...entry.theirsLines);
432
+ break;
433
+ case "@base":
434
+ if (!entry.baseLines) {
435
+ throw new Error(
436
+ `Conflict #${entry.id} has no base section (2-way merge). \`@base\` is only valid for diff3 conflicts.`,
437
+ );
438
+ }
439
+ out.push(...entry.baseLines);
440
+ break;
441
+ case "@both":
442
+ out.push(...entry.oursLines, ...entry.theirsLines);
443
+ break;
444
+ default:
445
+ out.push(rawLine);
446
+ break;
447
+ }
448
+ }
449
+ return out.join("\n");
450
+ }
451
+
452
+ /** Reconstruct a conflict-marker line from prefix and optional label. */
453
+ function markerLine(prefix: string, label: string | undefined): string {
454
+ return label && label.length > 0 ? `${prefix} ${label}` : prefix;
455
+ }
456
+
457
+ /**
458
+ * Materialise a conflict block for `conflict://<N>` reads (and their
459
+ * `/ours` / `/theirs` / `/base` scopes).
460
+ *
461
+ * Returns:
462
+ * - `lines`: the lines to render, ordered top-to-bottom.
463
+ * - `startLine`: the 1-indexed file line number `lines[0]` corresponds
464
+ * to, so the read formatter can label hashline anchors.
465
+ */
466
+ export function renderConflictRegion(
467
+ entry: ConflictEntry,
468
+ scope: ConflictScope | undefined,
469
+ ): { lines: string[]; startLine: number } {
470
+ if (scope === "ours") {
471
+ return { lines: [...entry.oursLines], startLine: entry.startLine + 1 };
472
+ }
473
+ if (scope === "theirs") {
474
+ return { lines: [...entry.theirsLines], startLine: entry.separatorLine + 1 };
475
+ }
476
+ if (scope === "base") {
477
+ if (entry.baseLines === undefined || entry.baseLine === undefined) {
478
+ throw new Error(
479
+ `Conflict #${entry.id} has no base section (2-way merge). 'conflict://${entry.id}/base' is only valid for diff3 conflicts.`,
480
+ );
481
+ }
482
+ return { lines: [...entry.baseLines], startLine: entry.baseLine + 1 };
483
+ }
484
+ const out: string[] = [];
485
+ out.push(markerLine("<<<<<<<", entry.oursLabel));
486
+ out.push(...entry.oursLines);
487
+ if (entry.baseLines !== undefined) {
488
+ out.push(markerLine("|||||||", entry.baseLabel));
489
+ out.push(...entry.baseLines);
490
+ }
491
+ out.push("=======");
492
+ out.push(...entry.theirsLines);
493
+ out.push(markerLine(">>>>>>>", entry.theirsLabel));
494
+ return { lines: out, startLine: entry.startLine };
495
+ }
496
+
497
+ const PREVIEW_SIDE_LINES = 6;
498
+
499
+ function pickLabel(
500
+ entries: readonly ConflictEntry[],
501
+ get: (e: ConflictEntry) => string | undefined,
502
+ ): string | undefined {
503
+ for (const e of entries) {
504
+ const label = get(e);
505
+ if (label && label.trim().length > 0) return label;
506
+ }
507
+ return undefined;
508
+ }
509
+
510
+ function sectionsEqual(a: readonly string[], b: readonly string[]): boolean {
511
+ if (a.length !== b.length) return false;
512
+ for (let i = 0; i < a.length; i++) {
513
+ if (a[i] !== b[i]) return false;
514
+ }
515
+ return true;
516
+ }
517
+
518
+ function appendBody(out: string[], section: readonly string[]): void {
519
+ if (section.length === 0) {
520
+ out.push("(empty)");
521
+ return;
522
+ }
523
+ const shown = section.slice(0, PREVIEW_SIDE_LINES);
524
+ for (const line of shown) out.push(line);
525
+ const hidden = section.length - shown.length;
526
+ if (hidden > 0) out.push(`… (${hidden} more line${hidden === 1 ? "" : "s"})`);
527
+ }
528
+
529
+ export interface FormatConflictWarningOptions {
530
+ /** Total number of conflicts in the underlying file. */
531
+ totalInFile?: number;
532
+ /** Display path used inside the `:conflicts` hint. */
533
+ displayPath?: string;
534
+ /** Whether the underlying file scan hit its byte cap. */
535
+ scanTruncated?: boolean;
536
+ }
537
+
538
+ /**
539
+ * Build a compact diff-style footer describing the conflicts registered
540
+ * during a read. Designed to be appended after the file content.
541
+ *
542
+ * Format:
543
+ *
544
+ * ⚠ N unresolved conflicts detected
545
+ * - ours = HEAD
546
+ * - theirs = feature/x
547
+ * NOTICE: …
548
+ *
549
+ * ──── #1 L42-48 ────
550
+ * <<< ours
551
+ * …ours body…
552
+ * === base ≡ ours
553
+ * >>> theirs
554
+ * …theirs body…
555
+ */
556
+ export function formatConflictWarning(
557
+ entries: readonly ConflictEntry[],
558
+ options: FormatConflictWarningOptions = {},
559
+ ): string {
560
+ if (entries.length === 0) return "";
561
+ const total = options.totalInFile ?? entries.length;
562
+ const partial = total > entries.length;
563
+ const out: string[] = [];
564
+ out.push("");
565
+ const word = total === 1 ? "conflict" : "conflicts";
566
+ if (partial) {
567
+ const hintPath = options.displayPath ?? "<file>";
568
+ out.push(
569
+ `⚠ ${entries.length} of ${total} unresolved ${word} visible in this window (read \`${hintPath}:conflicts\` for the full list).`,
570
+ );
571
+ } else {
572
+ out.push(`⚠ ${total} unresolved ${word} detected`);
573
+ }
574
+ if (options.scanTruncated) {
575
+ out.push("- note: file scan hit the byte cap; additional conflicts may exist beyond the scanned prefix.");
576
+ }
577
+
578
+ const oursLabel = pickLabel(entries, (e) => e.oursLabel);
579
+ const theirsLabel = pickLabel(entries, (e) => e.theirsLabel);
580
+ const baseLabel = pickLabel(entries, (e) => (e.baseLines !== undefined ? e.baseLabel : undefined));
581
+ const anyBase = entries.some((e) => e.baseLines !== undefined);
582
+ if (oursLabel) out.push(`- ours = ${oursLabel}`);
583
+ if (theirsLabel) out.push(`- theirs = ${theirsLabel}`);
584
+ if (anyBase) out.push(`- base = ${baseLabel ?? "(no label)"}`);
585
+ out.push(
586
+ 'NOTICE: Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` to render a single side). Resolve with `write({ path: "conflict://<N>", content })`, or bulk-resolve every registered conflict with `write({ path: "conflict://*", content })`.',
587
+ );
588
+ out.push(
589
+ '`content` shorthand: a line that is exactly `@ours` / `@theirs` / `@base` / `@both` expands to that recorded section. Non-token lines pass through verbatim.',
590
+ );
591
+
592
+ for (const entry of entries) {
593
+ const range = entry.startLine === entry.endLine ? `L${entry.startLine}` : `L${entry.startLine}-${entry.endLine}`;
594
+ out.push("");
595
+ out.push(`──── #${entry.id} ${range} ────`);
596
+
597
+ const baseEqualsOurs = entry.baseLines !== undefined && sectionsEqual(entry.baseLines, entry.oursLines);
598
+ const baseEqualsTheirs = entry.baseLines !== undefined && sectionsEqual(entry.baseLines, entry.theirsLines);
599
+ const theirsEqualsOurs = sectionsEqual(entry.theirsLines, entry.oursLines);
600
+
601
+ out.push("<<< ours");
602
+ appendBody(out, entry.oursLines);
603
+
604
+ if (entry.baseLines !== undefined) {
605
+ if (baseEqualsOurs) {
606
+ out.push("=== base ≡ ours");
607
+ } else if (baseEqualsTheirs) {
608
+ out.push("=== base ≡ theirs");
609
+ } else {
610
+ out.push("=== base");
611
+ appendBody(out, entry.baseLines);
612
+ }
613
+ }
614
+
615
+ if (theirsEqualsOurs) {
616
+ out.push(">>> theirs ≡ ours");
617
+ } else {
618
+ out.push(">>> theirs");
619
+ appendBody(out, entry.theirsLines);
620
+ }
621
+ }
622
+ return out.join("\n");
623
+ }
624
+
625
+ /**
626
+ * Render a single-line-per-block index of every conflict in a file.
627
+ * Used by `<path>:conflicts` selector.
628
+ */
629
+ export function formatConflictSummary(
630
+ entries: readonly ConflictEntry[],
631
+ options: { displayPath: string; scanTruncated?: boolean } = { displayPath: "" },
632
+ ): string {
633
+ const lines: string[] = [];
634
+ const total = entries.length;
635
+ const word = total === 1 ? "conflict" : "conflicts";
636
+ lines.push(`⚠ ${total} unresolved ${word} in ${options.displayPath || "<file>"}`);
637
+ if (options.scanTruncated) {
638
+ lines.push("- note: file scan hit the byte cap; additional conflicts may exist beyond the scanned prefix.");
639
+ }
640
+ const oursLabel = pickLabel(entries, (e) => e.oursLabel);
641
+ const theirsLabel = pickLabel(entries, (e) => e.theirsLabel);
642
+ const baseLabel = pickLabel(entries, (e) => (e.baseLines !== undefined ? e.baseLabel : undefined));
643
+ const anyBase = entries.some((e) => e.baseLines !== undefined);
644
+ if (oursLabel) lines.push(`- ours = ${oursLabel}`);
645
+ if (theirsLabel) lines.push(`- theirs = ${theirsLabel}`);
646
+ if (anyBase) lines.push(`- base = ${baseLabel ?? "(no label)"}`);
647
+ lines.push(
648
+ 'NOTICE: Bulk-resolve with `write({ path: "conflict://*", content })`, or address a single block with `write({ path: "conflict://<N>", content })`.',
649
+ );
650
+ lines.push(
651
+ "`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections. Non-token lines pass through verbatim.",
652
+ );
653
+ lines.push("");
654
+ const idWidth = String(entries[entries.length - 1]?.id ?? 1).length;
655
+ for (const entry of entries) {
656
+ const range = entry.startLine === entry.endLine ? `L${entry.startLine}` : `L${entry.startLine}-${entry.endLine}`;
657
+ const idCell = `#${String(entry.id).padStart(idWidth, " ")}`;
658
+ const kind = entry.baseLines !== undefined ? " (3-way)" : "";
659
+ lines.push(`${idCell} ${range}${kind}`);
660
+ }
661
+ return lines.join("\n");
662
+ }
@@ -0,0 +1,30 @@
1
+ import { SECRET_KEY_PATTERN } from "./redaction.ts";
2
+
3
+ export interface SanitizeEnvOptions {
4
+ /** Allow-list of env var names to preserve. Supports trailing glob, e.g. `"PI_*"`. */
5
+ allowList?: string[];
6
+ }
7
+
8
+ /**
9
+ * Strip env vars whose keys look like secrets before passing to child processes.
10
+ *
11
+ * Default mode (no allowList): deny-list using SECRET_KEY_PATTERN.
12
+ * When allowList is provided, only keys matching the allow-list are preserved.
13
+ */
14
+ export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeEnvOptions): Record<string, string> {
15
+ const filtered: Record<string, string> = {};
16
+ if (options?.allowList && options.allowList.length > 0) {
17
+ const matchers = options.allowList.map((p) => {
18
+ if (p.endsWith("*")) return (k: string) => k.startsWith(p.slice(0, -1));
19
+ return (k: string) => k === p;
20
+ });
21
+ for (const [key, value] of Object.entries(env)) {
22
+ if (value !== undefined && matchers.some((fn) => fn(key))) filtered[key] = value;
23
+ }
24
+ return filtered;
25
+ }
26
+ for (const [key, value] of Object.entries(env)) {
27
+ if (value !== undefined && !SECRET_KEY_PATTERN.test(key)) filtered[key] = value;
28
+ }
29
+ return filtered;
30
+ }