maf 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -0
- data/README.md +11 -10
- data/assets/analyst +29 -51
- data/assets/coord +23 -20
- data/assets/dashboard +1 -5
- data/assets/dispatcher +69 -286
- data/assets/harness-hooks/context-watch.rb +5 -4
- data/assets/lima/maf.yaml.erb +61 -0
- data/lib/maf/bootstrap/script_planner.rb +1 -1
- data/lib/maf/cli.rb +3 -0
- data/lib/maf/flow/agent_links.rb +2 -2
- data/lib/maf/flow/bootstrapper.rb +5 -5
- data/lib/maf/flow/codex_hooks.rb +12 -1
- data/lib/maf/flow/generator.rb +1 -1
- data/lib/maf/flow/harness_linker.rb +2 -2
- data/lib/maf/flow/hook_files.rb +6 -0
- data/lib/maf/flow/hook_installer.rb +6 -20
- data/lib/maf/flow/manifest.rb +2 -2
- data/lib/maf/flow/mcp_config.rb +8 -22
- data/lib/maf/flow/mcp_installer.rb +3 -3
- data/lib/maf/flow/models.rb +6 -41
- data/lib/maf/flow/options.rb +4 -2
- data/lib/maf/flow/prompt_builder.rb +7 -14
- data/lib/maf/flow/prompt_text.rb +7 -11
- data/lib/maf/flow/report.rb +2 -4
- data/lib/maf/flow/role_files.rb +4 -19
- data/lib/maf/flow/validator.rb +2 -2
- data/lib/maf/flow.rb +4 -20
- data/lib/maf/menu.rb +1 -1
- data/lib/maf/migrate/role_files.rb +3 -1
- data/lib/maf/retire.rb +42 -6
- data/lib/maf/setup_agent/dispatch.rb +1 -1
- data/lib/maf/setup_agent/launcher.rb +15 -66
- data/lib/maf/setup_agent/pty_relay.rb +75 -0
- data/lib/maf/setup_agent/role_file.rb +1 -1
- data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
- data/lib/maf/setup_agent.rb +1 -3
- data/lib/maf/shared/harness/base.rb +57 -0
- data/lib/maf/shared/harness/defaults.rb +140 -0
- data/lib/maf/shared/harness/output.rb +55 -0
- data/lib/maf/shared/harness/traits.rb +35 -0
- data/lib/maf/shared/harness/vm_credential.rb +36 -0
- data/lib/maf/shared/harness.rb +12 -0
- data/lib/maf/shared/harnesses/claude.rb +134 -0
- data/lib/maf/shared/harnesses/codex.rb +173 -0
- data/lib/maf/shared/harnesses/cursor.rb +64 -0
- data/lib/maf/shared/harnesses/hermes.rb +112 -0
- data/lib/maf/shared/harnesses/opencode.rb +169 -0
- data/lib/maf/shared/processes.rb +20 -0
- data/lib/maf/shared/sandbox/bwrap.rb +128 -0
- data/lib/maf/shared/sandbox/harness_files.rb +50 -0
- data/lib/maf/shared/sandbox/links.rb +28 -0
- data/lib/maf/shared/sandbox/paths.rb +124 -0
- data/lib/maf/shared/sandbox/references.rb +64 -0
- data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
- data/lib/maf/shared/sandbox.rb +84 -0
- data/lib/maf/team_command.rb +3 -3
- data/lib/maf/uninstall/manifest.rb +10 -8
- data/lib/maf/uninstall/mcp_entries.rb +4 -5
- data/lib/maf/uninstall/notes.rb +13 -2
- data/lib/maf/uninstall/role_files.rb +11 -10
- data/lib/maf/uninstall/runner.rb +4 -3
- data/lib/maf/uninstall/scripts.rb +1 -1
- data/lib/maf/uninstall/vm_files.rb +16 -0
- data/lib/maf/uninstall.rb +20 -5
- data/lib/maf/untrack.rb +6 -26
- data/lib/maf/version.rb +1 -1
- data/lib/maf/vm/login.rb +52 -0
- data/lib/maf/vm/options.rb +41 -0
- data/lib/maf/vm/template.rb +44 -0
- data/lib/maf/vm.rb +94 -0
- data/lib/maf/worker_control.rb +4 -7
- data/lib/maf/workers.rb +27 -0
- metadata +30 -11
- data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
- data/lib/maf/setup_agent/hermes_skill.rb +0 -26
- data/templates/claude.md.erb +0 -16
- data/templates/codex.md.erb +0 -7
- data/templates/hermes.md.erb +0 -12
- data/templates/opencode.md.erb +0 -24
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a374ca278aa9cd10c03f38a12fa6bc24956037e9be7d7f5cbc1b64ae5feb7649
|
|
4
|
+
data.tar.gz: 73a993337895a9981a134ffe7c95cf10c7134e6c314037168bf0b0e44f9c0f43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 84e490da67016e6a30344fc07645300ebbc81b37363161726ba29138d5108e2dc762a9cc022918e0e6b5bdfed8382bfda7236dd18690602e55c5e1d8a1155965
|
|
7
|
+
data.tar.gz: fb54b0eb031e1d42150a898c84cf6176490b92be13f0619c90fe0094cc67c84b045d2027abc6dbc7bd5a8217935208a748cfa29e769150b746fa210334ecab97
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,137 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.0] - 2026-10-14
|
|
6
|
+
|
|
7
|
+
- The sandbox also protects the flow config in `.maf/claude/` and `.maf/mcp/`, and each script
|
|
8
|
+
that it names. `maf start` and the dispatcher give these files to the harness on the host.
|
|
9
|
+
- Fix: `maf start` of a Codex worker in the sandbox stopped with `Errno::EPERM` on the protected
|
|
10
|
+
`.codex/hooks.json`. maf now writes the file only on a change.
|
|
11
|
+
- The sandbox also protects `.maf/coordination/taskdata/hooks/`: Taskwarrior runs each script
|
|
12
|
+
in it, also on the host.
|
|
13
|
+
- Fix: on Linux, a protected file in `/tmp` (for example a hook script in a harness config)
|
|
14
|
+
made bwrap bind the host `/tmp`, with the sockets of host services. bwrap now binds a parent
|
|
15
|
+
folder only in an allowed folder.
|
|
16
|
+
- Fix: `coord worktree` in the sandbox stopped with an error on the protected `.maf/env.sh` of a
|
|
17
|
+
worktree. coord now writes the file only on a change, and it shows a warning if it cannot.
|
|
18
|
+
- Fix: `maf retire` did not stop a dispatcher of an older maf during a run longer than one
|
|
19
|
+
hour. maf now also finds the dispatcher from its command in the worktree.
|
|
20
|
+
- `maf vm create` installs the maf version of the host, not the newest maf.
|
|
21
|
+
- The sandbox also protects `.maf/coordination/harness-hooks` of the main checkout.
|
|
22
|
+
- On Linux, maf no longer creates an empty `~/.codex/packages` or a `{}` `~/.cursor/cli-config.json`.
|
|
23
|
+
- Fix: `maf team` showed a running dispatcher of an older maf as stopped.
|
|
24
|
+
- maf does not take `bwrap` from a folder of the `write` list.
|
|
25
|
+
- Fix: a broken `.maf/sandbox.json` turned the sandbox off. maf now stops with an error.
|
|
26
|
+
- Fix: maf ran `sandbox-exec` and `bwrap` from `PATH`. maf now runs `/usr/bin/sandbox-exec`,
|
|
27
|
+
and it does not take `bwrap` from the project or a temporary folder.
|
|
28
|
+
- On Linux, each sandboxed run gets a private `/dev/shm`.
|
|
29
|
+
- The analyst command starts in a new session without a terminal.
|
|
30
|
+
- Fix: an empty start time in `workers.json` made a running dispatcher look stopped.
|
|
31
|
+
- The sandbox also protects a hook script that `$CLAUDE_PROJECT_DIR` names, the scripts that
|
|
32
|
+
the harness config of a worktree names, and `config` and `hooks/` of each submodule.
|
|
33
|
+
- Fix: `maf retire` stopped for good when another process had the pid of an old dispatcher.
|
|
34
|
+
For a record without a start time, maf now uses the heartbeat.
|
|
35
|
+
- In a worktree, maf checks the Codex hook trust of the main checkout, because Codex uses
|
|
36
|
+
the hooks of the main checkout there. The warning tells you to trust the hooks on the host.
|
|
37
|
+
- Fix: on Linux, a `TMPDIR` in `/run/user` (or a `write` path in or above a masked folder)
|
|
38
|
+
opened the session bus again. bwrap now skips such a path.
|
|
39
|
+
- Fix: `maf retire` and `maf worker restart` went on when maf could not check the pid of a
|
|
40
|
+
worker from an older maf. They now stop and show the `kill` command.
|
|
41
|
+
- Fix: in the dispatcher, a failed start of a run could run the exit handlers of the
|
|
42
|
+
dispatcher in the child process.
|
|
43
|
+
- Fix: `printf task | maf start` (input without a final newline) waited forever in the sandbox.
|
|
44
|
+
- Fix: on macOS, a run could take the temporary folder of another project from `TMPDIR`.
|
|
45
|
+
- `maf vm login` says "this computer" instead of "this Mac", because it also runs on Linux.
|
|
46
|
+
- Fix: the dashboard ran the analyst command of `.maf/config.json` on the host. The agent
|
|
47
|
+
can change that file, so the command now runs in the sandbox.
|
|
48
|
+
- The sandbox also protects `.maf/coordination/harness-hooks` in each worktree, and
|
|
49
|
+
`.git/config.worktree` of the project and of each worktree.
|
|
50
|
+
- Fix: a relative path in the `write` list of `.maf/sandbox.json` started at the current
|
|
51
|
+
folder. It now starts at the project root.
|
|
52
|
+
- Fix: on Linux, a home folder with its own overlay made maf think that it already ran in the
|
|
53
|
+
sandbox, so it did not wrap the agent. The sandbox now also sets `MAF_BWRAP=1`.
|
|
54
|
+
- Fix: `echo task | maf start` with the sandbox on waited forever. The terminal relay now
|
|
55
|
+
sends ^D when the input ends.
|
|
56
|
+
- Fix: `maf vm` stopped with an error for a VM without mounts.
|
|
57
|
+
- Fix: `maf start` could start a second dispatcher for a worker whose presence record has no
|
|
58
|
+
start time. Such a record now counts as live while its heartbeat is recent. `maf stop` then
|
|
59
|
+
shows the `kill` command instead of a signal to an unknown pid.
|
|
60
|
+
- Fix: in the sandbox, `maf stop` of an interactive worker stopped only the terminal relay.
|
|
61
|
+
The relay now passes TERM, HUP, and INT to the harness, and waits for it.
|
|
62
|
+
- On Linux, maf puts back a protected symlink (for example `~/.claude/CLAUDE.md` into your
|
|
63
|
+
dotfiles) that the agent changed during a run, and shows a warning.
|
|
64
|
+
- The sandbox also protects the harness config in each worktree.
|
|
65
|
+
- Fix: on Linux, a worker that a lead starts failed when the home folder has a space in its path.
|
|
66
|
+
- `docs/sandbox.md` has a new section: what the sandbox protects against.
|
|
67
|
+
- The sandbox uses an allowlist in the folder of each harness: the agent can write only
|
|
68
|
+
the state paths, for example `~/.claude/projects` and `~/.codex/sessions`. Each other
|
|
69
|
+
path is protected, also a new name in an update of the harness. On Linux, each existing
|
|
70
|
+
path that is not state is bound read-only.
|
|
71
|
+
- Fix: the dispatcher ran the verify command of `.maf/config.json` on the host. The agent
|
|
72
|
+
can change that file, so the command now runs in the sandbox.
|
|
73
|
+
- Fix: a dispatched run could push a command into the terminal of the dispatcher (TIOCSTI).
|
|
74
|
+
Each run now starts in a new session without a terminal.
|
|
75
|
+
- Fix: the dispatcher ran `coord` from the worktree, where the agent can change it. It now
|
|
76
|
+
runs `coord` of the main checkout.
|
|
77
|
+
- Fix: a sandboxed run of a worker with `--command` stopped with `NoMethodError`.
|
|
78
|
+
- The sandbox protects the config of each harness in the project (`.mcp.json`,
|
|
79
|
+
`.claude/settings.json`, `.codex/config.toml`, `.opencode/`, and more), and `.maf/bin`,
|
|
80
|
+
`.maf/lib`, and `.maf/env.sh` in each worktree.
|
|
81
|
+
- The sandbox no longer opens `~/.cache` and `~/Library/Caches`. On macOS, `XDG_CACHE_HOME`
|
|
82
|
+
is a private folder of the project.
|
|
83
|
+
- The sandbox also protects a file that a config names with a relative path, and a named file of
|
|
84
|
+
1 MB or more. In a Markdown file, maf follows only the imports, so the text of a skill does not
|
|
85
|
+
protect the files of the project.
|
|
86
|
+
- `maf vm create` finds the agents of the project also in a subfolder or in a worktree.
|
|
87
|
+
- Fix: an agent in the sandbox could push a command into the terminal of an interactive
|
|
88
|
+
`maf start` (TIOCSTI). The shell ran the command outside the sandbox after the harness stopped.
|
|
89
|
+
With the sandbox on, the harness now gets its own terminal.
|
|
90
|
+
- The sandbox also protects each file that the config of a harness names: hook scripts,
|
|
91
|
+
status line scripts, and imports in `CLAUDE.md`. It also protects `~/.claude/rules`, and
|
|
92
|
+
`prompts`, `packages`, and `AGENTS.override.md` in `~/.codex`.
|
|
93
|
+
- On Linux, maf creates each missing config file of a harness before each run, so bwrap can protect it.
|
|
94
|
+
- `maf uninstall` removes `.maf/vm/` and tells you if the VM of the project still exists.
|
|
95
|
+
- `maf vm create` checks the checksum of the Taskwarrior download, and accepts only sizes above 0.
|
|
96
|
+
- The sandbox also protects `.maf/coordination/taskrc`, `.maf/coordination/worktree-env.rb`,
|
|
97
|
+
`.git/commondir`, the git links of each worktree, and the config, hooks, and plugins of each harness.
|
|
98
|
+
If a protected path is a symlink, the link is protected too. On Linux, maf creates the missing
|
|
99
|
+
protected folders before each run.
|
|
100
|
+
- On macOS, the sandbox blocks LaunchServices (`open`) and Apple Events: they started apps outside the sandbox.
|
|
101
|
+
- `maf vm` with a missing or unknown action, or an unknown flag, shows the usage.
|
|
102
|
+
- A VM boot no longer installs the harness CLIs again. `maf vm create` makes a smaller VM by default:
|
|
103
|
+
4 GiB of memory and a 20 GiB disk.
|
|
104
|
+
- Fix: `maf retire` could stop an unrelated process that got the pid of an ended dispatcher.
|
|
105
|
+
maf now signals a pid only if its start time matches the recorded one. `maf start --detach`
|
|
106
|
+
records the start time. `maf team`, `maf worker`, and the dashboard use the same check.
|
|
107
|
+
- The sandbox policy moved to `.maf/sandbox.json`. The agent cannot change it, the flow scripts,
|
|
108
|
+
the message hooks, the git hooks, or `.git/config`. On macOS, each run gets a private `TMPDIR`,
|
|
109
|
+
and the agent can connect only to the sockets of its own folders.
|
|
110
|
+
- `maf vm shell` loads `.maf/env.sh`, so `coord` is on PATH.
|
|
111
|
+
- The sandbox blocks host service sockets (tmux, Docker, systemd --user, D-Bus, SSH agent).
|
|
112
|
+
On Linux, `/tmp` is private to the run, and bwrap keeps the Claude Code state file.
|
|
113
|
+
- `maf vm`: the VM name holds a hash of the project path, and each command checks that the VM
|
|
114
|
+
mounts the project. The VM runs Ubuntu 26.04 and needs Lima 2.2.0 or later. The copied logins are always mode 600.
|
|
115
|
+
- Sandbox mode `bwrap` needs bubblewrap 0.10 or later. maf stops with an older bubblewrap.
|
|
116
|
+
- Linux support: sandbox mode `bwrap` (bubblewrap) and mode `auto` (seatbelt on macOS, bwrap on Linux).
|
|
117
|
+
`maf vm` also runs on a Linux host with QEMU, and copies the claude login from `~/.claude/.credentials.json`.
|
|
118
|
+
- New command `maf vm`: run the whole team in a Lima VM on macOS. `maf vm create` makes a VM that
|
|
119
|
+
sees only the project folder, at the same path as on the host. It installs Ruby, git, tmux,
|
|
120
|
+
Taskwarrior 3, maf, and the CLI of each harness of the project. Then it asks for each harness
|
|
121
|
+
if it can copy the login from this Mac into the VM (`--copy-logins`, `--no-copy-logins`, `maf vm login`).
|
|
122
|
+
- New sandbox for the agents on macOS: `{"mode": "seatbelt"}` in `.maf/sandbox.json`
|
|
123
|
+
or `MAF_SANDBOX=seatbelt`. `maf start` and the dispatcher run the harness in `sandbox-exec`.
|
|
124
|
+
The agent can write only to the project, the board, the temporary folders, and the folders of its harness.
|
|
125
|
+
- Each harness is one file in `lib/maf/shared/harnesses/`. To add a harness, add one
|
|
126
|
+
file, one test file, and one row in the Harnesses table of the user manual.
|
|
127
|
+
`test/harnesses/contract_test.rb` checks every harness file.
|
|
128
|
+
- The dispatcher has no default harness. Give `--harness NAME` or `--command TEMPLATE`.
|
|
129
|
+
`maf start` gives the harness, so `maf start` does not change.
|
|
130
|
+
- A dispatched run gets `OPENCODE_CONFIG` only on the `opencode` harness.
|
|
131
|
+
- `maf untrack` untracks only the role links in `.cursor/agents/`, not the whole folder.
|
|
132
|
+
- New harness `cursor` (the Cursor CLI, `agent`): `maf add cursor:ROLE`, `maf start cursor ROLE`,
|
|
133
|
+
and `dispatcher ROLE --harness cursor`. Role files go to `.cursor/agents/` as Cursor subagents.
|
|
134
|
+
Cursor has no wake-up hook yet, so its agents wait with `coord next --wait`.
|
|
135
|
+
|
|
5
136
|
## [0.1.0] - 2026-10-08
|
|
6
137
|
|
|
7
138
|
- First release as a gem: `gem install maf`.
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# multi_agent_flow
|
|
2
2
|
|
|
3
3
|
Coordination layer for running several AI coding agents (opencode, Claude Code,
|
|
4
|
-
Hermes, Codex) in parallel on one repository, each in its own terminal.
|
|
4
|
+
Hermes, Codex, Cursor) in parallel on one repository, each in its own terminal.
|
|
5
5
|
|
|
6
6
|
Three pillars:
|
|
7
7
|
|
|
@@ -25,7 +25,7 @@ The `maf` gem holds the flow. Install these tools yourself:
|
|
|
25
25
|
| Ruby 3.0+ | runs `maf`, `coord`, and the other scripts | macOS: `brew install mise && mise install ruby`. Linux: your package manager or [mise](https://mise.jdx.dev) |
|
|
26
26
|
| git | worktrees, hooks, the local exclude file | macOS: `xcode-select --install` or `brew install git`. Linux: `apt install git` |
|
|
27
27
|
| Taskwarrior | the task board | macOS: `brew install task`. Linux: `apt install taskwarrior` |
|
|
28
|
-
| One or more harnesses | the agents | [Claude Code](https://docs.claude.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [opencode](https://opencode.ai), [Hermes](https://github.com/NousResearch/hermes-agent) |
|
|
28
|
+
| One or more harnesses | the agents | [Claude Code](https://docs.claude.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [opencode](https://opencode.ai), [Cursor CLI](https://cursor.com/cli), [Hermes](https://github.com/NousResearch/hermes-agent) |
|
|
29
29
|
| graphify (optional) | the shared knowledge graph | `uv tool install graphifyy` |
|
|
30
30
|
|
|
31
31
|
Install the gem:
|
|
@@ -83,7 +83,7 @@ lives in `graphify-out/` at the project root, where graphify looks by default.
|
|
|
83
83
|
lib/maf/shared/ code that the scripts in bin/ load (maf update replaces it)
|
|
84
84
|
coordination/ task board, inbox, locks, presence, sessions, hooks, logs
|
|
85
85
|
worktrees/ one git worktree per worker
|
|
86
|
-
agents/ role files: claude/, opencode/, codex/
|
|
86
|
+
agents/ role files: claude/, opencode/, codex/, cursor/
|
|
87
87
|
claude/ settings.json: the Claude Code hooks (maf start passes --settings)
|
|
88
88
|
mcp/ the graphify MCP server for Claude Code and opencode
|
|
89
89
|
config.json the agents and settings of the project
|
|
@@ -113,7 +113,7 @@ Local files that a harness or git reads outside `.maf/`:
|
|
|
113
113
|
|---|---|
|
|
114
114
|
| `.git/hooks/*` | Git reads them there. |
|
|
115
115
|
| `.git/info/exclude` | Keeps the flow out of git in this clone. |
|
|
116
|
-
| `.claude/agents/`, `.opencode/agents/`, `.codex/prompts/` | Symlinks into `.maf/agents/<harness>/`. |
|
|
116
|
+
| `.claude/agents/`, `.opencode/agents/`, `.codex/prompts/`, `.cursor/agents/` | Symlinks into `.maf/agents/<harness>/`. |
|
|
117
117
|
| `.opencode/plugins/board-watch.js` | opencode reads it there. |
|
|
118
118
|
| `.codex/hooks.json` | Codex reads it there. Excluded when the project does not track it. |
|
|
119
119
|
| `~/.hermes/skills/<project>-<role>/SKILL.md` | Hermes reads it there. |
|
|
@@ -145,6 +145,8 @@ section of [USER_MANUAL.md](USER_MANUAL.md).
|
|
|
145
145
|
| **[GETTING_STARTED.md](GETTING_STARTED.md)** | First-time user | Concepts, prerequisites, manual install, basic workflow |
|
|
146
146
|
| **[USER_MANUAL.md](USER_MANUAL.md)** | Setting up a real team | Full install (maf), all harnesses, dispatcher, monitoring |
|
|
147
147
|
| **[install.md](install.md)** | An AI coding agent | Interactive wizard: asks the user for harnesses/roles, runs `maf add` |
|
|
148
|
+
| **[docs/sandbox.md](docs/sandbox.md)** | Running agents without approvals | Sandbox mode (macOS and Linux), the protected files, the limits, and the Lima VM |
|
|
149
|
+
| **[docs/releasing.md](docs/releasing.md)** | Maintainer | Release a new gem version: version bump, tag, the release workflow, fixes |
|
|
148
150
|
| **[docs/out-of-scope.md](docs/out-of-scope.md)** | Contributor | Requests that the project rejects on purpose, with the reason |
|
|
149
151
|
| **[SKILL.md](SKILL.md)** | Agent skill loader | Self-contained portable skill (frontmatter + full API reference) |
|
|
150
152
|
|
|
@@ -191,16 +193,14 @@ multi_agent_flow/
|
|
|
191
193
|
untrack.rb # maf untrack: removes an older install from git
|
|
192
194
|
local_exclude.rb # the flow block in .git/info/exclude
|
|
193
195
|
shared/ # stdlib-only code that the maf CLI and the scripts share (installed as .maf/lib/maf/shared/)
|
|
196
|
+
shared/harness/ # the harness registry, the default of each harness method, shared modules
|
|
197
|
+
shared/harnesses/ # one file per harness: commands, role file template, quirks
|
|
194
198
|
scripts/
|
|
195
199
|
check.rb # repo consistency check (UDA sync, markers, script signatures)
|
|
196
200
|
templates/
|
|
197
201
|
roles.yml # role definitions + model hints
|
|
198
202
|
role-stub.yml.erb # stub that maf role add writes
|
|
199
203
|
workflows/ # default workflows: simple, plan-review, tdd, panel
|
|
200
|
-
opencode.md.erb # role file templates per harness
|
|
201
|
-
claude.md.erb
|
|
202
|
-
codex.md.erb
|
|
203
|
-
hermes.md.erb
|
|
204
204
|
assets/
|
|
205
205
|
coord # coordination CLI (Ruby)
|
|
206
206
|
dispatcher # polls task board + inbox, starts one-shot agents (Ruby)
|
|
@@ -219,6 +219,7 @@ multi_agent_flow/
|
|
|
219
219
|
maf_test.rb # tests for the maf command
|
|
220
220
|
dashboard_test.rb # tests for the dashboard data
|
|
221
221
|
dispatcher_test.rb # tests for the dispatcher
|
|
222
|
+
harnesses/ # one test file per harness, and the contract test for all harnesses
|
|
222
223
|
uninstaller_test.rb # tests for uninstall.rb
|
|
223
224
|
migrate_test.rb # tests for migrate.rb
|
|
224
225
|
roles_workflow_test.rb # tests for project roles and the workflow
|
|
@@ -316,8 +317,8 @@ bundle exec rake build # writes pkg/maf-VERSION.gem
|
|
|
316
317
|
bundle exec rake install # builds and installs the gem
|
|
317
318
|
```
|
|
318
319
|
|
|
319
|
-
To release,
|
|
320
|
-
|
|
320
|
+
To release, bump `Maf::VERSION`, merge, then tag `main` with `vX.Y.Z` and push the tag.
|
|
321
|
+
See **[docs/releasing.md](docs/releasing.md)** for the full steps, the one-time setup, and the fixes for a failed release.
|
|
321
322
|
|
|
322
323
|
RuboCop (pinned in the `Gemfile`) uses `.rubocop.yml`. It sets the
|
|
323
324
|
size rules: a class at most 100 lines, a method at most 5 lines and 4
|
data/assets/analyst
CHANGED
|
@@ -20,9 +20,16 @@
|
|
|
20
20
|
require "json"
|
|
21
21
|
require "fileutils"
|
|
22
22
|
require "open3"
|
|
23
|
+
require "rbconfig"
|
|
23
24
|
require "optparse"
|
|
24
25
|
require "tmpdir"
|
|
25
26
|
require "time"
|
|
27
|
+
begin
|
|
28
|
+
require_relative "../lib/maf/shared/harness"
|
|
29
|
+
require_relative "../lib/maf/shared/sandbox"
|
|
30
|
+
rescue LoadError
|
|
31
|
+
abort "analyst: the maf shared library is missing. Run maf update."
|
|
32
|
+
end
|
|
26
33
|
|
|
27
34
|
module Analyst
|
|
28
35
|
HISTORY = 20
|
|
@@ -71,57 +78,14 @@ module Analyst
|
|
|
71
78
|
Call.new(kind || command.sub(SETUP, "").split.first.to_s, text, size.to_i)
|
|
72
79
|
end
|
|
73
80
|
|
|
74
|
-
# Transcripts reads the tool calls of one session from the files of the
|
|
81
|
+
# Transcripts reads the tool calls of one session from the files of the
|
|
82
|
+
# harness (Harness#transcript_calls).
|
|
75
83
|
module Transcripts
|
|
76
84
|
def self.calls(harness, id)
|
|
77
85
|
return [] if id.to_s.empty?
|
|
78
86
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def self.lines(path) = path ? File.foreach(path).filter_map { |line| JSON.parse(line) rescue nil } : []
|
|
84
|
-
|
|
85
|
-
# Claude Code: a tool_use block, then a tool_result block with the same ID.
|
|
86
|
-
def self.claude(id)
|
|
87
|
-
events = lines(Dir.glob(File.join(Dir.home, ".claude", "projects", "*", "#{id}.jsonl")).first)
|
|
88
|
-
blocks = events.flat_map { |event| Array(event.dig("message", "content")).select { |block| block.is_a?(Hash) } }
|
|
89
|
-
sizes = blocks.select { |block| block["type"] == "tool_result" }
|
|
90
|
-
.to_h { |block| [block["tool_use_id"], block["content"].to_s.size] }
|
|
91
|
-
blocks.select { |block| block["type"] == "tool_use" }
|
|
92
|
-
.map { |block| Analyst.call(block["name"], block.dig("input", "command"), sizes[block["id"]]) }
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Codex: a custom_tool_call or function_call, then its output with the same call_id.
|
|
96
|
-
def self.codex(id)
|
|
97
|
-
events = lines(Dir.glob(File.join(Dir.home, ".codex", "sessions", "**", "rollout-*#{id}.jsonl")).first)
|
|
98
|
-
payloads = events.map { |event| event["payload"] || {} }
|
|
99
|
-
sizes = payloads.select { |p| p["type"].to_s.end_with?("_output") }
|
|
100
|
-
.to_h { |p| [p["call_id"], p["output"].to_s.size] }
|
|
101
|
-
payloads.select { |p| %w[custom_tool_call function_call].include?(p["type"]) }
|
|
102
|
-
.map { |p| Analyst.call(p["name"], codex_command(p), sizes[p["call_id"]]) }
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def self.codex_command(payload)
|
|
106
|
-
text = (payload["input"] || payload["arguments"]).to_s
|
|
107
|
-
text[/cmd["']?\s*:\s*["']((?:[^"'\\]|\\.)*)/, 1] || text
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# opencode keeps its sessions in a SQLite database. The sqlite3 CLI reads it.
|
|
111
|
-
def self.opencode(id)
|
|
112
|
-
db = File.join(Dir.home, ".local", "share", "opencode", "opencode.db")
|
|
113
|
-
return [] unless File.exist?(db) && id.match?(/\A[\w-]+\z/)
|
|
114
|
-
|
|
115
|
-
out, status = Open3.capture2("sqlite3", "-readonly", "-json", db, opencode_query(id))
|
|
116
|
-
status.success? ? JSON.parse(out.empty? ? "[]" : out).map { |row| Analyst.call(*row.values) } : []
|
|
117
|
-
rescue SystemCallError, JSON::ParserError
|
|
118
|
-
[]
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def self.opencode_query(id)
|
|
122
|
-
"select json_extract(data,'$.tool') tool, json_extract(data,'$.state.input.command') command, " \
|
|
123
|
-
"length(json_extract(data,'$.state.output')) size from part where session_id = '#{id}' " \
|
|
124
|
-
"and json_extract(data,'$.type') = 'tool'"
|
|
87
|
+
calls = Maf::Shared::Harness[harness]&.transcript_calls(id) || []
|
|
88
|
+
calls.map { |name, command, size| Analyst.call(name, command, size) }
|
|
125
89
|
end
|
|
126
90
|
end
|
|
127
91
|
|
|
@@ -169,8 +133,10 @@ module Analyst
|
|
|
169
133
|
|
|
170
134
|
# Model sends the prompt to the analyst command and reads the hints.
|
|
171
135
|
module Model
|
|
172
|
-
|
|
173
|
-
|
|
136
|
+
NEW_SESSION = [RbConfig.ruby, "-e", "Process.setsid; exec(*ARGV)"].freeze
|
|
137
|
+
|
|
138
|
+
def self.ask(command, prompt, coord)
|
|
139
|
+
out, err, status = run(command, prompt, coord)
|
|
174
140
|
raise "#{command.first} failed: #{(err.empty? ? out : err).strip[0, 200]}" unless status.success?
|
|
175
141
|
|
|
176
142
|
reply = JSON.parse(out) rescue nil
|
|
@@ -178,6 +144,18 @@ module Analyst
|
|
|
178
144
|
[hints(reply["result"] || out), reply["total_cost_usd"]]
|
|
179
145
|
end
|
|
180
146
|
|
|
147
|
+
def self.run(command, prompt, coord)
|
|
148
|
+
sandbox = Maf::Shared::Sandbox
|
|
149
|
+
wrapped = [*NEW_SESSION, *sandbox.wrap(command, harness(command), coord_dir: coord)]
|
|
150
|
+
run = -> { Open3.capture3(MODEL_ENV, *wrapped, stdin_data: prompt, chdir: Dir.tmpdir) }
|
|
151
|
+
sandbox.keeping_links(harness(command), coord_dir: coord, &run)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.harness(command)
|
|
155
|
+
name = File.basename(command.first.to_s)
|
|
156
|
+
Maf::Shared::Harness.all.find { |harness| harness.command == name } || Maf::Shared::Harness::Base.new
|
|
157
|
+
end
|
|
158
|
+
|
|
181
159
|
def self.hints(text)
|
|
182
160
|
data = JSON.parse(text.to_s[/\{\s*"hints".*\}/m].to_s)
|
|
183
161
|
Array(data["hints"]).first(3).filter_map { |hint| clean(hint) }
|
|
@@ -214,9 +192,9 @@ module Analyst
|
|
|
214
192
|
return puts(prompt) if @print
|
|
215
193
|
|
|
216
194
|
write("running" => true)
|
|
217
|
-
hints, cost = Model.ask(command, prompt)
|
|
195
|
+
hints, cost = Model.ask(command, prompt, @coord)
|
|
218
196
|
write("hints" => hints, "cost_usd" => cost)
|
|
219
|
-
rescue StandardError => e
|
|
197
|
+
rescue StandardError, SystemExit => e
|
|
220
198
|
write("error" => e.message)
|
|
221
199
|
end
|
|
222
200
|
|
data/assets/coord
CHANGED
|
@@ -24,6 +24,7 @@ begin
|
|
|
24
24
|
require_relative "../lib/maf/shared/roles"
|
|
25
25
|
require_relative "../lib/maf/shared/git_exclude"
|
|
26
26
|
require_relative "../lib/maf/shared/git_identity"
|
|
27
|
+
require_relative "../lib/maf/shared/harness"
|
|
27
28
|
rescue LoadError
|
|
28
29
|
abort "coord: the maf shared library is missing. Run maf update."
|
|
29
30
|
end
|
|
@@ -200,7 +201,7 @@ module Coord
|
|
|
200
201
|
def ensure_taskrc
|
|
201
202
|
had_marker = File.exist?(@taskrc) && File.read(@taskrc).include?(MARKER)
|
|
202
203
|
FileUtils.mkdir_p(File.dirname(@taskrc))
|
|
203
|
-
FileUtils.touch(@taskrc)
|
|
204
|
+
FileUtils.touch(@taskrc) unless File.exist?(@taskrc)
|
|
204
205
|
ensure_data_location(warn_when_external: !had_marker)
|
|
205
206
|
had_marker ? add_missing_udas : append_uda_block
|
|
206
207
|
end
|
|
@@ -840,20 +841,7 @@ module Coord
|
|
|
840
841
|
entries.each { |entry| puts line(entry) }
|
|
841
842
|
end
|
|
842
843
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
def self.live_entry?(entry)
|
|
846
|
-
return false unless Maf::Shared::Processes.alive?(entry["pid"])
|
|
847
|
-
|
|
848
|
-
recorded = entry["started"].to_s
|
|
849
|
-
recorded.empty? ? legacy_live?(entry) : recorded == Maf::Shared::Processes.started_at(entry["pid"])
|
|
850
|
-
end
|
|
851
|
-
|
|
852
|
-
def self.legacy_live?(entry)
|
|
853
|
-
Time.now - Time.parse(entry["seen_at"].to_s) < LEGACY_LIVE_WINDOW
|
|
854
|
-
rescue ArgumentError
|
|
855
|
-
false
|
|
856
|
-
end
|
|
844
|
+
def self.live_entry?(entry) = Maf::Shared::Processes.live?(entry)
|
|
857
845
|
|
|
858
846
|
private
|
|
859
847
|
|
|
@@ -1823,11 +1811,17 @@ end
|
|
|
1823
1811
|
def write(dir, slug)
|
|
1824
1812
|
slot = Slots.new(@coord_dir).assign(slug)
|
|
1825
1813
|
FileUtils.mkdir_p(File.join(dir, MAF_DIR))
|
|
1826
|
-
|
|
1814
|
+
save(File.join(dir, FILE), base_lines(slot) + hook_lines(dir, slot))
|
|
1827
1815
|
end
|
|
1828
1816
|
|
|
1829
1817
|
private
|
|
1830
1818
|
|
|
1819
|
+
def save(file, text)
|
|
1820
|
+
File.write(file, text) unless File.exist?(file) && File.read(file) == text
|
|
1821
|
+
rescue SystemCallError => e
|
|
1822
|
+
warn "coord: cannot write #{file} (#{e.message}). Run coord worktree outside the sandbox to update it."
|
|
1823
|
+
end
|
|
1824
|
+
|
|
1831
1825
|
def base_lines(slot)
|
|
1832
1826
|
"export COORD_DIR=#{@coord_dir}\nexport TASKRC=#{File.join(@coord_dir, "taskrc")}\n" \
|
|
1833
1827
|
"export COORD_SLOT=#{slot}\n#{bin_lines}"
|
|
@@ -1913,10 +1907,11 @@ end
|
|
|
1913
1907
|
# slash, so it also matches the symlink.
|
|
1914
1908
|
GRAPH_DIR = "graphify-out"
|
|
1915
1909
|
LOCAL_EXCLUDES = [ENV_FILE, "/#{GRAPH_DIR}"].freeze
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1910
|
+
# The hook files of the flow, and the files that each harness writes in a worktree.
|
|
1911
|
+
RUNTIME_FILES = (%w[.maf/bin/coord .maf/coordination/harness-hooks/next-task.rb
|
|
1912
|
+
.maf/coordination/harness-hooks/board-watch.rb .maf/coordination/harness-hooks/session-guard.rb
|
|
1913
|
+
.maf/coordination/harness-hooks/context-watch.rb] +
|
|
1914
|
+
Maf::Shared::Harness.all.flat_map(&:runtime_files)).freeze
|
|
1920
1915
|
|
|
1921
1916
|
def self.dir_for(root, slug) = Maf::Shared::Project.worktree_dir(root, slug)
|
|
1922
1917
|
|
|
@@ -2025,6 +2020,14 @@ end
|
|
|
2025
2020
|
|
|
2026
2021
|
FileUtils.mkdir_p(File.dirname(dest))
|
|
2027
2022
|
FileUtils.cp(src, dest, preserve: true)
|
|
2023
|
+
rescue Errno::EPERM, Errno::EROFS => e
|
|
2024
|
+
blocked(src, e)
|
|
2025
|
+
end
|
|
2026
|
+
|
|
2027
|
+
def blocked(src, error)
|
|
2028
|
+
raise error unless src.start_with?(File.join(@root, MAF_DIR, ""))
|
|
2029
|
+
|
|
2030
|
+
abort "coord: cannot copy #{src} into the worktree in the sandbox. Run coord worktree on the host."
|
|
2028
2031
|
end
|
|
2029
2032
|
|
|
2030
2033
|
# The graph is not committed. A relative symlink gives the worktree the
|
data/assets/dashboard
CHANGED
|
@@ -137,11 +137,7 @@ end
|
|
|
137
137
|
{}
|
|
138
138
|
end
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
def live?(presence)
|
|
142
|
-
pid = presence["pid"].to_i
|
|
143
|
-
Maf::Shared::Processes.alive?(pid) && [nil, "", Maf::Shared::Processes.started_at(pid)].include?(presence["started"])
|
|
144
|
-
end
|
|
140
|
+
def live?(presence) = Maf::Shared::Processes.live?(presence)
|
|
145
141
|
|
|
146
142
|
def tail(name)
|
|
147
143
|
path = File.join(sessions, name)
|