Files
stack/docs/plans/2026-09-16_discord-git-tools.md
T
jason.woltjeandClaude Fable 5.1 1949ed8d31 feat(discord): git verbs for the Discord Sage on the shared-signals root, seat identity through a package credential helper, vault record protocol (#1509)
Row 24. A writable root that is a git work tree may carry a git object in
the binding; the seat then has git_status, git_commit (explicit paths, seat
author, Requested-by trailer from the envelope requester, push at once per
D6), git_pull (ff-only) and git_push (one branch, never force), plus
reserve_id and per-write clone locks under protocol vault. Git children run
with no host config and one credential helper, bin/git-credential.mjs,
reading the 0600 seat token file named in the binding; the fleet helper
serves only the Gitea hosts. Suite 58/58, node 143. rev-code-02 APPROVED
round 1 (#1509 comment 26375, tree 82ab962f).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-18 07:52:35 -05:00

153 lines
7.6 KiB
Markdown

# 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 <[email protected]>` 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 <current branch>`. A
non-fast-forward result is refused with the reason and nothing is
merged or rebased.
- `git_push(root)`: `git push origin <current branch>`, 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 <[email protected]>`) 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 <[email protected]>", "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 <[email protected]>`. 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.