# Discord Sage: git for the strategy repository (#1509, QUEUE row 24) Jason's word, 2026-09-16, after the first write from Discord landed: "Sage will need to have git tooling to commit, push, pull, etc. for the shared-signals repo." This replaces the row 23 rule that Jason commits from the terminal. ## 1. Outcome A decision reached in Discord ends up committed and pushed to `shared-signals` on GitHub, by Sage, in the same conversation, with the commit and push in the turn record. Sage still has no shell and no git anywhere else. ## 2. What is built Four fixed verbs in the extension, each a `git` child process with a fixed argument list, run only in a root that is marked `"write": true` and is a git work tree. No verb takes free-form arguments. - `git_status(root)`: branch, ahead/behind, changed paths. Read only. - `git_commit(root, message, paths)`: stages exactly the named paths (one to fifty; the "every change when omitted" form of the first draft was dropped for section 6's explicit-path rule, since the root is Jason's own clone), refuses a dot-prefixed path or a path outside the root, refuses when the index already holds staged work, refuses an empty message or one over 500 characters, refuses when nothing is staged, commits with author `Sage ` and a trailer naming the Discord author by role (`Requested-by: Jason` or `Carmen`, never an id). Returns the short hash. - `git_pull(root)`: `git pull --ff-only origin `. A non-fast-forward result is refused with the reason and nothing is merged or rebased. - `git_push(root)`: `git push origin `, current branch only, never `--force`, never a tag, never another remote. Fences shared by all four: the root's work tree must be clean of conflicts and not mid-merge or mid-rebase; the current branch must be the one the binding names (`"branch": "main"`), so a detached head or another branch refuses; 60 s timeout; stdout and stderr are captured, trimmed to 4 KiB and returned as data; exit codes become fixed refusals. Credentials, as built (2026-09-18; the draft above said the fleet helper would serve): `~/.mosaic/tools/git/git-credential-mosaic` answers only the two Gitea hosts and exits silently for github.com, and the host's global git config sends github.com to Jason's own `gh` login. Neither is acceptable for Sage, so the verbs run git with `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, no askpass, no prompt, and one helper set through `GIT_CONFIG_COUNT`: the package's `bin/git-credential.mjs`, which answers `get` over https from the 0600 file the binding names in `tokenFile`. The connector checks the file's mode at load and never reads it; the path reaches git only for push, pull and reserve, through the environment, never as an argument. The token is never read by the connector, printed, journaled or passed as an argument; a push failure returns git's message with any `https://…@` form and token shape masked. The D5 identity (seat token, `Sage `) is unchanged; only the mechanism that presents it differs from the draft. The requester in the trailer is the Discord author's server name, which the connector now writes into the envelope line (`requester="…"`) and the extension reads on `before_agent_start`. A turn without a requester cannot commit. Binding (`tools` key, fixed), as built: ```json { "name": "shared-signals", "path": "…/shared-signals", "write": true, "git": { "branch": "main", "identity": "sage", "tokenFile": "…/secrets/github-jetrich-sage.token", "author": "Sage ", "protocol": "vault" } } ``` Without `git` on a root, no git verbs are offered for it. The prompt paragraph says which root has git, that a commit is real once pushed, and that Sage reports the hash. ## 3. Not built No shell, no arbitrary git arguments, no branches, no force, no tags, no other remotes, no rebase, no reset, no history rewriting, no git in the Mosaic roots. A pull that needs a merge stops and says so; Jason resolves it from the terminal. ## 4. Evidence - `packages/discord/tests/git.test.mjs` against a local bare remote: status, commit with the trailer, pull ff-only, push; refusals for an empty message, a dot path, a path outside the root, a non-ff pull, a detached head, another branch, a conflicted tree; no token in any argument list or output. - Suite: the extension exposes the four verbs only with a `git` key. - Live: Jason asks in #ideas for a decision to be written, committed and pushed; the GitHub commit shows Sage as author and the trailer; the turn record shows write, commit and push. ## 5. Rulings from Jason (2026-09-16, evening) - D5. Identity: the sage seat's GitHub token (`github-jetrich-sage` in the seat's secrets, resolved by the existing helper), author `Sage `. Ruled: "That email works is acceptable." - D6. Push every time. Ruled against the recommendation to push only on request: "This will be fatal. Failure to automatically push will result in stale data." So every commit pushes at once; a commit whose push fails is reported as such in the reply and the turn record, and the next commit retries the push. - D7. Reviewer: rev-code-02 on #1509. Ruled: "agree". ## 6. Shared-signals record protocol (briefed 2026-09-17 by shared-signals-05) Verified against `origin/main` of `jetrich/shared-signals` (tooling landed in `8f0d946`; main at `7aa88ad` on 2026-09-17): `tools/vault_lock.py`, `tools/validate_vault.py`, `docs/ID-REGISTRY.txt`, `docs/RECORDS.md` "Reserving IDs and locking files", AGENTS.md step 4. A record's id must be reserved in the registry before the file exists, the registry is append-only and committed, `validate_vault.py` fails a commit whose ids are not registered or are used twice, and a file being edited for more than a moment is locked per clone under `.vault-locks/` (gitignored, fcntl, default TTL 3600 s). Owner comes from `VAULT_LOCK_OWNER`, then `MOSAIC_AGENT_NAME`, then git user.name; the connector already sets `MOSAIC_AGENT_NAME` to the seat, so locks read "sage" with no change. What this means for the verbs, since Sage never gets a shell: - `git_commit` runs `python3 tools/validate_vault.py` in the root first and refuses the commit, with the validator's first lines, when it fails. It also runs `vault_lock.py check` on the staged paths and refuses when another owner holds one. - A new fixed tool `reserve_id` (prefix BUS, PRJ, SS, DEC or REF plus a title) runs `vault_lock.py reserve` and returns the id; the registry line it appends is staged with the record in the next commit. `write_file` of a new record without a reserved id is not blocked by the connector; the validator catches it at commit, and the prompt tells Sage to reserve first. - `write_file` and `edit_file` take the clone lock for the path around the write (`lock`, write, `unlock`), so a terminal contributor on the same clone sees the claim. A live lock held by another owner refuses the write with that owner's name in the reply. - Staging is by explicit path only: Sage's changed records plus the registry. Jason's own uncommitted files in the same clone (the write root is his clone) are never swept in. `git_pull` is ff-only and refuses when the paths it would touch are dirty. - Push after every commit (D6). A push that fails is said in the reply and retried by the next commit. These scripts are Python in the shared-signals repo, not Mosaic code; the connector calls them as fixed argv, never through a shell, and only inside the root marked writable. ## 7. Order After row 23's web tools are reviewed. Git verbs, tests, suite, review, local commit, then the binding change and a live check.