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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +11 -10
  4. data/assets/analyst +29 -51
  5. data/assets/coord +23 -20
  6. data/assets/dashboard +1 -5
  7. data/assets/dispatcher +69 -286
  8. data/assets/harness-hooks/context-watch.rb +5 -4
  9. data/assets/lima/maf.yaml.erb +61 -0
  10. data/lib/maf/bootstrap/script_planner.rb +1 -1
  11. data/lib/maf/cli.rb +3 -0
  12. data/lib/maf/flow/agent_links.rb +2 -2
  13. data/lib/maf/flow/bootstrapper.rb +5 -5
  14. data/lib/maf/flow/codex_hooks.rb +12 -1
  15. data/lib/maf/flow/generator.rb +1 -1
  16. data/lib/maf/flow/harness_linker.rb +2 -2
  17. data/lib/maf/flow/hook_files.rb +6 -0
  18. data/lib/maf/flow/hook_installer.rb +6 -20
  19. data/lib/maf/flow/manifest.rb +2 -2
  20. data/lib/maf/flow/mcp_config.rb +8 -22
  21. data/lib/maf/flow/mcp_installer.rb +3 -3
  22. data/lib/maf/flow/models.rb +6 -41
  23. data/lib/maf/flow/options.rb +4 -2
  24. data/lib/maf/flow/prompt_builder.rb +7 -14
  25. data/lib/maf/flow/prompt_text.rb +7 -11
  26. data/lib/maf/flow/report.rb +2 -4
  27. data/lib/maf/flow/role_files.rb +4 -19
  28. data/lib/maf/flow/validator.rb +2 -2
  29. data/lib/maf/flow.rb +4 -20
  30. data/lib/maf/menu.rb +1 -1
  31. data/lib/maf/migrate/role_files.rb +3 -1
  32. data/lib/maf/retire.rb +42 -6
  33. data/lib/maf/setup_agent/dispatch.rb +1 -1
  34. data/lib/maf/setup_agent/launcher.rb +15 -66
  35. data/lib/maf/setup_agent/pty_relay.rb +75 -0
  36. data/lib/maf/setup_agent/role_file.rb +1 -1
  37. data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
  38. data/lib/maf/setup_agent.rb +1 -3
  39. data/lib/maf/shared/harness/base.rb +57 -0
  40. data/lib/maf/shared/harness/defaults.rb +140 -0
  41. data/lib/maf/shared/harness/output.rb +55 -0
  42. data/lib/maf/shared/harness/traits.rb +35 -0
  43. data/lib/maf/shared/harness/vm_credential.rb +36 -0
  44. data/lib/maf/shared/harness.rb +12 -0
  45. data/lib/maf/shared/harnesses/claude.rb +134 -0
  46. data/lib/maf/shared/harnesses/codex.rb +173 -0
  47. data/lib/maf/shared/harnesses/cursor.rb +64 -0
  48. data/lib/maf/shared/harnesses/hermes.rb +112 -0
  49. data/lib/maf/shared/harnesses/opencode.rb +169 -0
  50. data/lib/maf/shared/processes.rb +20 -0
  51. data/lib/maf/shared/sandbox/bwrap.rb +128 -0
  52. data/lib/maf/shared/sandbox/harness_files.rb +50 -0
  53. data/lib/maf/shared/sandbox/links.rb +28 -0
  54. data/lib/maf/shared/sandbox/paths.rb +124 -0
  55. data/lib/maf/shared/sandbox/references.rb +64 -0
  56. data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
  57. data/lib/maf/shared/sandbox.rb +84 -0
  58. data/lib/maf/team_command.rb +3 -3
  59. data/lib/maf/uninstall/manifest.rb +10 -8
  60. data/lib/maf/uninstall/mcp_entries.rb +4 -5
  61. data/lib/maf/uninstall/notes.rb +13 -2
  62. data/lib/maf/uninstall/role_files.rb +11 -10
  63. data/lib/maf/uninstall/runner.rb +4 -3
  64. data/lib/maf/uninstall/scripts.rb +1 -1
  65. data/lib/maf/uninstall/vm_files.rb +16 -0
  66. data/lib/maf/uninstall.rb +20 -5
  67. data/lib/maf/untrack.rb +6 -26
  68. data/lib/maf/version.rb +1 -1
  69. data/lib/maf/vm/login.rb +52 -0
  70. data/lib/maf/vm/options.rb +41 -0
  71. data/lib/maf/vm/template.rb +44 -0
  72. data/lib/maf/vm.rb +94 -0
  73. data/lib/maf/worker_control.rb +4 -7
  74. data/lib/maf/workers.rb +27 -0
  75. metadata +30 -11
  76. data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
  77. data/lib/maf/setup_agent/hermes_skill.rb +0 -26
  78. data/templates/claude.md.erb +0 -16
  79. data/templates/codex.md.erb +0 -7
  80. data/templates/hermes.md.erb +0 -12
  81. data/templates/opencode.md.erb +0 -24
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4cdbedeaf3c68e4b2aa40939c8f006f846d0d044531f2c6d53e01cddf4d9e45d
4
- data.tar.gz: e90a24b66c78fd7cc493e493f722b25a5803430967b2cae0de46202befaf12b2
3
+ metadata.gz: a374ca278aa9cd10c03f38a12fa6bc24956037e9be7d7f5cbc1b64ae5feb7649
4
+ data.tar.gz: 73a993337895a9981a134ffe7c95cf10c7134e6c314037168bf0b0e44f9c0f43
5
5
  SHA512:
6
- metadata.gz: ee79aa5c928c7be62ed39cdcf2904f7905b6e33189c31f19b7badd522cd0600431d6b39faebf72c7f60caf951ddbb577efc6d2a1fc167145f4f2503665ffe256
7
- data.tar.gz: 0aa9875967605035e251dab51a6ad097dc80c428cda40a8db8abfad99d0cb5381d92da1c5b7e9dc5b4daa5d7263e670655150100a40f70fb0ec9369b35f868fe
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, change `Maf::VERSION` in `lib/maf/version.rb` and add a
320
- `CHANGELOG.md` entry.
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 harness.
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
- { "claude" => -> { claude(id) }, "codex" => -> { codex(id) }, "opencode" => -> { opencode(id) } }
80
- .fetch(harness, -> { [] }).call
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
- def self.ask(command, prompt)
173
- out, err, status = Open3.capture3(MODEL_ENV, *command, stdin_data: prompt, chdir: Dir.tmpdir)
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
- LEGACY_LIVE_WINDOW = 3600
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
- File.write(File.join(dir, FILE), base_lines(slot) + hook_lines(dir, slot))
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
- RUNTIME_FILES = %w[.maf/bin/coord .maf/coordination/harness-hooks/next-task.rb
1917
- .maf/coordination/harness-hooks/board-watch.rb .maf/coordination/harness-hooks/session-guard.rb
1918
- .maf/coordination/harness-hooks/context-watch.rb .codex/hooks.json
1919
- .opencode/plugins/board-watch.js].freeze
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
- # A pid can belong to a new process. The recorded start time tells them apart.
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)