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

7.6 KiB

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:

{ "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.