@senad-d/branchme 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.13 - Unreleased
4
+
5
+ - Added optional `integrate_branch.keepConflicts` (default `false`). When true, a conflicted local integration returns `conflict_kept` with the merge left in progress for `conclude_merge` to finish after resolution or abort; default conflict handling still aborts and verifies restoration. Updated schemas, prompt guidance, documentation, and real-Git regression coverage.
6
+
7
+ ## 0.3.12 - Unreleased
8
+
9
+ - Added `pull_request_files` (read-only: a PR's first 100 changed files with change type and line counts, plus its labels) and `label_pull_request` (adds 1 to 5 existing repository labels to a PR; GitHub would create an unknown label, so it is refused first). An agent can now choose and set a PR's area label from what it changes.
10
+ - `pull_request_feedback` prints each comment's and review's URL, and review ids, so a follow-up issue can quote and link the exact comment.
11
+
3
12
  ## 0.3.11 - Unreleased
4
13
 
5
14
  - `land_branch` with `pullRequestNumber` accepts a local source branch that is the merged PR head or one of its ancestors: a branch left behind because the PR received commits pushed from another checkout (for example a merge of the base) holds nothing the PR lacks, and is retired. A local branch with commits the PR head does not contain is still refused, now with its own message and Blocked/Resolve guidance; a PR head that is not available locally counts as not containing it.
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  ---
18
18
 
19
- BranchMe is a Pi extension for safe Git repository, branch, and worktree workflow automation. Before each agent run, it appends a bounded, read-only snapshot of the current Git repository to the system prompt. It also adds an informational `/branchme` command and twenty-three agent-callable tools that initialize a repository, refresh state, manage, integrate, and retire local branches, conclude a kept merge, inspect/create/remove linked worktrees, push the current branch, create GitHub pull requests, and read and answer PR feedback.
19
+ BranchMe is a Pi extension for safe Git repository, branch, and worktree workflow automation. Before each agent run, it appends a bounded, read-only snapshot of the current Git repository to the system prompt. It also adds an informational `/branchme` command and twenty-five agent-callable tools that initialize a repository, refresh state, manage, integrate, and retire local branches, conclude a kept merge, inspect/create/remove linked worktrees, push the current branch, create GitHub pull requests, read and answer PR feedback, and list and label a PR's changed files.
20
20
 
21
21
  <table align="center">
22
22
  <tr>
@@ -257,6 +257,8 @@ Commands are informational only. BranchMe actions are performed by agent-callabl
257
257
  | `track_branch` | `{ "branchName": string, "remote"?: string, "remoteBranch"?: string }` | Fetch one exact remote branch and create/check out a new local tracking branch. Remote defaults to `origin`; remoteBranch defaults to branchName. Requires a clean idle checkout; verifies HEAD and upstream. |
258
258
  | `update_from_base` | `{ "baseBranch": string, "remote"?: string, "keepConflicts"?: boolean }` | Fetch the exact remote base (remote defaults to `origin`) and merge its captured commit into the clean current feature. Uses verified normal-merge policy; a `conflict` is automatically aborted with its paths listed, or with `keepConflicts: true` reported as `conflict_kept` and left in progress (MERGE_HEAD set, conflicted paths unmerged) for `conclude_merge`. Never rebases, pushes, or changes upstream configuration. |
259
259
  | `conclude_merge` | `{ "action": "conclude" \| "abort" }` | Finish or abandon the in-progress merge on the current checkout. `conclude` refuses default/custom-sized `<<<<<<<`, `|||||||`, `=======`, or `>>>>>>>` marker lines in unmerged working-tree paths and staged blobs (including CRLF and binary content, naming the paths), stages only the currently unmerged paths, rechecks the candidate index, commits with `git commit --no-edit`, and verifies MERGE_HEAD is gone and HEAD is a two-parent merge of the previous HEAD and MERGE_HEAD; unrelated unstaged changes stay unstaged. `abort` runs `git merge --abort` and verifies the restored HEAD, cleared operation state, and clean tree. Refuses when no merge is in progress. |
260
+ | `pull_request_files` | `{ "number": integer }` | List a pull request's first 100 changed files (path, change type, additions, deletions; the rest counted) and its current labels. Read-only. |
261
+ | `label_pull_request` | `{ "number": integer, "labels": string[] }` | Add 1 to 5 labels that already exist in the repository to a pull request; an unknown label is refused before anything changes, existing labels stay. |
260
262
  | `reply_pull_request_feedback` | `{ "number": integer, "body": string, "threadId"?: string, "resolve"?: boolean }` | Answer PR feedback on an open, merged or closed PR of this repository (forks included). With `threadId` (from `pull_request_feedback`) it replies in that review thread after verifying the thread belongs to the PR, and `resolve: true` then resolves it; without `threadId` it posts one conversation comment after confirming the number is a pull request, not an issue. Body is Markdown, at most 4,000 characters. Never edits, deletes, approves, merges, or closes. |
261
263
  | `pull_request_feedback` | `{ "number"?: integer, "headBranch"?: string }` | Read what a same-repository PR asks to be fixed in one GraphQL request: unresolved, current review threads with their comments (resolved and outdated threads are counted only), review summaries with a body or requested changes, the latest 50 conversation comments, and the head commit's failing checks with up to 20 failure and warning annotations each (`path:line LEVEL: message`). Threads and conversation comments carry their GraphQL ids, every comment its author and time, and comments by the token's own user are marked `(you)`, so a caller can reply to a thread and tell new comments from its own replies. For up to three failing GitHub Actions jobs it adds the log lines leading to the last `##[error]`. A job log that cannot be read is reported per check. No mutations. |
262
264
  | `pull_request_status` | `{ "number"?: integer, "headBranch"?: string }` | Read exact same-repository PR lifecycle and head/base/merge identities. Number and headBranch are mutually exclusive. Without number, returns the most recently updated PR for headBranch or the current branch. Does not certify CI checks or review approvals. |
@@ -271,14 +273,14 @@ Commands are informational only. BranchMe actions are performed by agent-callabl
271
273
  | `fetch_branch` | `{ "remote"?: string, "branch"?: string }` | With no arguments it requires a current branch with a configured upstream and runs `git fetch --no-tags --no-recurse-submodules <upstream-remote> <upstream-branch-ref>:<remote-tracking-ref>`. With `branch` (and optional `remote`, default `origin`; `remote` requires `branch`) it fetches that exact remote branch into `refs/remotes/<remote>/<branch>` instead. Either way only that tracking ref is refreshed without changing local branches, working-tree files, or upstream configuration. |
272
274
  | `pull_branch` | `{}` | Requires a clean current branch with a configured upstream and runs `git pull --ff-only --no-rebase --no-autostash <upstream-remote> <upstream-branch-ref>`; divergence fails without rebasing or creating a merge commit. |
273
275
  | `rebase_branch` | `{}` | Requires a clean current branch with a configured upstream and runs `git rebase --no-autostash --no-update-refs <upstream>`; it rewrites local commits and automatically attempts `git rebase --abort` on failure. |
274
- | `integrate_branch` | `{ "sourceBranch": string, "targetBranch": string }` | Integrates one exact existing local source branch into one distinct existing local target branch. The clean active control worktree must already have the target checked out. It returns `already_integrated`, `fast_forward`, `merge_commit`, or a `conflict` only after automatic abort and verified restoration. It never fetches or pushes. |
276
+ | `integrate_branch` | `{ "sourceBranch": string, "targetBranch": string, "keepConflicts"?: boolean }` | Integrates one exact existing local source branch into one distinct existing local target branch. The clean active control worktree must already have the target checked out. It returns `already_integrated`, `fast_forward`, `merge_commit`, or a `conflict` only after automatic abort and verified restoration; with `keepConflicts: true` a conflict is reported as `conflict_kept` and left in progress (MERGE_HEAD set, conflicted paths unmerged) for `conclude_merge`. It never fetches or pushes. |
275
277
  | `create_branch` | `{ "branchName": string }` | Validates `branchName`, rejects existing local branches, and runs `git switch -c <branchName>` from current `HEAD`. |
276
278
  | `push_branch` | `{}` | Pushes the current branch to its configured upstream remote with an explicit `HEAD:<upstream-branch-ref>` refspec, or publishes it with `git push --set-upstream origin <currentBranch>` when no upstream exists. A branch whose upstream is a differently named `main`, `master`, `trunk`, `develop`, or origin default branch is published to its own name instead and tracks it. |
277
279
  | `pull_request` | `{ "headBranch"?: string, "baseBranch"?: string, "title"?: string, "body"?: string, "draft"?: boolean }` | Preflights GitHub branch visibility and verifies the GitHub `headBranch` commit matches the local branch, then reuses an exact matching open PR or creates one in the resolved current repository. Existing PR metadata is preserved. Omitted fields require `BRANCHME_PR_AUTOFILL=true`; branch refs must be distinct, exist locally, and cannot use `owner:branch`. |
278
280
 
279
281
  `conclude_merge` supports only single-head merges and refuses multi-head merges before mutation. Its marker check also covers manually staged resolutions and clean-filter output. Marker-like lines (including Markdown setext underlines) are conservatively refused; configured marker sizes above 4,096 are refused. Git commits the **entire index**, including already-staged merge entries: do not stage unrelated edits during the merge. Unrelated unstaged edits remain outside the commit. A failed/lost commit response reports uncertain postconditions; inspect `HEAD`, `MERGE_HEAD`, and the index before retrying.
280
282
 
281
- All schemas reject additional properties. `fetch_remote` accepts only optional `remote` and `prune`, never arbitrary refspecs or tag/force/checkout controls. `list_branches` accepts only optional `kind` and bounded `patterns`; it remains read-only. `init_repository` accepts only optional `initialBranch`, never a path or repository-mode controls. `change_branch` never accepts `baseRef`, `force`, `stash`, `discard`, `create`, `owner`, `repo`, or path inputs. `fetch_branch` accepts only the optional `remote` and `branch` pair for a targeted remote-tracking refresh (`remote` requires `branch`) and never accepts refspec, tags, prune, or force controls. `pull_branch` and `rebase_branch` have strict empty schemas and never accept a branch, remote, refspec, force, autostash, or arbitrary rebase target. `integrate_branch` requires exactly `sourceBranch` and `targetBranch`; it accepts no repository, path, remote, strategy, message, squash, signing, commit, continuation, abort, force, fetch, push, deletion, or worktree controls. `conclude_merge` requires exactly `action` (`conclude` or `abort`) and accepts no path list, commit message, strategy, `--ours`/`--theirs`, or force controls. `create_worktree` requires exactly `worktreePath`, `branchName`, and `branchMode`, plus an optional read-only `baseRef` start point for `branchMode: "new"`; `remove_worktree` requires `worktreePath` and accepts optional boolean `deleteIgnored`. No worktree tool accepts force, move, prune, repair, lock, unlock, detached, orphan, remote, or refspec controls. `retire_branch` requires exactly `branchName`, a full 40- or 64-hex-character `expectedHead`, a distinct local `targetBranch`, and the boolean `force` decision; it accepts no repository, path, remote, refspec, pattern, branch list, prune, remote-delete, worktree-removal, or inferred-target control. `pull_request` never accepts `owner`, `repo`, or owner-prefixed branch refs; BranchMe resolves the repository from local `origin` and/or matching `GITHUB_REPOSITORY`. Generic `continue_merge` and `abort_merge` tools are not available; `conclude_merge` is the only merge-continuation surface and only commits paths whose markers are already gone.
283
+ All schemas reject additional properties. `fetch_remote` accepts only optional `remote` and `prune`, never arbitrary refspecs or tag/force/checkout controls. `list_branches` accepts only optional `kind` and bounded `patterns`; it remains read-only. `init_repository` accepts only optional `initialBranch`, never a path or repository-mode controls. `change_branch` never accepts `baseRef`, `force`, `stash`, `discard`, `create`, `owner`, `repo`, or path inputs. `fetch_branch` accepts only the optional `remote` and `branch` pair for a targeted remote-tracking refresh (`remote` requires `branch`) and never accepts refspec, tags, prune, or force controls. `pull_branch` and `rebase_branch` have strict empty schemas and never accept a branch, remote, refspec, force, autostash, or arbitrary rebase target. `integrate_branch` requires exactly `sourceBranch` and `targetBranch` plus optional boolean `keepConflicts`; it accepts no repository, path, remote, strategy, message, squash, signing, commit, continuation, abort, force, fetch, push, deletion, or worktree controls. `conclude_merge` requires exactly `action` (`conclude` or `abort`) and accepts no path list, commit message, strategy, `--ours`/`--theirs`, or force controls. `create_worktree` requires exactly `worktreePath`, `branchName`, and `branchMode`, plus an optional read-only `baseRef` start point for `branchMode: "new"`; `remove_worktree` requires `worktreePath` and accepts optional boolean `deleteIgnored`. No worktree tool accepts force, move, prune, repair, lock, unlock, detached, orphan, remote, or refspec controls. `retire_branch` requires exactly `branchName`, a full 40- or 64-hex-character `expectedHead`, a distinct local `targetBranch`, and the boolean `force` decision; it accepts no repository, path, remote, refspec, pattern, branch list, prune, remote-delete, worktree-removal, or inferred-target control. `pull_request` never accepts `owner`, `repo`, or owner-prefixed branch refs; BranchMe resolves the repository from local `origin` and/or matching `GITHUB_REPOSITORY`. Generic `continue_merge` and `abort_merge` tools are not available; `conclude_merge` is the only merge-continuation surface and only commits paths whose markers are already gone.
282
284
 
283
285
  ---
284
286
 
@@ -338,9 +340,10 @@ The structured status is one of:
338
340
  - `already_integrated`: no merge ran and both branch refs remained stable;
339
341
  - `fast_forward`: the target advanced exactly to the captured source commit;
340
342
  - `merge_commit`: Git created an exact normal two-parent merge whose parents are the captured prior target and source;
341
- - `conflict`: bounded, exact repository-relative conflict paths were captured, `git merge --abort` succeeded, and repository identity, current target, exact source/target refs, clean state, and absence of operation state were verified.
343
+ - `conflict`: bounded, exact repository-relative conflict paths were captured, `git merge --abort` succeeded, and repository identity, current target, exact source/target refs, clean state, and absence of operation state were verified;
344
+ - `conflict_kept` (only with `keepConflicts: true`): the conflict paths were captured and the merge was left in progress with MERGE_HEAD at the captured source commit, HEAD unchanged, and no other operation active; `conclude_merge` commits it after the markers are removed or aborts it.
342
345
 
343
- All outcomes include captured before/after source and target commit IDs plus final source and prior-target ancestry proof. Failed non-conflict merges remain errors after cleanup. BranchMe uses no reset-based rollback; if abort or postcondition checks are inconclusive, or a ref moves unexpectedly, it reports that integration may have completed and tells the caller to inspect the repository before retrying.
346
+ Successful and automatically aborted outcomes include captured before/after source and target commit IDs plus final source and prior-target ancestry proof. `conflict_kept` instead includes the captured source and target commit IDs and in-progress merge facts. Failed non-conflict merges remain errors after cleanup. BranchMe uses no reset-based rollback; if abort or postcondition checks are inconclusive, or a ref moves unexpectedly, it reports that integration may have completed and tells the caller to inspect the repository before retrying.
344
347
 
345
348
  The same-repository mutation queue covers preflight, merge, cleanup, and verification, but it exists only inside the current BranchMe process. It does not lock another Pi session or an external Git process. `integrate_branch` itself does not fetch, pull, push, delete branches, remove worktrees, start agents, ask questions, continue a merge, or resolve semantic conflicts. A Mission or other orchestrator may explicitly call it; after a verified `conflict`, that separate workflow decides whether to delegate analysis to an integration agent or ask the developer about semantic intent.
346
349
 
@@ -536,7 +539,7 @@ Ensure the token and Git credentials have permission for the branch and pull req
536
539
  | Integration target mismatch or missing local branch | Check out the exact local `targetBranch` in the active control worktree and ensure both distinct branch refs already exist locally; `integrate_branch` never switches, fetches, or accepts remote-only refs. |
537
540
  | Integration rejects branch-specific merge options | Clear `branch.<targetBranch>.mergeOptions` outside BranchMe before retrying; options such as `--no-commit`, `--squash`, `--no-verify`, or a custom strategy would change the fixed policy. |
538
541
  | Integration reports `conflict` | The initial merge was automatically aborted and exact restoration was verified; the conflict paths are listed in the result text. BranchMe has no generic `continue_merge` tool and does not resolve semantic conflicts. |
539
- | `update_from_base` reports `conflict_kept` | The merge is in progress with MERGE_HEAD set and the listed paths unmerged. Have a file-editing step remove every conflict marker, then run `conclude_merge({ action: "conclude" })`; run `conclude_merge({ action: "abort" })` to restore the branch instead. Other BranchMe mutations refuse while the merge is in progress. |
542
+ | `integrate_branch` or `update_from_base` reports `conflict_kept` | The merge is in progress with MERGE_HEAD set and the listed paths unmerged. Have a file-editing step remove every conflict marker, then run `conclude_merge({ action: "conclude" })`; run `conclude_merge({ action: "abort" })` to restore the branch instead. Other BranchMe mutations refuse while the merge is in progress. |
540
543
  | `conclude_merge` refuses with marker paths | An unmerged working-tree file or staged blob contains a default/custom-sized conflict marker line. Remove the markers and restage any manually staged resolution; marker-like Markdown underlines also count. Retry or abort. |
541
544
  | Integration is uncertain or cleanup fails | Inspect branch refs, `HEAD`, worktree status, and Git operation state manually before retrying. BranchMe does not use reset-based rollback. |
542
545
  | Retirement expected `HEAD` is stale | Refresh the exact local branch commit and target ancestry, confirm the intended commit, then make a new sequential `retire_branch` call. Never reuse a stale commit identity. |
@@ -566,7 +569,7 @@ npm run check:pack
566
569
  printf '/branchme help\n/quit\n' | pi --no-extensions -e .
567
570
  ```
568
571
 
569
- Validation covers TypeScript typechecking, formatting checks, automatic context collection and prompt injection, mocked GitHub lookup, isolated real-Git worktree, branch-integration, and leased branch-retirement lifecycle tests, package checks, checkout Pi runtime smoke, and package-content verification. The checkout smoke loads BranchMe through Pi, then uses a temporary verifier command to confirm all twenty-three BranchMe tools are visible through `pi.getAllTools()` with strict schemas and prompt metadata, including `integrate_branch`, `retire_branch`, `conclude_merge`, and targeted `branch_status.ancestry`, with no generic `continue_merge` or `abort_merge` tool. Runtime smoke inspects retirement and landing registration/schema but never executes either cleanup tool. Smoke-test notes are recorded in [`docs/SMOKE_TEST.md`](docs/SMOKE_TEST.md), and TUI/help captures are stored in [`docs/TUI_CAPTURE.md`](docs/TUI_CAPTURE.md).
572
+ Validation covers TypeScript typechecking, formatting checks, automatic context collection and prompt injection, mocked GitHub lookup, isolated real-Git worktree, branch-integration, and leased branch-retirement lifecycle tests, package checks, checkout Pi runtime smoke, and package-content verification. The checkout smoke loads BranchMe through Pi, then uses a temporary verifier command to confirm all twenty-five BranchMe tools are visible through `pi.getAllTools()` with strict schemas and prompt metadata, including `integrate_branch`, `retire_branch`, `conclude_merge`, and targeted `branch_status.ancestry`, with no generic `continue_merge` or `abort_merge` tool. Runtime smoke inspects retirement and landing registration/schema but never executes either cleanup tool. Smoke-test notes are recorded in [`docs/SMOKE_TEST.md`](docs/SMOKE_TEST.md), and TUI/help captures are stored in [`docs/TUI_CAPTURE.md`](docs/TUI_CAPTURE.md).
570
573
 
571
574
  Refresh TUI captures intentionally with:
572
575
 
package/SECURITY.md CHANGED
@@ -25,7 +25,7 @@ Implemented git mutations are limited to:
25
25
  - `fetch_branch`: `git fetch --no-tags --no-recurse-submodules <remote> <remoteBranchRef>:<remoteTrackingRef>`. With no arguments it validates and fetches the current branch's configured upstream; with explicit `branch` and optional configured `remote` (default `origin`) it fetches that exact remote branch. The explicit destination is limited to the selected remote-tracking ref, so local branches and working-tree files are not changed.
26
26
  - `pull_branch`: `git pull --ff-only --no-rebase --no-autostash <upstreamRemote> <upstreamBranchRef>` for the clean current branch after validating its configured upstream target.
27
27
  - `rebase_branch`: `git rebase --no-autostash --no-update-refs <upstream>` for the clean current branch after validating its configured upstream target. It rewrites local commits and automatically attempts `git rebase --abort` without the cancelled caller signal if the rebase fails or is killed.
28
- - `integrate_branch`: after rejecting a non-empty `branch.<targetBranch>.mergeOptions` setting, runs `git -c rerere.enabled=false merge --ff --no-edit --no-autostash --no-rerere-autoupdate --no-overwrite-ignore refs/heads/<sourceBranch>` from the verified clean control worktree, which must already have the distinct existing local `targetBranch` checked out. It uses normal merge semantics: no-op, fast-forward, or a Git-generated standard two-parent merge commit for divergent histories.
28
+ - `integrate_branch`: after rejecting a non-empty `branch.<targetBranch>.mergeOptions` setting, runs `git -c rerere.enabled=false merge --ff --no-edit --no-autostash --no-rerere-autoupdate --no-overwrite-ignore refs/heads/<sourceBranch>` from the verified clean control worktree, which must already have the distinct existing local `targetBranch` checked out. It uses normal merge semantics: no-op, fast-forward, or a Git-generated standard two-parent merge commit for divergent histories. Conflicts are automatically aborted with restoration verified by default; explicit `keepConflicts: true` instead returns `conflict_kept` after verifying the captured paths, unchanged target HEAD, source MERGE_HEAD, repository identity, and single-merge operation state. Use `conclude_merge` to finish after resolution or abort before any other Git mutation.
29
29
  - `push_branch`: `git push <upstreamRemote> HEAD:<upstreamBranchRef>` for the current branch when an upstream exists, or `git push --set-upstream origin <currentBranch>` when no upstream exists.
30
30
  - `create_worktree`: `git worktree add -b <branchName> <canonicalPath> <capturedCommit>` for a new local branch from current HEAD or optional read-only `baseRef`, or `git worktree add <canonicalPath> <existingLocalBranch>` for an existing unoccupied local branch, after destination and repository-boundary validation.
31
31
  - `remove_worktree`: `git worktree remove <verifiedCanonicalPath>` without force, only after fresh repository-membership, safety-state, path, tracked/untracked status, and ignored-entry checks. The local branch is retained and verified at the same commit.
@@ -81,7 +81,7 @@ The branch preflight requests have no body. BranchMe uses the resolved `headBran
81
81
  - `fetch_branch` accepts optional `branch` and `remote` (remote requires branch), otherwise resolves the current branch's configured upstream. It constructs the source-to-remote-tracking refspec internally and disables tag fetching and submodule recursion; no arbitrary refspec or prune controls are exposed.
82
82
  - `pull_branch` accepts no parameters, updates only the clean current branch from its configured upstream, and uses fast-forward-only semantics.
83
83
  - `rebase_branch` accepts no parameters, rebases only the clean current branch onto its configured upstream, disables autostash and multi-ref updates, never pushes, and attempts to abort on failure.
84
- - `integrate_branch` accepts exactly `sourceBranch` and `targetBranch`. Both must be distinct existing local refs in the current repository, and the active clean control worktree must already be on the target. Remote-only refs, full refs, commit IDs, paths, repository/remotes, owner-prefixed refs, and merge controls are rejected. A source branch may be checked out in another dirty linked worktree because only its captured committed ref is read.
84
+ - `integrate_branch` requires exactly `sourceBranch` and `targetBranch`, plus optional boolean `keepConflicts` (default `false`). Both must be distinct existing local refs in the current repository, and the active clean control worktree must already be on the target. Remote-only refs, full refs, commit IDs, paths, repository/remotes, owner-prefixed refs, and merge controls are rejected. A source branch may be checked out in another dirty linked worktree because only its captured committed ref is read.
85
85
  - Targeted `branch_status.ancestry` accepts a nested object containing two exact local branch or remote-tracking names; it is read-only, must run after integration completes, and must not share the same parallel tool batch.
86
86
  - If local `origin` and `GITHUB_REPOSITORY` both resolve but disagree, PR creation and related-PR lookup fail closed.
87
87
  - Resolved PR branches are validated as distinct, existing local branch-name refs; identical or missing local branches and cross-repository `head` values are rejected before any GitHub request.
@@ -15,7 +15,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
15
15
  - Exported extension function: `branchMeExtension`
16
16
  - Repository URL: `https://github.com/senad-d/branchme`
17
17
  - One-sentence pitch: Verified Pi tools for Git repository initialization plus current-repository branch, integration, retirement, linked-worktree, push, and GitHub pull request workflows.
18
- - Tool count: twenty-three strict agent-callable tools.
18
+ - Tool count: twenty-five strict agent-callable tools.
19
19
 
20
20
  ## 3. Users and use cases
21
21
 
@@ -57,10 +57,12 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
57
57
  | Tool | `conclude_merge` | Commit or abort the in-progress kept merge | Refuses remaining markers; stages only formerly unmerged paths; `commit --no-edit`; verified two-parent result or verified abort |
58
58
  | Tool | `pull_request_status` | Read exact PR or latest PR for a head | Open/closed/merged state and commit identities; not a CI/review verdict |
59
59
  | Tool | `pull_request_feedback` | Read what a PR asks to be fixed | Unresolved review threads, review summaries, conversation comments, failing checks with annotations and Actions log tails; read-only |
60
+ | Tool | `pull_request_files` | List a PR's changed files | Path, change type and line counts of the first 100 changed files, plus current labels; read-only |
61
+ | Tool | `label_pull_request` | Label a PR | Adds 1-5 labels that already exist in the repository; refuses unknown labels; never removes |
60
62
  | Tool | `reply_pull_request_feedback` | Answer PR feedback | One reply in a review thread of the PR (optionally resolving it) or one conversation comment; the thread must belong to the PR; never edits, deletes, approves, merges or closes |
61
63
  | Tool | `init_repository` | Initialize the exact current directory as a new non-bare Git repository | Optional initial branch; rejects reinitialization/nesting; no path, commit, or project-file controls |
62
64
  | Tool | `branch_status` | Refresh bounded current-worktree Git and related-PR context; optionally prove captured local or remote-tracking ancestry | Read-only; targeted ancestry is absent from automatic context |
63
- | Tool | `integrate_branch` | Integrate one exact local source into the already-current clean local target | Fixed normal-merge policy; verified automatic abort/restoration on conflict |
65
+ | Tool | `integrate_branch` | Integrate one exact local source into the already-current clean local target | Fixed normal-merge policy; verified automatic abort/restoration on conflict unless `keepConflicts` |
64
66
  | Tool | `list_worktrees` | List bounded main/linked worktree inventory | Read-only and explicit |
65
67
  | Tool | `create_worktree` | Create and verify a linked worktree | Exact absolute handoff cwd; new/existing local branch modes only |
66
68
  | Tool | `remove_worktree` | Remove an exact verified clean linked worktree | Force-free; ignored entries protected by default and deletable with explicit `deleteIgnored: true`; branch retained |
@@ -97,7 +99,7 @@ Originally approved on 2026-06-30. Updated to describe the implemented `0.3.1` p
97
99
  - `src/github.ts`
98
100
  - `src/ui/branchme-panel.ts`
99
101
  - Module boundaries:
100
- - The extension entry point registers the informational command, twenty-three tools, and automatic context hook.
102
+ - The extension entry point registers the informational command, twenty-five tools, and automatic context hook.
101
103
  - `src/git-workflow.ts` and `src/tools/workflow-tools.ts` own verified remote tracking, base updates, and the new PR status registration.
102
104
  - The context module owns bounded read-only collection, prompt formatting, targeted ancestry rendering, and the `before_agent_start` hook; automatic context never runs ancestry queries.
103
105
  - The command and UI modules own mode-safe informational help/status behavior and never invoke mutations.
@@ -139,7 +141,7 @@ Standalone removal retains its branch and requires explicit `deleteIgnored: true
139
141
 
140
142
  ## 8. Branch integration contract
141
143
 
142
- - `integrate_branch` accepts exactly `sourceBranch` and `targetBranch`. They must identify distinct existing local refs in the current repository; remote-only refs, full refs, commit IDs, paths, repositories, remotes, and owner-prefixed refs are rejected.
144
+ - `integrate_branch` accepts exactly `sourceBranch` and `targetBranch`, plus optional boolean `keepConflicts`. They must identify distinct existing local refs in the current repository; remote-only refs, full refs, commit IDs, paths, repositories, remotes, and owner-prefixed refs are rejected.
143
145
  - The active Pi worktree is the control worktree and must already have the target checked out, be clean, and have no merge/rebase/cherry-pick/revert/sequencer state. The source may be checked out in another dirty worktree because only its captured commit ref is read.
144
146
  - Before mutation, BranchMe rejects a non-empty `branch.<targetBranch>.mergeOptions` setting so branch-specific defaults cannot change the policy. The fixed command is `git -c rerere.enabled=false merge --ff --no-edit --no-autostash --no-rerere-autoupdate --no-overwrite-ignore refs/heads/<sourceBranch>`. It does not fetch or push, disables autostash and rerere, protects ignored files, and preserves hooks, custom merge drivers, clean/smudge filters, and signing policy.
145
147
  - Results distinguish `already_integrated`, `fast_forward`, `merge_commit`, and `conflict`. Before/after source and target commit IDs, repository/control-worktree identity, clean/operation state, and source/prior-target ancestry are verified. Merge-commit classification requires exact captured target/source parents.
@@ -198,7 +200,7 @@ Standalone removal retains its branch and requires explicit `deleteIgnored: true
198
200
  - Slash commands remain informational; tools perform all Git and GitHub actions.
199
201
  - Automatic context remains focused on the active worktree; inventory is available only through `list_worktrees`.
200
202
  - `create_worktree` returns a verified target for a caller-managed session but does not change cwd or create processes.
201
- - `integrate_branch` and `update_from_base` use the verified normal-merge state machine and automatically abort initial conflicts; only `update_from_base` fetches an explicit remote base before merging its captured commit, and only it may keep a conflicted merge in progress with `keepConflicts: true`. `conclude_merge` is the single continuation surface: it commits only marker-free formerly unmerged paths with Git's prepared message or aborts with verified restoration, and performs no semantic resolution.
203
+ - `integrate_branch` and `update_from_base` use the verified normal-merge state machine and automatically abort initial conflicts; only `update_from_base` fetches an explicit remote base before merging its captured commit, and either may keep a conflicted merge in progress with `keepConflicts: true`. `conclude_merge` is the single continuation surface: it commits only marker-free formerly unmerged paths with Git's prepared message or aborts with verified restoration, and performs no semantic resolution.
202
204
  - Worktree removal remains force-free, protects ignored entries by default, requires explicit authorization to delete them, and preserves the local branch.
203
205
  - Local branch retirement remains a separate explicit operation requiring a fresh expected `HEAD`, exact target ancestry, zero complete-inventory occupancy, and an explicit boolean force decision; it deletes only the leased local ref and leaves branch configuration and remote/remote-tracking refs untouched.
204
206
  - `push_branch` uses `origin` only when the current branch has no configured upstream.
@@ -23,9 +23,9 @@ pi --no-extensions -e .
23
23
  ## Automated smoke behavior
24
24
 
25
25
  - `npm run smoke:pi` first runs isolated checkout Pi processes from a temporary non-Git working directory: one with `pi --no-extensions -e <package> -e <temporary verifier>` and `/branchmeverify verify`, then one with `pi --no-extensions -e <package>` and `/branchme help`.
26
- - The temporary command verifier calls `pi.getAllTools()` after BranchMe loads and confirms exactly twenty-three tools—`fetch_remote`, `list_branches`, `track_branch`, `update_from_base`, `conclude_merge`, `pull_request_status`, `pull_request_feedback`, `reply_pull_request_feedback`, `branch_status`, `init_repository`, `change_branch`, `create_branch`, `create_worktree`, `fetch_branch`, `integrate_branch`, `land_branch`, `list_worktrees`, `pull_branch`, `pull_request`, `push_branch`, `rebase_branch`, `remove_worktree`, and `retire_branch`—are each registered exactly once and active with strict schemas, named prompt guidelines, descriptions, and extension source metadata. It also proves `continue_merge` and `abort_merge` are absent.
27
- - Runtime schema inspection verifies that `integrate_branch` has exactly the required `sourceBranch` and `targetBranch` fields and that `retire_branch` has exactly the required `branchName`, `expectedHead`, `targetBranch`, and `force` fields with `additionalProperties: false`. It also verifies that `branch_status` has only an optional top-level `ancestry` object whose required nested `sourceBranch` and `targetBranch` fields reject additional nested or top-level properties.
28
- - A second temporary verifier registers a deterministic local smoke model, blocks `fetch`, and runs normal prompts through real Pi lifecycle handling. It verifies automatic no-tool Git context in a temporary repository, one real `branch_status` tool refresh after a verifier-created local change, safe credential-free related-PR status with no request, and non-Git startup fallback. It expects all twenty-three tools but forbids `land_branch`, `retire_branch`, `integrate_branch`, `conclude_merge`, and every remote or worktree mutation tool (including `fetch_remote`) from executing.
26
+ - The temporary command verifier calls `pi.getAllTools()` after BranchMe loads and confirms exactly twenty-five tools—`fetch_remote`, `list_branches`, `track_branch`, `update_from_base`, `conclude_merge`, `pull_request_status`, `pull_request_feedback`, `reply_pull_request_feedback`, `pull_request_files`, `label_pull_request`, `branch_status`, `init_repository`, `change_branch`, `create_branch`, `create_worktree`, `fetch_branch`, `integrate_branch`, `land_branch`, `list_worktrees`, `pull_branch`, `pull_request`, `push_branch`, `rebase_branch`, `remove_worktree`, and `retire_branch`—are each registered exactly once and active with strict schemas, named prompt guidelines, descriptions, and extension source metadata. It also proves `continue_merge` and `abort_merge` are absent.
27
+ - Runtime schema inspection verifies that `integrate_branch` has exactly the required `sourceBranch` and `targetBranch` fields plus optional `keepConflicts` and that `retire_branch` has exactly the required `branchName`, `expectedHead`, `targetBranch`, and `force` fields with `additionalProperties: false`. It also verifies that `branch_status` has only an optional top-level `ancestry` object whose required nested `sourceBranch` and `targetBranch` fields reject additional nested or top-level properties.
28
+ - A second temporary verifier registers a deterministic local smoke model, blocks `fetch`, and runs normal prompts through real Pi lifecycle handling. It verifies automatic no-tool Git context in a temporary repository, one real `branch_status` tool refresh after a verifier-created local change, safe credential-free related-PR status with no request, and non-Git startup fallback. It expects all twenty-five tools but forbids `land_branch`, `retire_branch`, `integrate_branch`, `conclude_merge`, and every remote or worktree mutation tool (including `fetch_remote`) from executing.
29
29
  - The Pi runtime smoke validates worktree, integration, and retirement tool registration, strict schemas, and prompt contracts only; it never creates or removes a worktree, runs a merge, or retires a branch. Real-Git lifecycle coverage runs under `npm run test` in isolated temporary local repositories with no remote contact.
30
30
  - Isolated real-Git integration tests cover `already_integrated`, `fast_forward`, exact two-parent `merge_commit`, and conflict-path capture followed by verified automatic abort/restoration. They also cover target mismatch, dirty control state, rejection of branch-specific target merge options, unrelated histories, ignored-file overwrite protection, preserved repository hooks, and a committed source ref checked out in another dirty linked worktree. The recorded merge argv proves autostash and rerere are disabled, ignored-file protection is enabled, and `--no-verify` is absent.
31
31
  - Isolated real-Git retirement tests cover merged and explicitly forced-unmerged local-ref deletion, stale expected-`HEAD` rejection, current and linked-worktree occupancy, dirty unrelated worktrees, non-current targets, and preservation of target refs, remote-tracking refs, worktrees, working-tree files, and `branch.<name>.*` configuration without contacting a remote. Recorded argv proves retirement uses `update-ref --no-deref -d` with the captured expected commit and never uses `git branch -d/-D`, fetch, push, or a remote ref target.
package/docs/STRUCTURE.md CHANGED
@@ -30,7 +30,7 @@ src/
30
30
 
31
31
  ## Module boundaries
32
32
 
33
- 1. `src/extension.ts` stays small and registers the command, twenty-three tools, and one `before_agent_start` context hook.
33
+ 1. `src/extension.ts` stays small and registers the command, twenty-five tools, and one `before_agent_start` context hook.
34
34
  2. `src/git-context.ts` owns the shared read-only collector, escaped/bounded formatter, automatic system-prompt append, and the current-state output used by `branch_status`.
35
35
  3. `src/commands/branchme-command.ts` parses `/branchme`, `/branchme help`, `--help`, and `-h`; it never performs git or GitHub mutations and avoids raw stdout in JSON mode.
36
36
  4. `src/tools/branchme-tools.ts` owns strict TypeBox schemas, prompt metadata, bounded tool content, and safe structured details. `branch_status` delegates to the shared context collector and optional ancestry verifier; `integrate_branch` and `retire_branch` delegate to focused mutation state machines; worktree tools expose explicit inventory and verified handoff operations.
@@ -53,7 +53,7 @@ src/
53
53
  - No long-lived processes, watchers, timers, sockets, or background jobs start in the extension factory.
54
54
  - A single `before_agent_start` handler synchronously collects a fresh snapshot for each agent run and appends it to the existing system prompt; failures degrade to bounded unavailable context rather than blocking startup.
55
55
  - Slash commands are informational; tools perform repository initialization, branch, integration, retirement, worktree, push, and PR actions. Commands never initialize repositories, create/remove worktrees, merge or retire branches, change cwd, or start Pi sessions. There is no context command.
56
- - Every tool uses a strict TypeBox object schema with `additionalProperties: false`; `init_repository` accepts only optional `initialBranch`; `integrate_branch` requires exactly `sourceBranch` and `targetBranch`; `retire_branch` requires exactly `branchName`, full commit `expectedHead`, distinct `targetBranch`, and boolean `force`; `update_from_base` accepts optional boolean `keepConflicts`; `conclude_merge` requires exactly `action` (`conclude` or `abort`); worktree creation requires `worktreePath`, `branchName`, and `branchMode` with optional new-mode `baseRef`, listing is empty, and removal requires `worktreePath` with optional boolean `deleteIgnored`.
56
+ - Every tool uses a strict TypeBox object schema with `additionalProperties: false`; `init_repository` accepts only optional `initialBranch`; `integrate_branch` requires exactly `sourceBranch` and `targetBranch` with optional boolean `keepConflicts`; `retire_branch` requires exactly `branchName`, full commit `expectedHead`, distinct `targetBranch`, and boolean `force`; `update_from_base` accepts optional boolean `keepConflicts`; `conclude_merge` requires exactly `action` (`conclude` or `abort`); worktree creation requires `worktreePath`, `branchName`, and `branchMode` with optional new-mode `baseRef`, listing is empty, and removal requires `worktreePath` with optional boolean `deleteIgnored`.
57
57
  - Every tool defines a description, `promptSnippet`, and tool-specific `promptGuidelines` that explicitly name the tool.
58
58
  - Git commands use argv arrays with cwd, signal, and timeout; network calls use the transport wrapper's absolute executable and child environment, while other commands use `pi.exec("git", args, { cwd, signal, timeout })`; `init_repository` uses Pi's file-mutation queue for the new `.git` entry and queues on the canonical target directory, and repository mutations run from verified locations and same-repository mutation/PR windows are serialized per repository. Retirement uses one canonical-active-worktree-keyed queue window from preflight through final verification. The queue is process-local and does not lock a different active worktree, another Pi process, or an external Git process.
59
59
  - Worktree results expose serializable requested and verified before/after state. Create returns `handoff: { cwd: <absolute>, branch, head, ready: true, summary }`; remove returns the retained branch/HEAD with `cwd: null` and `ready: false`.
@@ -74,7 +74,7 @@ src/
74
74
  - `pull_branch` requires a clean worktree and configured upstream, then updates only the current branch with an explicit `git pull --ff-only --no-rebase --no-autostash <remote> <remote-ref>` command; divergence fails without a rebase or merge commit.
75
75
  - `rebase_branch` requires a clean worktree and configured upstream, then rebases only the current branch with `git rebase --no-autostash --no-update-refs <upstream>`; it rewrites local commits and automatically attempts `git rebase --abort` on failure.
76
76
  - `integrate_branch` requires distinct existing local refs and a clean control worktree already on the target. It rejects non-empty target-branch `mergeOptions`, runs `git -c rerere.enabled=false merge --ff --no-edit --no-autostash --no-rerere-autoupdate --no-overwrite-ignore refs/heads/<sourceBranch>`, verifies before/after refs and ancestry, and classifies already-integrated, fast-forward, exact two-parent merge-commit, or conflict.
77
- - Conflict status is emitted only after bounded lossless paths are captured, `git merge --abort` succeeds, and repository identity, exact refs, target checkout, absent operation state, and clean worktree restoration are verified. With `update_from_base` `keepConflicts: true`, `conflict_kept` is emitted only after the paths are captured and MERGE_HEAD, unchanged HEAD, branch, repository identity, and single-merge operation state are verified; a failed capture falls back to the verified abort. Failed or uncertain postconditions throw; no reset rollback exists.
77
+ - Conflict status is emitted only after bounded lossless paths are captured, `git merge --abort` succeeds, and repository identity, exact refs, target checkout, absent operation state, and clean worktree restoration are verified. With `integrate_branch` or `update_from_base` `keepConflicts: true`, `conflict_kept` is emitted only after the paths are captured and MERGE_HEAD, unchanged HEAD, branch, repository identity, and single-merge operation state are verified; a failed capture falls back to the verified abort. Failed or uncertain postconditions throw; no reset rollback exists.
78
78
  - `conclude_merge` operates only when MERGE_HEAD contains exactly one commit on a current direct local branch. `conclude` checks unmerged working-tree paths and staged changed blobs for default/custom-sized conflict markers (including diff3, CRLF, and binary content), stages exactly the currently unmerged paths with `git --literal-pathspecs add --`, rechecks the candidate index to cover clean filters, commits the full index with `git commit --no-edit`, and verifies MERGE_HEAD is gone, HEAD moved on the same branch, and HEAD has exactly the previous HEAD and MERGE_HEAD as parents; unrelated unstaged changes stay unstaged. Already-staged entries are included in the commit; callers must not stage unrelated edits during a merge. Pre-mutation reads honor cancellation; after staging begins, bounded mutation/verification ignores cancellation. Lost commit responses report uncertain postconditions. `abort` reuses the integration abort and restoration verification.
79
79
  - `retire_branch` requires one direct local ref, its exact full expected `HEAD`, one distinct direct local target, and a boolean force decision. It rejects complete-inventory worktree occupancy, proves ancestry against captured commits, and allows negative ancestry only with explicit `force: true` authorization.
80
80
  - Retirement deletes only `refs/heads/<branchName>` with `git update-ref --no-deref -d <fullRef> <capturedHead>`. The expected-old-value lease prevents deleting a moved ref; final repository, target, retiring-ref, and occupancy checks run with bounded timeouts after the mutation attempt even if the caller cancels. Contradictory or inconclusive state throws an uncertain error with manual-inspection guidance and no rollback ref mutation.
@@ -44,13 +44,15 @@ Commands only show info; BranchMe tools perform actions.
44
44
  - `pull_request_status` — inspect a PR by number, or the latest PR for a head branch; not a CI/review verdict.
45
45
  - `pull_request_feedback` — read a PR's unresolved review comments and failing checks with log tails.
46
46
  - `reply_pull_request_feedback` — reply to a PR review thread (optionally resolving it) or post a PR comment.
47
+ - `pull_request_files` — list a PR's changed files and current labels.
48
+ - `label_pull_request` — add existing repository labels to a PR.
47
49
  - `pull_request` — reuses an exact matching open PR without changing its title, body, or draft state.
48
50
  - `land_branch` — after host merge, clean up from the primary checkout; use `pullRequestNumber` for squash/rebase evidence.
49
51
 
50
52
  ## Branch integration
51
53
 
52
54
  - `integrate_branch` — integrate an exact local source into an exact local target; the clean control worktree must already have the target checked out.
53
- - It never fetches or pushes; a conflict is automatically aborted after paths are captured and restoration is verified.
55
+ - It never fetches or pushes; a conflict is automatically aborted after paths are captured and restoration is verified, unless `keepConflicts: true` leaves it in progress for `conclude_merge`.
54
56
  - BranchMe does not resolve semantic conflicts; handle them in a separate developer workflow.
55
57
 
56
58
  ## Branch retirement
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senad-d/branchme",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "type": "module",
5
5
  "description": "Pi extension for verified Git repository initialization, branch, worktree, integration, retirement, push, and pull request workflows.",
6
6
  "license": "MIT",
@@ -53,13 +53,15 @@ export function getBranchMeHelpText(): string {
53
53
  "- `pull_request_status` — inspect a PR by number, or the latest PR for a head branch; not a CI/review verdict.",
54
54
  "- `pull_request_feedback` — read a PR's unresolved review comments and failing checks with log tails.",
55
55
  "- `reply_pull_request_feedback` — reply to a PR review thread (optionally resolving it) or post a PR comment.",
56
+ "- `pull_request_files` — list a PR's changed files and current labels.",
57
+ "- `label_pull_request` — add existing repository labels to a PR.",
56
58
  "- `pull_request` — reuses an exact matching open PR without changing its title, body, or draft state.",
57
59
  "- `land_branch` — after host merge, clean up from the primary checkout; use `pullRequestNumber` for squash/rebase evidence.",
58
60
  "",
59
61
  "## Branch integration",
60
62
  "",
61
63
  "- `integrate_branch` — integrate an exact local source into an exact local target; the clean control worktree must already have the target checked out.",
62
- "- It never fetches or pushes; a conflict is automatically aborted after paths are captured and restoration is verified.",
64
+ "- It never fetches or pushes; a conflict is automatically aborted after paths are captured and restoration is verified, unless `keepConflicts: true` leaves it in progress for `conclude_merge`.",
63
65
  "- BranchMe does not resolve semantic conflicts; handle them in a separate developer workflow.",
64
66
  "",
65
67
  "## Branch retirement",
package/src/constants.ts CHANGED
@@ -18,6 +18,8 @@ export const PULL_REQUEST_TOOL_NAME = "pull_request";
18
18
  export const PULL_REQUEST_STATUS_TOOL_NAME = "pull_request_status";
19
19
  export const PULL_REQUEST_FEEDBACK_TOOL_NAME = "pull_request_feedback";
20
20
  export const REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME = "reply_pull_request_feedback";
21
+ export const PULL_REQUEST_FILES_TOOL_NAME = "pull_request_files";
22
+ export const LABEL_PULL_REQUEST_TOOL_NAME = "label_pull_request";
21
23
  export const LIST_WORKTREES_TOOL_NAME = "list_worktrees";
22
24
  export const CREATE_WORKTREE_TOOL_NAME = "create_worktree";
23
25
  export const REMOVE_WORKTREE_TOOL_NAME = "remove_worktree";
@@ -47,6 +49,8 @@ export const BRANCHME_TOOL_NAMES = [
47
49
  PULL_REQUEST_STATUS_TOOL_NAME,
48
50
  PULL_REQUEST_FEEDBACK_TOOL_NAME,
49
51
  REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME,
52
+ PULL_REQUEST_FILES_TOOL_NAME,
53
+ LABEL_PULL_REQUEST_TOOL_NAME,
50
54
  LIST_WORKTREES_TOOL_NAME,
51
55
  CREATE_WORKTREE_TOOL_NAME,
52
56
  REMOVE_WORKTREE_TOOL_NAME,
@@ -98,5 +102,6 @@ export const PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS = 2_000;
98
102
  export const PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS = 6_000;
99
103
  export const PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS = 24_000;
100
104
  export const PULL_REQUEST_REPLY_BODY_LIMIT_CHARS = 4_000;
105
+ export const PULL_REQUEST_LABEL_LIMIT = 5;
101
106
  export const GITHUB_GRAPHQL_TIMEOUT_MS = 15_000;
102
107
  export const GITHUB_JOB_LOG_TIMEOUT_MS = 30_000;
@@ -814,6 +814,8 @@ export async function integrateBranch(
814
814
  request,
815
815
  worktreeRoot,
816
816
  signal,
817
+ false,
818
+ request.keepConflicts === true,
817
819
  );
818
820
  return withRepositoryMutationQueue(worktreeRoot, operation);
819
821
  }
@@ -2,7 +2,7 @@ import {
2
2
  GITHUB_API_BASE_URL, GITHUB_GRAPHQL_TIMEOUT_MS, GITHUB_JOB_LOG_TIMEOUT_MS, PULL_REQUEST_FEEDBACK_BODY_LIMIT_CHARS,
3
3
  PULL_REQUEST_FEEDBACK_LOG_JOB_LIMIT, PULL_REQUEST_FEEDBACK_LOG_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_LOG_TAIL_BYTES,
4
4
  PULL_REQUEST_FEEDBACK_LOG_TAIL_LINES, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES, PULL_REQUEST_FEEDBACK_SUMMARY_LIMIT_CHARS,
5
- PULL_REQUEST_REPLY_BODY_LIMIT_CHARS,
5
+ PULL_REQUEST_LABEL_LIMIT, PULL_REQUEST_REPLY_BODY_LIMIT_CHARS,
6
6
  } from "./constants.ts";
7
7
  import {
8
8
  encodePathSegment, fetchGitHubResponse, gitHubJsonHeaders, isRecord, parseGitHubJson, pullRequestNumberField,
@@ -12,7 +12,8 @@ import {
12
12
  import { redactSecrets } from "./redaction.ts";
13
13
  import type {
14
14
  GitHubRepository, PullRequestFeedbackAnnotation, PullRequestFeedbackCheck, PullRequestFeedbackComment, PullRequestFeedbackDetails,
15
- PullRequestFeedbackReview, PullRequestFeedbackThread, PullRequestReplyDetails, PullRequestReplyInput,
15
+ PullRequestFeedbackReview, PullRequestFeedbackThread, PullRequestFilesDetails, PullRequestLabelDetails,
16
+ PullRequestReplyDetails, PullRequestReplyInput,
16
17
  } from "./types.ts";
17
18
 
18
19
  // One GraphQL request reads review threads with their resolution state, review summaries, conversation
@@ -255,9 +256,10 @@ function location(path: string, line: number | null): string {
255
256
  return line === null ? path : `${path}:${line}`;
256
257
  }
257
258
 
258
- // "@alice (you) 2026-10-14T19:50:15Z" — the marker and time decide what is new.
259
+ // "@alice (you) 2026-10-14T19:50:15Z <url>" — the marker and time decide what is new;
260
+ // the URL lets a follow-up issue link the exact comment.
259
261
  function byline(comment: PullRequestFeedbackComment): string {
260
- return [`@${comment.author}`, comment.byViewer ? "(you)" : "", comment.createdAt].filter(Boolean).join(" ");
262
+ return [`@${comment.author}`, comment.byViewer ? "(you)" : "", comment.createdAt, comment.url ? `<${comment.url}>` : ""].filter(Boolean).join(" ");
261
263
  }
262
264
 
263
265
  function idTag(kind: string, id: string): string {
@@ -291,7 +293,7 @@ export function formatPullRequestFeedback(details: PullRequestFeedbackDetails):
291
293
  `Unresolved review threads: ${details.unresolvedThreads.length} (${details.resolvedThreadCount} resolved and ${details.outdatedThreadCount} outdated not listed${omittedThreads}).`,
292
294
  ...details.unresolvedThreads.map(formatThread),
293
295
  `Reviews with a body or requested changes: ${details.reviews.length}.`,
294
- ...details.reviews.map((review) => `- ${byline(review)} ${review.state}: ${review.body}`),
296
+ ...details.reviews.map((review) => `- ${idTag("review", review.id)}${byline(review)} ${review.state}: ${review.body}`),
295
297
  `Conversation comments: ${details.comments.length}${omittedComments}.`,
296
298
  ...details.comments.map((comment) => `- ${idTag("comment", comment.id)}${byline(comment)}: ${comment.body}`),
297
299
  `Failing checks: ${details.failingChecks.length}${omittedChecks}.`,
@@ -392,3 +394,84 @@ export function formatPullRequestReply(details: PullRequestReplyDetails): string
392
394
  if (details.resolved !== null) resolution = details.resolved ? "; thread resolved" : "; thread NOT resolved";
393
395
  return `Replied to thread ${details.threadId} on PR #${details.number}${resolution}: ${details.commentUrl}`;
394
396
  }
397
+
398
+ const PULL_REQUEST_FILES_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
399
+ repository(owner: $owner, name: $repo) {
400
+ pullRequest(number: $number) {
401
+ number
402
+ labels(first: 50) { nodes { name } }
403
+ files(first: 100) { totalCount nodes { path changeType additions deletions } }
404
+ }
405
+ }
406
+ }`;
407
+
408
+ function requirePullRequestNode(payload: Record<string, unknown>, repository: GitHubRepository, number: number): Record<string, unknown> {
409
+ const pullRequest = isRecord(payload.data) && isRecord(payload.data.repository) ? payload.data.repository.pullRequest : null;
410
+ if (!isRecord(pullRequest) || pullRequest.number !== number) {
411
+ throw new Error(`Pull request #${number} was not found in ${repository.owner}/${repository.repo}.`);
412
+ }
413
+ return pullRequest;
414
+ }
415
+
416
+ /** The first 100 changed files of a pull request and its current labels. */
417
+ export async function getPullRequestFiles(repository: GitHubRepository, number: number, token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestFilesDetails> {
418
+ validateGitHubRepository(repository);
419
+ pullRequestNumberField(number);
420
+ const payload = await postGraphQL(PULL_REQUEST_FILES_QUERY, { owner: repository.owner, repo: repository.repo, number }, token, options, "GitHub pull request files");
421
+ const pullRequest = requirePullRequestNode(payload, repository, number);
422
+ const files = nodes(pullRequest.files).map((file) => ({
423
+ path: safeText(file.path, token, 500),
424
+ change: safeText(file.changeType, token, 20).toLowerCase(),
425
+ additions: typeof file.additions === "number" ? file.additions : 0,
426
+ deletions: typeof file.deletions === "number" ? file.deletions : 0,
427
+ }));
428
+ return {
429
+ action: "pull_request_files",
430
+ repository,
431
+ number,
432
+ labels: nodes(pullRequest.labels).map((label) => safeText(label.name, token, 100)).filter(Boolean),
433
+ files,
434
+ omitted: Math.max(0, totalCount(pullRequest.files) - files.length),
435
+ };
436
+ }
437
+
438
+ export function formatPullRequestFiles(details: PullRequestFilesDetails): string {
439
+ const omitted = details.omitted > 0 ? `; ${details.omitted} more not listed` : "";
440
+ return [
441
+ `PR #${details.number}: ${details.files.length} changed file(s)${omitted}. Labels: ${details.labels.join(", ") || "none"}.`,
442
+ ...details.files.map((file) => `- ${file.path} (${file.change}, +${file.additions} -${file.deletions})`),
443
+ ].join("\n");
444
+ }
445
+
446
+ async function requireExistingLabel(repository: GitHubRepository, label: string, token: string, options: PullRequestFetchOptions): Promise<void> {
447
+ const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/labels/${encodePathSegment(label)}`;
448
+ const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), url, {
449
+ method: "GET", headers: gitHubJsonHeaders(token), signal: options.signal,
450
+ }, "GitHub label lookup failed", token);
451
+ if (response.status === 404) throw new Error(`Label "${label}" does not exist in ${repository.owner}/${repository.repo}; label_pull_request adds existing labels only.`);
452
+ if (!response.ok) throw new Error(`GitHub label lookup returned HTTP ${response.status}.`);
453
+ }
454
+
455
+ /** Adds existing repository labels to a pull request; GitHub would create unknown ones, so they are refused first. */
456
+ export async function labelPullRequest(repository: GitHubRepository, number: number, labels: readonly string[], token: string, options: PullRequestFetchOptions = {}): Promise<PullRequestLabelDetails> {
457
+ validateGitHubRepository(repository);
458
+ pullRequestNumberField(number);
459
+ const wanted = [...new Set(labels.map((label) => label.trim()).filter(Boolean))];
460
+ if (wanted.length === 0 || wanted.length > PULL_REQUEST_LABEL_LIMIT) throw new Error(`labels must name 1 to ${PULL_REQUEST_LABEL_LIMIT} labels.`);
461
+ const found = await postGraphQL(PULL_REQUEST_EXISTS_QUERY, { owner: repository.owner, repo: repository.repo, number }, token, options, "GitHub pull request");
462
+ requirePullRequestNode(found, repository, number);
463
+ await Promise.all(wanted.map((label) => requireExistingLabel(repository, label, token, options)));
464
+ const url = `${GITHUB_API_BASE_URL}/repos/${encodePathSegment(repository.owner)}/${encodePathSegment(repository.repo)}/issues/${number}/labels`;
465
+ const response = await fetchGitHubResponse(requireFetchImplementation(options.fetchImpl ?? globalThis.fetch), url, {
466
+ method: "POST", headers: gitHubJsonHeaders(token), body: JSON.stringify({ labels: wanted }), signal: options.signal,
467
+ }, "GitHub pull request labeling failed", token);
468
+ if (!response.ok) throw new Error(`GitHub pull request labeling returned HTTP ${response.status}.`);
469
+ const text = await readBoundedResponseText(response, options.signal, PULL_REQUEST_FEEDBACK_RESPONSE_LIMIT_BYTES);
470
+ const payload: unknown = parseGitHubJson(text.text, "GitHub pull request labels response", token);
471
+ const after = Array.isArray(payload) ? payload.filter(isRecord).map((label) => safeText(label.name, token, 100)).filter(Boolean) : [];
472
+ return { action: "label_pull_request", repository, number, added: wanted, labels: after };
473
+ }
474
+
475
+ export function formatPullRequestLabel(details: PullRequestLabelDetails): string {
476
+ return `Labeled PR #${details.number} with ${details.added.join(", ")}; labels now: ${details.labels.join(", ") || "none"}.`;
477
+ }
@@ -133,6 +133,7 @@ const IntegrateBranchParametersSchema = Type.Object(
133
133
  {
134
134
  sourceBranch: Type.String({ minLength: 1, description: "Exact existing local source branch to integrate." }),
135
135
  targetBranch: Type.String({ minLength: 1, description: "Exact existing local target branch already checked out in the clean control worktree." }),
136
+ keepConflicts: Type.Optional(Type.Boolean({ description: "When true, a conflicted merge is left in progress (MERGE_HEAD set, conflicted paths unmerged) for conclude_merge instead of being aborted; defaults to false." })),
136
137
  },
137
138
  { additionalProperties: false },
138
139
  );
@@ -772,15 +773,16 @@ export function registerBranchMeTools(host: Pick<ExtensionAPI, "registerTool" |
772
773
  pi.registerTool({
773
774
  name: INTEGRATE_BRANCH_TOOL_NAME,
774
775
  label: "Integrate Branch",
775
- description: "integrate_branch verifies and merges one exact existing local source branch into one exact existing local target branch from the current clean control worktree, returning an already-integrated, fast-forward, merge-commit, or automatically aborted conflict result. integrate_branch never fetches or pushes.",
776
- promptSnippet: "integrate_branch: merge an exact local source branch into the checked-out clean local target with verified outcomes and automatic conflict abort",
776
+ description: "integrate_branch verifies and merges one exact existing local source branch into one exact existing local target branch from the current clean control worktree, returning an already-integrated, fast-forward, merge-commit, or conflict result. A conflict is automatically aborted with restoration verified unless keepConflicts is true, which leaves the conflicted merge in progress (conflict_kept) for conclude_merge. integrate_branch never fetches or pushes.",
777
+ promptSnippet: "integrate_branch: merge an exact local source branch into the checked-out clean local target with verified outcomes and automatic conflict abort; keepConflicts keeps a conflicted merge in progress",
777
778
  promptGuidelines: [
778
779
  "Use integrate_branch only when the user explicitly wants one exact existing local source branch integrated into one exact existing local target branch.",
779
780
  "Before integrate_branch, require the current clean control worktree to already have targetBranch checked out; integrate_branch never switches branches, stashes, or discards changes.",
780
781
  "Use integrate_branch only after local refs already contain the commits to integrate; integrate_branch never fetches or pushes.",
781
782
  "Call integrate_branch by itself and wait for it to complete; never batch integrate_branch with other Git mutations.",
782
783
  "After integrate_branch completes, run any targeted branch_status ancestry proof separately; never place integrate_branch and branch_status in the same parallel tool batch.",
783
- "A conflict result from integrate_branch means the merge was automatically aborted and restoration was verified; semantic conflict analysis belongs to a separate delegated workflow, not integrate_branch.",
784
+ "A conflict result from integrate_branch means the merge was automatically aborted and restoration was verified unless keepConflicts is true; semantic conflict analysis belongs to a separate delegated workflow, not integrate_branch.",
785
+ "Pass keepConflicts: true to integrate_branch only when a file-editing step will resolve the listed paths; the result conflict_kept leaves the merge in progress with MERGE_HEAD set, and conclude_merge must then conclude or abort it before any other Git mutation.",
784
786
  ],
785
787
  parameters: IntegrateBranchParametersSchema,
786
788
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -3,12 +3,16 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
  import {
5
5
  CONCLUDE_MERGE_TOOL_NAME, GIT_CONTEXT_VALUE_LIMIT_CHARS, PULL_REQUEST_FEEDBACK_TOOL_NAME, PULL_REQUEST_REPLY_BODY_LIMIT_CHARS,
6
- PULL_REQUEST_STATUS_TOOL_NAME, REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME, TRACK_BRANCH_TOOL_NAME, UPDATE_FROM_BASE_TOOL_NAME,
6
+ LABEL_PULL_REQUEST_TOOL_NAME, PULL_REQUEST_FILES_TOOL_NAME, PULL_REQUEST_LABEL_LIMIT, PULL_REQUEST_STATUS_TOOL_NAME,
7
+ REPLY_PULL_REQUEST_FEEDBACK_TOOL_NAME, TRACK_BRANCH_TOOL_NAME, UPDATE_FROM_BASE_TOOL_NAME,
7
8
  } from "../constants.ts";
8
9
  import { getGitRoot, requireCurrentBranch, safeWorktreeValue } from "../git.ts";
9
10
  import { concludeMerge, formatConflictPathList } from "../git-integration.ts";
10
11
  import { findGitHubPullRequest, getGitHubPullRequest, resolveGitHubRepository, resolveGitHubToken } from "../github.ts";
11
- import { formatPullRequestFeedback, formatPullRequestReply, getPullRequestFeedback, replyPullRequestFeedback } from "../github-feedback.ts";
12
+ import {
13
+ formatPullRequestFeedback, formatPullRequestFiles, formatPullRequestLabel, formatPullRequestReply, getPullRequestFeedback,
14
+ getPullRequestFiles, labelPullRequest, replyPullRequestFeedback,
15
+ } from "../github-feedback.ts";
12
16
  import type { ConcludeMergeDetails, PullRequestStatusDetails } from "../types.ts";
13
17
  import type { BranchMeToolOptions } from "./branchme-tools.ts";
14
18
  import { trackBranch, updateFromBase, type UpdateFromBaseDetails } from "../git-workflow.ts";
@@ -146,6 +150,47 @@ export function registerWorkflowTools(pi: Pick<ExtensionAPI, "registerTool" | "e
146
150
  return { content: [{ type: "text", text: formatPullRequestReply(details) }], details };
147
151
  },
148
152
  });
153
+ pi.registerTool({
154
+ name: PULL_REQUEST_FILES_TOOL_NAME,
155
+ label: "Pull Request Files",
156
+ description: "pull_request_files lists the changed files of a pull request of this GitHub repository (path, change type, additions, deletions; the first 100, with a count of the rest) and its current labels. No mutations.",
157
+ promptSnippet: "pull_request_files: list a PR's changed files and current labels",
158
+ promptGuidelines: [
159
+ "Use pull_request_files to see which areas of the repository a pull request touches, for example before choosing its labels.",
160
+ "pull_request_files never reads file contents or diffs, and never changes the pull request.",
161
+ ],
162
+ parameters: Type.Object({
163
+ number: Type.Integer({ minimum: 1, description: "PR number." }),
164
+ }, { additionalProperties: false }),
165
+ async execute(_id, params, signal, _update, ctx) {
166
+ const rootCtx = { cwd: await getGitRoot(pi, ctx, signal) };
167
+ const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
168
+ const token = (await resolveGitHubToken(options.env, { cwd: rootCtx.cwd, signal })).token;
169
+ const details = await getPullRequestFiles(repository, params.number, token, { fetchImpl: options.fetchImpl, signal });
170
+ return { content: [{ type: "text", text: formatPullRequestFiles(details) }], details };
171
+ },
172
+ });
173
+ pi.registerTool({
174
+ name: LABEL_PULL_REQUEST_TOOL_NAME,
175
+ label: "Label Pull Request",
176
+ description: "label_pull_request adds labels that already exist in this GitHub repository to one of its pull requests (1 to 5 per call). A label that does not exist is refused before anything changes; existing labels on the pull request stay.",
177
+ promptSnippet: "label_pull_request: add existing repository labels to a PR",
178
+ promptGuidelines: [
179
+ "Use label_pull_request only with label names the assignment or the repository's labels name exactly; it never creates, renames or removes labels.",
180
+ "label_pull_request adds labels and keeps the pull request's existing ones.",
181
+ ],
182
+ parameters: Type.Object({
183
+ number: Type.Integer({ minimum: 1, description: "PR number." }),
184
+ labels: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: PULL_REQUEST_LABEL_LIMIT, description: "Exact names of existing labels." }),
185
+ }, { additionalProperties: false }),
186
+ async execute(_id, params, signal, _update, ctx) {
187
+ const rootCtx = { cwd: await getGitRoot(pi, ctx, signal) };
188
+ const repository = await resolveGitHubRepository(pi, rootCtx, signal, options.env);
189
+ const token = (await resolveGitHubToken(options.env, { cwd: rootCtx.cwd, signal })).token;
190
+ const details = await labelPullRequest(repository, params.number, params.labels, token, { fetchImpl: options.fetchImpl, signal });
191
+ return { content: [{ type: "text", text: formatPullRequestLabel(details) }], details };
192
+ },
193
+ });
149
194
  pi.registerTool({
150
195
  name: UPDATE_FROM_BASE_TOOL_NAME,
151
196
  label: "Update From Base",
@@ -170,10 +215,10 @@ export function registerWorkflowTools(pi: Pick<ExtensionAPI, "registerTool" | "e
170
215
  pi.registerTool({
171
216
  name: CONCLUDE_MERGE_TOOL_NAME,
172
217
  label: "Conclude Merge",
173
- description: "conclude_merge finishes or abandons the in-progress merge on the current checkout, normally one kept by update_from_base with keepConflicts: true. action \"conclude\" checks unmerged working-tree paths and staged blobs for default/custom-sized conflict marker lines, stages exactly the unmerged paths, checks the candidate index again, and commits the full index with Git's prepared merge message, then verifies the two-parent result; action \"abort\" runs git merge --abort and verifies restoration. conclude_merge never stages other changes, accepts no commit message, and never pushes.",
218
+ description: "conclude_merge finishes or abandons the in-progress merge on the current checkout, normally one kept by update_from_base or integrate_branch with keepConflicts: true. action \"conclude\" checks unmerged working-tree paths and staged blobs for default/custom-sized conflict marker lines, stages exactly the unmerged paths, checks the candidate index again, and commits the full index with Git's prepared merge message, then verifies the two-parent result; action \"abort\" runs git merge --abort and verifies restoration. conclude_merge never stages other changes, accepts no commit message, and never pushes.",
174
219
  promptSnippet: "conclude_merge: commit or abort the kept in-progress merge after conflict markers are removed",
175
220
  promptGuidelines: [
176
- "Use conclude_merge only after update_from_base reported conflict_kept, or when a single-head merge is otherwise in progress on the current checkout; run conclude_merge by itself. Multi-head merges are refused before mutation.",
221
+ "Use conclude_merge only after update_from_base or integrate_branch reported conflict_kept, or when a single-head merge is otherwise in progress on the current checkout; run conclude_merge by itself. Multi-head merges are refused before mutation.",
177
222
  "Before conclude_merge with action conclude, a file-editing step must remove every <<<<<<<, |||||||, =======, and >>>>>>> marker line from the listed paths; conclude_merge refuses and names the paths that still contain markers.",
178
223
  "conclude_merge with action conclude stages only the currently unmerged paths and uses Git's prepared merge message; unrelated unstaged edits stay unstaged and are not part of the merge commit. Git commits the entire index, including previously staged entries: do not stage unrelated edits during the merge.",
179
224
  "conclude_merge with action abort restores the branch with git merge --abort and verifies a clean idle checkout; conclude_merge refuses when no merge is in progress.",
package/src/types.ts CHANGED
@@ -86,6 +86,8 @@ export interface InitRepositoryDetails {
86
86
  export interface IntegrateBranchToolInput {
87
87
  sourceBranch: string;
88
88
  targetBranch: string;
89
+ /** Leave a conflicted merge in progress for conclude_merge instead of aborting it. */
90
+ keepConflicts?: boolean;
89
91
  }
90
92
 
91
93
  export interface RetireBranchToolInput {
@@ -440,7 +442,7 @@ export interface IntegrateBranchConflictDetails extends IntegrateBranchDetailsBa
440
442
  };
441
443
  }
442
444
 
443
- /** A conflicted merge deliberately left in progress (update_from_base keepConflicts); conclude_merge finishes or abandons it. */
445
+ /** A conflicted merge deliberately left in progress (integrate_branch or update_from_base keepConflicts); conclude_merge finishes or abandons it. */
444
446
  export interface IntegrateBranchConflictKeptDetails {
445
447
  action: "integrate_branch";
446
448
  status: "conflict_kept";
@@ -660,6 +662,24 @@ export interface PullRequestFeedbackDetails {
660
662
  omitted: { threads: number; comments: number; checks: number };
661
663
  }
662
664
 
665
+ export interface PullRequestFilesDetails {
666
+ action: "pull_request_files";
667
+ repository: GitHubRepository;
668
+ number: number;
669
+ labels: string[];
670
+ files: { path: string; change: string; additions: number; deletions: number }[];
671
+ omitted: number;
672
+ }
673
+
674
+ export interface PullRequestLabelDetails {
675
+ action: "label_pull_request";
676
+ repository: GitHubRepository;
677
+ number: number;
678
+ added: string[];
679
+ /** Every label on the pull request after the call. */
680
+ labels: string[];
681
+ }
682
+
663
683
  export interface PullRequestReplyInput {
664
684
  number: number;
665
685
  body: string;