Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ Machine `gap sync` writes `global/AGENTS.md` between `<!-- gap:begin -->` and `<
- `~/.codex/gap/global-agent-policy.md` (`CODEX_HOME` when set). SessionStart and SubagentStart hooks print that file. Sync leaves old policy text in `~/.codex/AGENTS.md`, so Codex loads the policy twice until `gap sync --remove-codex-legacy`. Run the flag only after both hooks are trusted in `/hooks`; it refuses unless both were in `hooks.json` before that run. Empty `CODEX_HOME` means unset. A relative `CODEX_HOME` is refused.
- `~/.cursor/rules/global-agent-policy.mdc`
- `~/.empryo/EMPRYO.md`. Empryo loads it into every tab. SoulStack keeps its own marked block there, so the first sync is a foreign-file splice and needs a terminal yes.
- `~/.jcode/prompt-overlay.md` (`$JCODE_HOME/prompt-overlay.md` when set). jcode reads it into every new session. Empty `JCODE_HOME` means unset. A relative `JCODE_HOME` is refused.
- `~/.omp/agent/commands/<name>.md` for each `global/commands/<name>.md`
- `~/.claude/commands/<name>.md` for the same file. Claude Code loads that directory as user slash commands. `$ARGUMENTS` and `argument-hint` are Claude's own syntax, so the file is copied unchanged.
- `~/.pi/agent/prompts/<name>.md` for the same file. Pi loads that directory as prompt templates and expands `$ARGUMENTS` (proved 2026-11-12 with a probe template under Pi 1.5.2).

The destinations come from the `harnesses` table in `cmd/gap/harness.go`. Codex, Cursor, and Empryo have no command destination: Codex 0.167 no longer reads custom prompts, and Cursor's and Empryo's command folders are unproven.
The destinations come from the `harnesses` table in `cmd/gap/harness.go`. Codex, Cursor, Empryo, and jcode have no command destination: Codex 0.167 no longer reads custom prompts, and the others' command folders are unproven.

The CLI writes regular files. Do not symlink those paths. Do not add Stow. Publish only from the primary branch (`origin/HEAD`, `master` here) or GitButler's `gitbutler/workspace`, not from another branch or a worktree. Sync writes the policy between `<!-- gap:begin -->` and `<!-- gap:end -->`. A file that already has those markers is updated in place. Only the block changes, and the rest stays byte for byte. A foreign policy file with no markers stays unless a terminal answers yes. Yes backs that file up under `~/.gap/backups/` and appends the block, so the harness keeps loading the rest. No prompt leaves that file alone and the other destinations still publish. A symlink is still moved aside after a yes. That harness stops loading the old target. The backup keeps those bytes only. Command files are whole-file copies. A foreign command file is replaced after a yes. That harness stops loading the old file. The backup keeps the bytes only. Empty `CODEX_HOME` means unset. A relative `CODEX_HOME` is refused. Grok already loads `~/.claude/rules/global-agent-policy.md` when Claude compatibility is on. Confirm with `grok inspect` before adding a Grok destination. A second copy loads the policy twice. The human note is in `README.md`.
Do not point `~/CLAUDE.md` at the shared file. An ancestor `CLAUDE.md` suppresses that project's `AGENTS.md`.
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ To uninstall, run:
gap uninstall
```

It previews what it will remove and asks on a terminal (`--yes` skips the question, `--dry-run` only previews). Without a terminal, `--yes` is required. Use the same `CODEX_HOME` you published with. It removes every utility the way [gap remove](#add-a-utility) does, the MCP servers and commands recorded for components, GAP policy blocks, the GAP Codex hooks, and published global commands that still match `~/.gap/published.json`. Text outside marked policy blocks stays byte for byte; files left with only whitespace or Cursor front matter are removed. An unmarked policy file is removed when its whole-file SHA-256 matches the ledger. A ledger-verified prefix, including a legacy Codex GAP prefix, is stripped while preserving the text after it; the file is removed if only whitespace remains. Other policy files and unmatched global commands stay and are listed.
It previews what it will remove and asks on a terminal (`--yes` skips the question, `--dry-run` only previews). Without a terminal, `--yes` is required. Use the same `CODEX_HOME` and `JCODE_HOME` you published with. It removes every utility the way [gap remove](#add-a-utility) does, the MCP servers and commands recorded for components, GAP policy blocks, the GAP Codex hooks, and published global commands that still match `~/.gap/published.json`. Text outside marked policy blocks stays byte for byte; files left with only whitespace or Cursor front matter are removed. An unmarked policy file is removed when its whole-file SHA-256 matches the ledger. A ledger-verified prefix, including a legacy Codex GAP prefix, is stripped while preserving the text after it; the file is removed if only whitespace remains. Other policy files and unmatched global commands stay and are listed.

Skills are removed from both `~/.agents/skills/` and `~/.claude/skills/` when recorded in a component's `skills` or `skillCopies`, or selected by `global/skills.json` with a matching source in the skills.sh lock. An unreadable or malformed skills lock stops discovery before removal. Policy, command, skill, and Codex hook backups go under `~/.gap/backups/<time>/`; utility removals can use separate timestamped folders. Before removing utilities, GAP saves their skill and command ownership in `~/.gap/state.json` so cleanup can resume after an interruption. After cleanup succeeds, it deletes `~/.gap/store/`, `~/.gap/state.json`, and `~/.gap/published.json`. A failed cleanup exits `1` and keeps the records for a retry.

Expand Down Expand Up @@ -150,7 +150,7 @@ Empryo reads `~/.empryo/EMPRYO.md`, which already holds SoulStack's block. The f

Empryo injects every skill in `skills.autoLoad` in full into every prompt. GAP recommends three: `do-todo` (the policy tracks tasks every turn), `do-commit` and `but-axi` (the GitButler flow). Everything else loads by name when a task needs it. Until your pins match, `gap sync` prints the exact `settings` call. Ask an agent inside Empryo to run it. gap never changes Empryo's settings; it only writes its block in `EMPRYO.md`.

`gap sync` also warns when `~/.empryo/skills/<name>` differs from a managed skill. Empryo loads that copy first, so the managed one never runs there.
`gap sync` also warns when `~/.empryo/skills/<name>` differs from a managed skill. Empryo loads that copy first, so the managed one never runs there. For jcode's additional skill-copy warning and its home override, see the [drift-check contract](cmd/gap/AGENTS.md#sync-steps).

## Manage skills

Expand Down Expand Up @@ -239,7 +239,7 @@ After selection, terminal output shows a framed plan, phase headers, progress li

```
gap sync will:
policy publish AGENTS.md to 6 harness files and 1 command
policy publish AGENTS.md to 7 harness files and 1 command
do-skills 66 skills @ master (abc1234)
echo run its installer @ v0.13.0 (def5678)
manifest 15 skills from 12 sources in global/skills.json (skills.sh, needs npx)
Expand Down Expand Up @@ -364,6 +364,7 @@ GAP keeps no skill source. [Do-Skills](https://github.com/edheltzel/Do-Skills#re
| Codex | `~/.codex/gap/global-agent-policy.md` (`$CODEX_HOME/gap/global-agent-policy.md` when nonempty), printed by the SessionStart and SubagentStart hooks | none |
| Cursor | `~/.cursor/rules/global-agent-policy.mdc` | none |
| Empryo | `~/.empryo/EMPRYO.md`, beside the SoulStack block. The first sync asks once, so run it from a terminal. | none |
| jcode | `~/.jcode/prompt-overlay.md` (`$JCODE_HOME/prompt-overlay.md` when nonempty; must be absolute) | none |
| Grok | No file of its own. It reads the Claude copy. | none |

### How sync tells its own files apart
Expand Down
3 changes: 2 additions & 1 deletion cmd/gap/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The setup breadcrumb contract, including the crew exception, and the Echo adapte
7. Read `global/skills.json`; the effective `gap.toml` and `gap.lock` were resolved before publication. A missing `gap.toml` is an empty distro. Schema must be `"2"`. `~/.config/gap/gap.toml` may add a component (a table with `repo` whose id the distro does not use); its pin comes from `~/.config/gap/gap.lock`, and a missing pin names `gap update <id>`. A project or profile file that adds one, or a user table with `repo` on a distro id, is refused. Every selected component needs a lock entry or sync stops before publication. Sync never moves pins. After selection and before publication, each pin is fetched into `~/.gap/store/<id>/<commit>` (reused, with no network, when its on-disk source tree matches the lock). The store keeps git's file modes, so installed copies stay writable and executable bits survive. It is trusted only after its worktree tree id matches the lock, checked on every use with a temporary Git index and object directory. Seed that index from `HEAD`, then stage changes without forcing ignored files. This ignores untracked build output while still detecting tracked edits, deletions, and mode changes. The store's real index and objects stay untouched. `.git` stays so the tree can be checked again. A component skill's baseline is that store path, independent of the skills.sh lock. A mismatched tree is an error and is not installed. When the verified store has a `gap-utility.toml` (`manifest.go`), it supplies the [installation declarations](../../README.md#publish-a-utility); `names` narrows its skills, and `install` or `doctor` in the spec beside a manifest is an error. A distro store without one keeps the spec's `names`, `install`, and `doctor`; user utilities require a manifest. Manifest source `"."` is this checkout. Any other manifest source is `owner/repo`, optionally with a subpath. A component repo is `github.com/owner/repo` (clone URL `https://github.com/owner/repo.git`), a `file://` URL, or an absolute path.
8. Install selected skills as defined in [Manage skills](../../README.md#manage-skills), including load levels, destinations, component-owned installers, and runtime requirements. A component's install hook (its update hook after a first install) runs only when `installed` in `~/.gap/state.json` differs from the pinned commit. Successful hooks are recorded before the next component starts; a successful declarative install also enables later update hooks. A user component's hook first passes `trust.go`: a `[trust]` match runs; otherwise a terminal asks `[y/N]` for that execution and no terminal skips the hook and exits 1. Distro components are trusted. Each manifest `commands` file publishes like `global/commands`, skipping a destination that already holds the same bytes under its ledger digest; command ownership is checked against all registrations and outstanding copies before any publication. Each placed copy adds its ownership to `state.json`, retaining earlier home skill names and command paths until removal; removal follows [Add a utility](../../README.md#add-a-utility). User utility skills cannot use project load. Add and update verify both skill destinations exist and match the source hash. That hash skips `node_modules`, `.git`, `__pycache__`, `__pypackages__`, and `metadata.json` on both the source and the installed copy. Skills selected by neither the manifest nor a component are never touched.
9. Manifest source-identity migration. The skills.sh lock file is `$XDG_STATE_HOME/skills/.skill-lock.json` when `XDG_STATE_HOME` is set, otherwise `~/.agents/.skill-lock.json`. An entry whose source is not the manifest source is foreign. `"."` matches the absolute checkout path. `owner/repo`, a github.com URL, and a subpath are one identity. A missing or unreadable lock file is not a mismatch. Automatic migration runs only when both copies match the recorded hash (40-hex git tree or 64-hex skills.sh SHA-256). It backs up both destinations under `~/.gap/backups/` and reinstalls from the manifest. A failed migration restores those destinations and the previous lock entry. A missing or unverifiable hash, or an edited copy, goes to drift review and is not replaced without a choice. Component entries bypass this legacy-lock migration; see [Manage skills](../../README.md#manage-skills).
10. Drift check. Compare each managed skill with its source: the pinned store for a component, this checkout for source `"."`, or the skills.sh lock hash for another manifest source. On drift, a terminal shows the diff and asks `[k]eep installed`, `[t]ake source`, or `[s]kip`. An edited external skill clones the manifest source for that diff, not the lock's old source. Keep with both destinations complete leaves the copies and exits 0. An incomplete keep still exits 1. Take source backs up both destinations, reinstalls, and on failure restores those destinations and the previous lock entry. Skip exits 1. No terminal: the skill is left in place, the drift is listed, and the run exits 1. After drift resolution, including failed repairs, each `~/.empryo/skills/<name>` whose content hash differs from the resulting `~/.agents/skills/<name>` prints one warning; Empryo loads that copy first. The name `empryo` is Empryo's own and is skipped. gap never writes or deletes there, and the warning does not change the exit code.
10. Drift check. Compare each managed skill with its source: the pinned store for a component, this checkout for source `"."`, or the skills.sh lock hash for another manifest source. On drift, a terminal shows the diff and asks `[k]eep installed`, `[t]ake source`, or `[s]kip`. An edited external skill clones the manifest source for that diff, not the lock's old source. Keep with both destinations complete leaves the copies and exits 0. An incomplete keep still exits 1. Take source backs up both destinations, reinstalls, and on failure restores those destinations and the previous lock entry. Skip exits 1. No terminal: the skill is left in place, the drift is listed, and the run exits 1. After drift resolution, including failed repairs, each `~/.empryo/skills/<name>` whose content hash differs from the resulting `~/.agents/skills/<name>` prints one warning; Empryo loads that copy first. The name `empryo` is Empryo's own and is skipped. Each `$JCODE_HOME/skills/<name>` (`~/.jcode/skills` when unset) that differs prints one warning naming both paths too. gap never writes or deletes in either folder, and the warnings do not change the exit code.
11. Empryo pins. When `~/.empryo/config.json` exists and its `skills.autoLoad` differs from `empryoPins` (`do-todo`, `do-commit`, `but-axi`), print the `settings` call that sets it. gap only reads that file. Empryo's `settings` tool cannot write `presets`, and a global `skills.autoLoad` beats any preset, so GAP ships no preset.

### MCP servers
Expand All @@ -53,6 +53,7 @@ Doctor also asks Echo's verified pinned store for `scripts/install.sh --list-ins
- `$CODEX_HOME/gap/global-agent-policy.md` (`~/.codex` when unset)
- `~/.cursor/rules/global-agent-policy.mdc`
- `~/.empryo/EMPRYO.md`. The SoulStack block and any other text there stay outside the GAP markers.
- `$JCODE_HOME/prompt-overlay.md` (`~/.jcode` when unset; a relative `JCODE_HOME` stops the sync). Text outside the GAP markers stays as written.
- `~/.omp/agent/commands/<name>.md` for each `global/commands/*.md`
- `~/.claude/commands/<name>.md` for the same file. Claude Code loads that path as a user slash command. The prompt uses Claude's `$ARGUMENTS` and `argument-hint`, so the file is copied unchanged.
- `~/.pi/agent/prompts/<name>.md` for the same file. Pi loads that directory as prompt templates and expands `$ARGUMENTS` (proved 2026-11-12 with a probe template under Pi 1.5.2).
Expand Down
40 changes: 35 additions & 5 deletions cmd/gap/harness.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
package main

import "path/filepath"
import (
"fmt"
"os"
"path/filepath"
)

// harness is one agent GAP delivers to. Paths are relative to $HOME. An empty policy is Codex, whose file lives under CODEX_HOME.
// harness is one agent GAP delivers to. An empty policy is Codex, whose file lives under CODEX_HOME.
// commands is the folder the harness loads user slash commands from; empty means GAP publishes no commands there.
// mcp registers servers through the harness's own CLI. Add a path or CLI only after proving the harness loads it.
// homeEnv names an environment variable that moves the harness home; policy is then relative to that home, which defaults to homeDir under $HOME.
type harness struct {
name string
policy string
cursor bool
commands string
mcp *mcpCLI
homeEnv string
homeDir string
}

var harnesses = []harness{
Expand All @@ -20,23 +27,46 @@ var harnesses = []harness{
{name: "codex", mcp: codexMCP},
{name: "cursor", policy: ".cursor/rules/global-agent-policy.mdc", cursor: true},
{name: "empryo", policy: ".empryo/EMPRYO.md"},
{name: "jcode", policy: "prompt-overlay.md", homeEnv: "JCODE_HOME", homeDir: ".jcode"},
}

// resolveHarnessHome returns the harness home from its environment variable. Empty means unset; a relative value is refused.
func resolveHarnessHome(name, env, fallback string) (string, error) {
if env == "" {
return fallback, nil
}
if !filepath.IsAbs(env) {
return "", fmt.Errorf("%s must be an absolute path: %s", name, env)
}
return env, nil
}

func harnessHome(h harness, home string) (string, error) {
return resolveHarnessHome(h.homeEnv, os.Getenv(h.homeEnv), filepath.Join(home, h.homeDir))
Comment thread
pullfrog[bot] marked this conversation as resolved.
Resolved
}

type policyDest struct {
dest string
cursor bool
}

func policyDests(home, codexPolicy string) []policyDest {
func policyDests(home, codexPolicy string) ([]policyDest, error) {
dests := make([]policyDest, 0, len(harnesses))
for _, h := range harnesses {
dest := codexPolicy
if h.policy != "" {
switch {
case h.homeEnv != "":
base, err := harnessHome(h, home)
if err != nil {
return nil, err
}
dest = filepath.Join(base, filepath.FromSlash(h.policy))
case h.policy != "":
dest = filepath.Join(home, filepath.FromSlash(h.policy))
}
dests = append(dests, policyDest{dest, h.cursor})
}
return dests
return dests, nil
}

func commandDests(home, name string) []string {
Expand Down
15 changes: 6 additions & 9 deletions cmd/gap/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ func syncAll(opts syncOptions) error {
if err != nil {
return err
}
codexPolicy := filepath.Join(codexHome, "gap", "global-agent-policy.md")
dests, err := policyDests(home, codexPolicy)
if err != nil {
return err
}
docsDir = filepath.Join(repoRoot, "global", "docs", "agents")
co, err := readCheckout(repoRoot)
if err != nil {
Expand All @@ -98,8 +103,6 @@ func syncAll(opts syncOptions) error {
if err != nil {
return err
}
codexPolicy := filepath.Join(codexHome, "gap", "global-agent-policy.md")
dests := policyDests(home, codexPolicy)
groups := syncGroups(d, pins, manifest, projectRoot, len(dests), commandCount(repoRoot))
sel, err := chooseGroups(groups, opts.Only, opts.Skip, opts.Yes)
if err != nil {
Expand Down Expand Up @@ -513,13 +516,7 @@ func ignoredPathBlocks(path string) bool {
}

func resolveCodexHome(env, home string) (string, error) {
if env == "" {
return filepath.Join(home, ".codex"), nil
}
if !filepath.IsAbs(env) {
return "", fmt.Errorf("CODEX_HOME must be an absolute path: %s", env)
}
return env, nil
return resolveHarnessHome("CODEX_HOME", env, filepath.Join(home, ".codex"))
}

const (
Expand Down
Loading
Loading