feat(discord): writes on write-marked roots, web fetch and search, held prompts (#1509)

Row 23. write_file and edit_file for roots marked write: true under the
same fence as reads; web_fetch (https only, public addresses, pinned
connection, capped body) and web_search through SearXNG; extension
renamed to tools.mjs. Engine holds a prompt while pi is busy and sends
it as its own run, so a second message mid-turn no longer folds into
the first (live defect). fake-pi models the real follow-up folding.

Suite 52/52, node tests 129. rev-code-02 APPROVED round 3, comment
26362, tree dbd2ce9a. Records: QUEUE rows 23-24, CURRENT, BUILD-LOG
phase, SESSIONS, row 24 brief (git verbs, D5-D7 ruled).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-18 07:27:50 -05:00
co-authored by Claude Fable 5.1
parent 1ac812d3d5
commit 1685deb423
24 changed files with 1519 additions and 113 deletions
+134
View File
@@ -0,0 +1,134 @@
# 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 the named paths (or every
change under the root when omitted), refuses a dot-prefixed path or a
path outside the root, 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: the connector already runs with `MOSAIC_AGENT_NAME=sage`.
The engine adds `MOSAIC_GIT_IDENTITY=sage` to the child environment and
the repository's existing helper (`git-credential-mosaic`) resolves the
seat's GitHub token at push time. The token is never read, printed,
journaled or passed as an argument; a push failure returns git's message
with any `https://…@` form masked.
Binding (`tools` key, fixed):
```json
{ "name": "shared-signals", "path": "…/shared-signals", "write": true,
"git": { "branch": "main", "identity": "sage" } }
```
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.