maf 0.2.0 → 0.4.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 +59 -0
- data/README.md +22 -1
- data/assets/agents-contract.md +2 -0
- data/assets/coord +145 -21
- data/assets/dispatcher +67 -13
- data/install.md +6 -1
- data/lib/maf/backup.rb +63 -0
- data/lib/maf/bootstrap/claude_settings.rb +17 -9
- data/lib/maf/bootstrap.rb +4 -0
- data/lib/maf/budget.rb +2 -0
- data/lib/maf/cli.rb +38 -10
- data/lib/maf/flow/effort_report.rb +20 -0
- data/lib/maf/flow/generator.rb +2 -1
- data/lib/maf/flow/hermes_env_setup.rb +46 -0
- data/lib/maf/flow/manifest.rb +1 -1
- data/lib/maf/flow/models.rb +11 -0
- data/lib/maf/flow/options.rb +29 -3
- data/lib/maf/flow/prompt_text.rb +14 -7
- data/lib/maf/flow/report.rb +6 -5
- data/lib/maf/flow/role_catalog.rb +1 -1
- data/lib/maf/flow/role_files.rb +5 -5
- data/lib/maf/flow/roster.rb +6 -3
- data/lib/maf/flow.rb +3 -1
- data/lib/maf/menu.rb +48 -14
- data/lib/maf/restore.rb +62 -0
- data/lib/maf/setup_agent/args.rb +16 -10
- data/lib/maf/setup_agent/dispatch.rb +2 -2
- data/lib/maf/setup_agent/launcher.rb +6 -4
- data/lib/maf/setup_agent/manifest.rb +9 -3
- data/lib/maf/setup_agent.rb +11 -11
- data/lib/maf/shared/harness/defaults.rb +10 -3
- data/lib/maf/shared/harnesses/claude.rb +6 -3
- data/lib/maf/shared/harnesses/codex.rb +39 -10
- data/lib/maf/shared/harnesses/cursor.rb +60 -4
- data/lib/maf/shared/harnesses/hermes.rb +21 -11
- data/lib/maf/shared/harnesses/opencode.rb +25 -2
- data/lib/maf/shared/roles.rb +4 -0
- data/lib/maf/shared/sandbox/seatbelt.rb +5 -1
- data/lib/maf/shared/sandbox.rb +6 -0
- data/lib/maf/team.rb +15 -6
- data/lib/maf/team_command.rb +11 -2
- data/lib/maf/untrack.rb +12 -1
- data/lib/maf/version.rb +1 -1
- data/lib/maf/worker_control.rb +12 -1
- data/lib/maf/worker_list.rb +83 -0
- data/templates/roles.yml +14 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2844c9f128a0b025a9a1c933091642b9e885f069d3bfe7daff77f2fe3659c56
|
|
4
|
+
data.tar.gz: c464cfff417c58d40add531944b4ec12a93a5251510dc01d7f2d81a0a7905997
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d906ee9673550164f3c61c195eee88a41173f80443176c5be7809a89d2da32036f8c6ad03620ca8ddadb8994a345a565602b660e0a150563d4411c6c0e896fae
|
|
7
|
+
data.tar.gz: 3fe01e2df87c0676ced712531aa44ec1935d8a5d5982d1521e765a3d846a4d9fd524485076738ba1c35927f8eb2cb00fea6db174d3fb570ada0565b19ebdeff1
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-10-21
|
|
6
|
+
|
|
7
|
+
- Fix: in the macOS sandbox, a worker that the project manager started failed on each run with
|
|
8
|
+
`sandbox_apply: Operation not permitted`. macOS cannot start a sandbox inside a sandbox. The
|
|
9
|
+
sandbox now sets `MAF_SEATBELT=1`. In the sandbox, `maf prepare --dispatch` prepares the worker
|
|
10
|
+
and prints the start commands, and a dispatcher stops at once with the reason in its log.
|
|
11
|
+
- The project manager no longer adds workers on its own. Without a team budget, `maf prepare`
|
|
12
|
+
refuses when an agent runs it. Only the user sets the budget: `maf team set` refuses when an
|
|
13
|
+
agent runs it.
|
|
14
|
+
- Backup of the flow config. Each maf command copies `.maf/config.json`, `roles.yml`,
|
|
15
|
+
`workflow.md`, and `sandbox.json` to `.git/maf/backup/` when they changed, and keeps 10
|
|
16
|
+
copies. New command `maf restore [--list] [BACKUP] [--force]` puts them back and runs
|
|
17
|
+
`maf update`. When `.maf/` exists without `config.json`, maf names the backup. Before, a
|
|
18
|
+
merge of the `maf untrack` commit into a branch that still tracked the flow deleted the
|
|
19
|
+
config, and the agents, `base_branch`, and `github` settings were lost.
|
|
20
|
+
|
|
21
|
+
## [0.3.0] - 2026-10-21
|
|
22
|
+
|
|
23
|
+
- Fix: each dispatched Hermes run failed. Hermes 0.22 refuses `--run-budget` with `--format
|
|
24
|
+
stream-json`. maf no longer passes `--run-budget`. The `--timeout` of the dispatcher still
|
|
25
|
+
stops a run.
|
|
26
|
+
- Fix: in a Hermes run, `coord` did not get `COORD_WORKER` and the other flow variables, so
|
|
27
|
+
`coord done` refused to close the task. Hermes runs each session in a shared gateway and
|
|
28
|
+
passes only the names in `terminal.client_env_passthrough`. `maf add` and `maf update` print
|
|
29
|
+
the `hermes config set` command that adds the flow names to that list.
|
|
30
|
+
- `maf worker stop|start|restart|status ROLE` finds the worker when the role has one worker,
|
|
31
|
+
for example `tester-bot`.
|
|
32
|
+
- `maf worker list` names the stopped workers also when no worker runs.
|
|
33
|
+
- A worker does not start a team of its own. `.maf/claude/settings.json` sets
|
|
34
|
+
`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=0`, so a subagent that Claude names in a `maf start`
|
|
35
|
+
session does not start as a teammate. `maf update` adds the setting.
|
|
36
|
+
- Hermes: each role gets a toolset list. A role that edits no longer gets `kanban`,
|
|
37
|
+
`delegation`, `cronjob`, `computer_use`, and the other toolsets outside the list.
|
|
38
|
+
- Fix: a Hermes role with `can_edit: false` did not get the graph MCP server, because its
|
|
39
|
+
toolset list did not name `mcp-graphify-<project>`.
|
|
40
|
+
- The dispatcher blocks a task after 3 runs in a row that leave it claimed and not done, and
|
|
41
|
+
escalates it to the project manager. Before, it resumed such a task each hour without end.
|
|
42
|
+
New commands: `coord block ID [--kind K] TEXT` holds a task out of `coord next`, and
|
|
43
|
+
`coord unblock ID` returns it. `coord status` and `coord board` show blocked tasks.
|
|
44
|
+
- A worker that cannot finish a task blocks it with a kind: `needs_input`, `dependency`, or
|
|
45
|
+
`capability`. coord tells the architect (dependency) or the project manager (the other kinds).
|
|
46
|
+
Before, the worker kept the claim, and the dispatcher resumed the task each hour.
|
|
47
|
+
- `maf worker list` shows each running worker: its role, harness, mode (session or dispatch),
|
|
48
|
+
pid, and start time. A worker that runs twice, for example as a session and as a
|
|
49
|
+
dispatcher, gets a warning. The last line names the registered workers that do not run.
|
|
50
|
+
- Effort levels per role (#44): `maf add --effort ROLE=LEVEL`, `maf start` and `maf prepare
|
|
51
|
+
--effort LEVEL`, and `dispatcher --effort LEVEL`. maf passes the level to the harness in its
|
|
52
|
+
own form: `--effort` (Claude Code), `model_reasoning_effort` (Codex), `--reasoning` (Hermes),
|
|
53
|
+
`--variant` (opencode), and the model name for Cursor (`gpt-5.3-codex` + `high` =
|
|
54
|
+
`gpt-5.3-codex-high`). Each role has a default level in `roles.yml` (`effort:`), used when the
|
|
55
|
+
harness lists it. After `maf update`, the claude, codex, hermes, and cursor agents get the
|
|
56
|
+
default of their role. The interactive mode shows the levels of each model and asks for one.
|
|
57
|
+
- Fix: Codex 0.163 ran the commands of a worker in its shared background server. The commands
|
|
58
|
+
got the environment of the shell that started the server, not of the worker: no `.maf/bin` on
|
|
59
|
+
PATH, no `COORD_ROLE` and `COORD_WORKER`. maf now starts Codex with `--no-daemon`.
|
|
60
|
+
- Fix: after a message run, the dispatcher counted the unclaimed tasks as dispatched and backed
|
|
61
|
+
off: 2 minutes, then 4, then more. A message run must not claim a task, so the task waited
|
|
62
|
+
without a reason. The task now runs in the next cycle.
|
|
63
|
+
|
|
5
64
|
## [0.2.0] - 2026-10-14
|
|
6
65
|
|
|
7
66
|
- The sandbox also protects the flow config in `.maf/claude/` and `.maf/mcp/`, and each script
|
data/README.md
CHANGED
|
@@ -164,7 +164,7 @@ multi_agent_flow/
|
|
|
164
164
|
GETTING_STARTED.md # first-time walkthrough (concepts + manual setup)
|
|
165
165
|
USER_MANUAL.md # full team setup reference
|
|
166
166
|
maf.gemspec # the gem: files, version, the webrick dependency
|
|
167
|
-
Gemfile # development gems: rake, minitest, rubocop
|
|
167
|
+
Gemfile # development gems: rake, minitest, rubocop, mutant
|
|
168
168
|
CHANGELOG.md # changes per version
|
|
169
169
|
exe/
|
|
170
170
|
maf # the maf command line tool (gem install maf puts it on PATH)
|
|
@@ -187,10 +187,13 @@ multi_agent_flow/
|
|
|
187
187
|
team_command.rb # maf team: shows the team, or sets its budget
|
|
188
188
|
retire.rb # maf retire: removes one worker and archives its state
|
|
189
189
|
worker_control.rb # maf worker: stops, starts, or restarts one worker
|
|
190
|
+
worker_list.rb # maf worker list: shows the sessions and dispatchers that run
|
|
190
191
|
flow/role_catalog.rb # merges .maf/roles.yml over the built-in roles
|
|
191
192
|
flow/workflow.rb # reads .maf/workflow.md for the architect prompt
|
|
192
193
|
flow/mcp_config.rb # writes the graphify MCP server into .maf/mcp/
|
|
193
194
|
untrack.rb # maf untrack: removes an older install from git
|
|
195
|
+
backup.rb # copies of the flow config in .git/maf/backup
|
|
196
|
+
restore.rb # maf restore: puts the flow config back from a backup
|
|
194
197
|
local_exclude.rb # the flow block in .git/info/exclude
|
|
195
198
|
shared/ # stdlib-only code that the maf CLI and the scripts share (installed as .maf/lib/maf/shared/)
|
|
196
199
|
shared/harness/ # the harness registry, the default of each harness method, shared modules
|
|
@@ -231,7 +234,9 @@ multi_agent_flow/
|
|
|
231
234
|
hook_session_test.rb # tests for session isolation
|
|
232
235
|
hook_config_test.rb # tests for project hooks
|
|
233
236
|
worker_control_test.rb # tests for maf worker
|
|
237
|
+
worker_list_test.rb # tests for maf worker list
|
|
234
238
|
untrack_test.rb # tests for maf untrack
|
|
239
|
+
backup_test.rb # tests for the backup and maf restore
|
|
235
240
|
analyst_test.rb # tests for the analyst
|
|
236
241
|
shared_test.rb # tests for lib/maf/shared/
|
|
237
242
|
gemspec_test.rb # tests that the gem holds every runtime file
|
|
@@ -336,6 +341,20 @@ them when you run a test file directly, so a test never writes to that board:
|
|
|
336
341
|
env -u TASKRC -u COORD_DIR -u COORD_ROLE -u COORD_WORKER ruby test/coord_test.rb
|
|
337
342
|
```
|
|
338
343
|
|
|
344
|
+
`rake mutant` runs the mutation tests with [mutant](https://github.com/mbj/mutant).
|
|
345
|
+
Mutant changes the code in `lib/` and runs the tests again. A change that no test
|
|
346
|
+
finds is an alive mutation: add a test for it. Mutant needs Ruby 3.3 or later.
|
|
347
|
+
It is free for open source projects (`usage: opensource` in `.mutant.yml`).
|
|
348
|
+
|
|
349
|
+
```sh
|
|
350
|
+
bundle exec rake mutant # the subjects in .mutant.yml
|
|
351
|
+
bundle exec rake mutant SUBJECT='Maf::Budget*' # other subjects
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Mutant runs only the test classes that name their code with `cover`. To add a
|
|
355
|
+
subject, require `test/mutant_cover.rb` in the test file, add `cover "Maf::X*"` to
|
|
356
|
+
the test class, and add `Maf::X*` to `matcher.subjects` in `.mutant.yml`.
|
|
357
|
+
|
|
339
358
|
```sh
|
|
340
359
|
ruby test/coord_test.rb # covers the coord CLI
|
|
341
360
|
ruby test/installer_test.rb # covers bootstrap.rb, flow.rb, setup_agent.rb
|
|
@@ -353,7 +372,9 @@ ruby test/dispatcher_test.rb # covers the dispatcher
|
|
|
353
372
|
ruby test/board_watch_test.rb # covers the board watcher
|
|
354
373
|
ruby test/context_watch_test.rb # covers the context-watch hook
|
|
355
374
|
ruby test/worker_control_test.rb # covers maf worker
|
|
375
|
+
ruby test/worker_list_test.rb # covers maf worker list
|
|
356
376
|
ruby test/untrack_test.rb # covers maf untrack
|
|
377
|
+
ruby test/backup_test.rb # covers the backup and maf restore
|
|
357
378
|
ruby test/analyst_test.rb # covers the analyst
|
|
358
379
|
ruby test/shared_test.rb # covers lib/maf/shared/
|
|
359
380
|
```
|
data/assets/agents-contract.md
CHANGED
|
@@ -31,6 +31,8 @@ coord claim ID | start-task ID | done ID # take a task, check out its branch
|
|
|
31
31
|
coord annotate ID TEXT # add a note to a task
|
|
32
32
|
coord lesson ID dead_end|corrected TEXT # an approach that failed, or the right way: the graph keeps it
|
|
33
33
|
coord escalate [--task ID] TEXT # a problem you cannot fix: the project manager asks the user
|
|
34
|
+
coord block ID --kind K TEXT # you cannot finish: K is needs_input, dependency, or capability
|
|
35
|
+
coord unblock ID # lead roles: the blocker is gone; the task returns to coord next
|
|
34
36
|
coord msg --from A [--task ID] [--fyi] TO TEXT # message a role or a worker; --task also notes the task
|
|
35
37
|
# --fyi: no run starts; the next run of TO reads it
|
|
36
38
|
coord broadcast --from A [--to workers|leads|all] TEXT
|
data/assets/coord
CHANGED
|
@@ -54,6 +54,7 @@ module Coord
|
|
|
54
54
|
# A claim older than this is presumed abandoned (crashed worker) and can be
|
|
55
55
|
# taken over by `next`/`claim` without --force. Override with COORD_LEASE_TTL.
|
|
56
56
|
DEFAULT_LEASE_TTL = 4 * 3600
|
|
57
|
+
BLOCKED_TAG = "blocked"
|
|
57
58
|
DEFAULT_POLL_INTERVAL = 60
|
|
58
59
|
|
|
59
60
|
# Lead roles plan and talk to the user. They never own a task, so they wait
|
|
@@ -86,6 +87,10 @@ module Coord
|
|
|
86
87
|
coord conflicts list pending tasks with overlapping scopes
|
|
87
88
|
coord claim ID [--force] claim for COORD_WORKER (atomic; refused for lead roles)
|
|
88
89
|
coord unclaim ID release a claim without finishing it
|
|
90
|
+
coord block ID [--kind K] TEXT... release the claim, hold the task out of coord next, and tell
|
|
91
|
+
the architect (K=dependency) or the project manager
|
|
92
|
+
(K=needs_input, the default, capability, or failing)
|
|
93
|
+
coord unblock ID return a blocked task to the backlog
|
|
89
94
|
coord done ID [--force] complete (refused while the task branch lacks the goal head,
|
|
90
95
|
or while the verify command fails)
|
|
91
96
|
coord annotate ID TEXT...
|
|
@@ -120,7 +125,7 @@ module Coord
|
|
|
120
125
|
COMMANDS = {
|
|
121
126
|
"init" => :cmd_init, "add" => :cmd_add, "next" => :cmd_next,
|
|
122
127
|
"conflicts" => :cmd_conflicts,
|
|
123
|
-
"claim" => :cmd_claim, "unclaim" => :cmd_unclaim,
|
|
128
|
+
"claim" => :cmd_claim, "unclaim" => :cmd_unclaim, "block" => :cmd_block, "unblock" => :cmd_unblock,
|
|
124
129
|
"done" => :cmd_done, "annotate" => :cmd_annotate, "lesson" => :cmd_lesson,
|
|
125
130
|
"msg" => :cmd_msg, "inbox" => :cmd_inbox, "await" => :cmd_await, "broadcast" => :cmd_broadcast,
|
|
126
131
|
"log" => :cmd_log, "hooks" => :cmd_hooks,
|
|
@@ -346,6 +351,9 @@ module Coord
|
|
|
346
351
|
rescue ArgumentError
|
|
347
352
|
false
|
|
348
353
|
end
|
|
354
|
+
|
|
355
|
+
def self.blocked?(task) = Array(task["tags"]).include?(BLOCKED_TAG)
|
|
356
|
+
def self.held?(task) = waiting?(task) || blocked?(task)
|
|
349
357
|
end
|
|
350
358
|
|
|
351
359
|
# TaskCli runs Taskwarrior and parses its output. No domain logic here.
|
|
@@ -408,6 +416,8 @@ module Coord
|
|
|
408
416
|
export([id, "export"]).first || {}
|
|
409
417
|
end
|
|
410
418
|
|
|
419
|
+
def with_goal(uuid) = export(["goalid:#{uuid}", "export"])
|
|
420
|
+
|
|
411
421
|
private
|
|
412
422
|
|
|
413
423
|
def run(*args)
|
|
@@ -517,7 +527,7 @@ module Coord
|
|
|
517
527
|
end
|
|
518
528
|
|
|
519
529
|
def mine(role, worker)
|
|
520
|
-
pending.select { |task| task["role"] == role && task["worker"] == worker && task["start"] }
|
|
530
|
+
pending.select { |task| task["role"] == role && task["worker"] == worker && task["start"] && actionable?(task) }
|
|
521
531
|
end
|
|
522
532
|
|
|
523
533
|
# Returns the prior holder (nil if there was none) so the caller can warn
|
|
@@ -537,10 +547,18 @@ module Coord
|
|
|
537
547
|
@cli.modify(id, "worker:")
|
|
538
548
|
end
|
|
539
549
|
|
|
550
|
+
def block(id)
|
|
551
|
+
unclaim(id) if @cli.task(id)["start"]
|
|
552
|
+
@cli.modify(id, "+#{BLOCKED_TAG}")
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def unblock(id) = @cli.modify(id, "-#{BLOCKED_TAG}")
|
|
556
|
+
|
|
540
557
|
def find(id) = @cli.task(id)
|
|
541
558
|
# A short id of digits only would read as a task number. The filter avoids that.
|
|
542
559
|
def find_short(short) = @cli.task("uuid.startswith:#{short}")
|
|
543
560
|
def for_goal(uuid) = pending.select { |task| task["goalid"] == uuid }
|
|
561
|
+
def any_for_goal?(uuid) = @cli.with_goal(uuid).any?
|
|
544
562
|
def done(id) = @cli.done(id)
|
|
545
563
|
def annotate(id, text) = @cli.annotate(id, text)
|
|
546
564
|
def pending = @pending ||= @cli.pending
|
|
@@ -548,7 +566,7 @@ module Coord
|
|
|
548
566
|
|
|
549
567
|
private
|
|
550
568
|
|
|
551
|
-
def actionable?(task) = !Schedule.
|
|
569
|
+
def actionable?(task) = !Schedule.held?(task)
|
|
552
570
|
|
|
553
571
|
def held_by?(task, worker) = !worker.to_s.empty? && task["worker"] == worker
|
|
554
572
|
|
|
@@ -1097,9 +1115,10 @@ end
|
|
|
1097
1115
|
# Board renders status summaries and the Obsidian kanban file.
|
|
1098
1116
|
class Board
|
|
1099
1117
|
SECTIONS = {
|
|
1100
|
-
"Backlog" => ->(task) { !Schedule.
|
|
1101
|
-
"In progress" => ->(task) { !Schedule.
|
|
1102
|
-
"Waiting" => ->(task) { Schedule.waiting?(task) }
|
|
1118
|
+
"Backlog" => ->(task) { !Schedule.held?(task) && !task["start"] },
|
|
1119
|
+
"In progress" => ->(task) { !Schedule.held?(task) && task["start"] },
|
|
1120
|
+
"Waiting" => ->(task) { Schedule.waiting?(task) && !Schedule.blocked?(task) },
|
|
1121
|
+
"Blocked" => ->(task) { Schedule.blocked?(task) }
|
|
1103
1122
|
}.freeze
|
|
1104
1123
|
|
|
1105
1124
|
def initialize(tasks, paths)
|
|
@@ -1149,6 +1168,7 @@ end
|
|
|
1149
1168
|
end
|
|
1150
1169
|
|
|
1151
1170
|
def bucket(task)
|
|
1171
|
+
return "blocked" if Schedule.blocked?(task)
|
|
1152
1172
|
return "waiting" if Schedule.waiting?(task)
|
|
1153
1173
|
|
|
1154
1174
|
task["start"] ? "in-progress" : "backlog"
|
|
@@ -1185,6 +1205,8 @@ end
|
|
|
1185
1205
|
def self.run(dir, *args) = system("git", "-C", dir, *args, out: File::NULL, err: File::NULL)
|
|
1186
1206
|
def self.branch?(dir, name) = run(dir, "show-ref", "--verify", "--quiet", "refs/heads/#{name}")
|
|
1187
1207
|
def self.clean?(dir) = `git -C #{Shellwords.escape(dir)} status --porcelain --untracked-files=no`.strip.empty?
|
|
1208
|
+
def self.changed(dir) = out(dir, "diff", "HEAD", "--name-only").lines(chomp: true)
|
|
1209
|
+
def self.remote_branch?(dir, remote, name) = run(dir, "ls-remote", "--exit-code", "--heads", remote, name)
|
|
1188
1210
|
def self.out(dir, *args) = IO.popen(["git", "-C", dir, *args], err: File::NULL, &:read).strip
|
|
1189
1211
|
def self.ancestor?(dir, older, newer) = run(dir, "merge-base", "--is-ancestor", older, newer)
|
|
1190
1212
|
def self.own_commits?(dir, base, branch) = out(dir, "rev-list", "--count", "#{base}..#{branch}").to_i.positive?
|
|
@@ -1245,11 +1267,12 @@ end
|
|
|
1245
1267
|
goals.each { |goal| puts goal_line(goal) }
|
|
1246
1268
|
end
|
|
1247
1269
|
|
|
1248
|
-
def show(id)
|
|
1270
|
+
def show(id, artifacts_dir)
|
|
1249
1271
|
goal = find!(id)
|
|
1250
1272
|
puts goal_line(goal)
|
|
1251
1273
|
list = @tasks.for_goal(goal["uuid"])
|
|
1252
1274
|
list.empty? ? puts(" no open tasks") : list.each { |task| puts task_line(task) }
|
|
1275
|
+
plan_hint(goal["uuid"], artifacts_dir)
|
|
1253
1276
|
end
|
|
1254
1277
|
|
|
1255
1278
|
def done(id)
|
|
@@ -1266,6 +1289,13 @@ end
|
|
|
1266
1289
|
|
|
1267
1290
|
private
|
|
1268
1291
|
|
|
1292
|
+
def plan_hint(uuid, artifacts_dir)
|
|
1293
|
+
plan = File.join(artifacts_dir, uuid, "plan.md")
|
|
1294
|
+
return unless File.exist?(plan) && !@tasks.any_for_goal?(uuid)
|
|
1295
|
+
|
|
1296
|
+
puts " plan ready, no tasks: #{plan}. The plan waits for approval."
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1269
1299
|
def goal_line(goal)
|
|
1270
1300
|
open = @tasks.for_goal(goal["uuid"]).size
|
|
1271
1301
|
[goal["uuid"], goal["description"], self.class.branch(goal["uuid"]), "open=#{open}"].join("\t")
|
|
@@ -1286,7 +1316,7 @@ end
|
|
|
1286
1316
|
end
|
|
1287
1317
|
|
|
1288
1318
|
def start(uuid, base)
|
|
1289
|
-
|
|
1319
|
+
refuse_dirty!
|
|
1290
1320
|
|
|
1291
1321
|
branch = "task/#{Goals.short(uuid)}"
|
|
1292
1322
|
raise Error, "git checkout #{branch} failed" unless checkout(branch, base)
|
|
@@ -1296,6 +1326,17 @@ end
|
|
|
1296
1326
|
|
|
1297
1327
|
private
|
|
1298
1328
|
|
|
1329
|
+
FLOW_FILES = "These are files of the flow, and git tracks them. In the main checkout, run maf untrack and commit."
|
|
1330
|
+
|
|
1331
|
+
def refuse_dirty!
|
|
1332
|
+
paths = Git.changed(@dir)
|
|
1333
|
+
return if paths.empty?
|
|
1334
|
+
|
|
1335
|
+
flow = paths.all? { |path| path.start_with?(".maf/") || Worktree::RUNTIME_FILES.include?(path) }
|
|
1336
|
+
raise Error, ["uncommitted changes in #{@dir}: #{paths.first(5).join(", ")}#{" ..." if paths.size > 5}.",
|
|
1337
|
+
flow ? FLOW_FILES : "Commit or stash them first."].join(" ")
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1299
1340
|
def checkout(branch, base)
|
|
1300
1341
|
return Git.run(@dir, "checkout", "-q", branch) if Git.branch?(@dir, branch)
|
|
1301
1342
|
|
|
@@ -1351,8 +1392,8 @@ end
|
|
|
1351
1392
|
# commit. The task branch is then deleted. A worker worktree that still has
|
|
1352
1393
|
# the task branch checked out moves back to its worker branch first.
|
|
1353
1394
|
class Land
|
|
1354
|
-
def initialize(root, task)
|
|
1355
|
-
@root, @task = root, task
|
|
1395
|
+
def initialize(root, task, base)
|
|
1396
|
+
@root, @task, @base = root, task, base
|
|
1356
1397
|
end
|
|
1357
1398
|
|
|
1358
1399
|
def run(subject)
|
|
@@ -1381,8 +1422,14 @@ end
|
|
|
1381
1422
|
return nil unless Git.own_commits?(@root, goal_branch, branch)
|
|
1382
1423
|
raise Error, STALE % { branch: branch, goal: goal_branch } unless Git.ancestor?(@root, goal_branch, branch)
|
|
1383
1424
|
|
|
1425
|
+
parent = base_parent
|
|
1384
1426
|
merge_squash
|
|
1385
|
-
commit(SquashMessage.for(@task, subject))
|
|
1427
|
+
commit(SquashMessage.for(@task, subject), parent)
|
|
1428
|
+
end
|
|
1429
|
+
|
|
1430
|
+
def base_parent
|
|
1431
|
+
held = Git.out(@root, "merge-base", Git.base_ref(@root, @base), branch)
|
|
1432
|
+
held unless held.empty? || Git.ancestor?(@root, held, goal_branch)
|
|
1386
1433
|
end
|
|
1387
1434
|
|
|
1388
1435
|
STALE = "branch %<branch>s lacks the head of %<goal>s. Open a fix task: merge %<goal>s into %<branch>s."
|
|
@@ -1395,14 +1442,30 @@ end
|
|
|
1395
1442
|
end
|
|
1396
1443
|
|
|
1397
1444
|
# coord makes this commit, not the role, so the commit guard does not apply.
|
|
1398
|
-
def commit(message)
|
|
1399
|
-
return nil if Git.run(goal_dir, "diff", "--cached", "--quiet")
|
|
1445
|
+
def commit(message, parent)
|
|
1446
|
+
return nil if Git.run(goal_dir, "diff", "--cached", "--quiet") && parent.nil?
|
|
1447
|
+
|
|
1448
|
+
parent ? merge_commit(message, parent) : squash_commit(message)
|
|
1449
|
+
Git.out(goal_dir, "rev-parse", "--short", "HEAD")
|
|
1450
|
+
end
|
|
1400
1451
|
|
|
1452
|
+
def squash_commit(message)
|
|
1401
1453
|
args = ["git", "-C", goal_dir, "commit", "-q", "--no-verify", "-F", "-"]
|
|
1402
1454
|
IO.popen(args, "w") { |io| io.write(message) }
|
|
1403
1455
|
raise Error, "git commit on #{goal_branch} failed" unless $?.success?
|
|
1456
|
+
end
|
|
1404
1457
|
|
|
1405
|
-
|
|
1458
|
+
def merge_commit(message, parent)
|
|
1459
|
+
head = Git.out(goal_dir, "rev-parse", "HEAD")
|
|
1460
|
+
sha = commit_tree(message, head, parent)
|
|
1461
|
+
moved = sha && Git.run(goal_dir, "update-ref", "HEAD", sha, head)
|
|
1462
|
+
raise Error, "git commit on #{goal_branch} failed" unless moved
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
def commit_tree(message, *parents)
|
|
1466
|
+
args = ["commit-tree", Git.out(goal_dir, "write-tree"), *parents.flat_map { |sha| ["-p", sha] }, "-F", "-"]
|
|
1467
|
+
sha, status = Open3.capture2("git", "-C", goal_dir, *args, stdin_data: message)
|
|
1468
|
+
sha.strip if status.success?
|
|
1406
1469
|
end
|
|
1407
1470
|
|
|
1408
1471
|
def release_branch
|
|
@@ -1648,6 +1711,18 @@ end
|
|
|
1648
1711
|
raise Error, "git push of #{branch} to #{remote} failed"
|
|
1649
1712
|
end
|
|
1650
1713
|
|
|
1714
|
+
def push_with_base(branch, base)
|
|
1715
|
+
push_missing(base)
|
|
1716
|
+
push(branch)
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1719
|
+
def push_missing(branch)
|
|
1720
|
+
return if !Git.branch?(@root, branch) || Git.remote_branch?(@root, remote, branch)
|
|
1721
|
+
|
|
1722
|
+
push(branch)
|
|
1723
|
+
puts "pushed #{branch}: #{remote} had no branch #{branch}"
|
|
1724
|
+
end
|
|
1725
|
+
|
|
1651
1726
|
def create_pr(head:, base:, title:, body:)
|
|
1652
1727
|
args = ["--head", head, "--base", base, "--title", title, "--reviewer", reviewer, "--body-file", "-"]
|
|
1653
1728
|
gh("pr", "create", "--repo", repo, *args, input: body).lines.last.to_s.strip
|
|
@@ -2086,7 +2161,10 @@ end
|
|
|
2086
2161
|
def timeout = (v = value_of("--timeout")) && v.to_i
|
|
2087
2162
|
|
|
2088
2163
|
def require_arg(command, name)
|
|
2089
|
-
|
|
2164
|
+
value = shift
|
|
2165
|
+
abort("usage: coord #{command} #{name}") if value.nil? || value.start_with?("-")
|
|
2166
|
+
|
|
2167
|
+
value
|
|
2090
2168
|
end
|
|
2091
2169
|
|
|
2092
2170
|
def require_text(command)
|
|
@@ -2244,6 +2322,42 @@ end
|
|
|
2244
2322
|
end
|
|
2245
2323
|
end
|
|
2246
2324
|
|
|
2325
|
+
# Block commands hold a task out of coord next, tell the role that can
|
|
2326
|
+
# remove the blocker, and return the task.
|
|
2327
|
+
module BlockCommands
|
|
2328
|
+
BLOCK_KINDS = %w[needs_input dependency capability failing].freeze
|
|
2329
|
+
BLOCK_USAGE = "usage: coord block ID [--kind #{BLOCK_KINDS.join("|")}] TEXT...".freeze
|
|
2330
|
+
|
|
2331
|
+
def cmd_block
|
|
2332
|
+
kind = block_kind(@args.value_of("--kind") || BLOCK_KINDS.first)
|
|
2333
|
+
id = @args.require_arg("block", "ID")
|
|
2334
|
+
block(id, kind, @args.require_text("block"))
|
|
2335
|
+
puts "blocked #{id} (#{kind})"
|
|
2336
|
+
end
|
|
2337
|
+
|
|
2338
|
+
def block(id, kind, text)
|
|
2339
|
+
tasks.annotate(id, "BLOCKED (#{kind}): #{text}")
|
|
2340
|
+
tasks.block(id)
|
|
2341
|
+
event_log.append("block", @worker, "#{id} #{kind} #{text}")
|
|
2342
|
+
notify_block(id, kind, text)
|
|
2343
|
+
end
|
|
2344
|
+
|
|
2345
|
+
def block_kind(kind) = BLOCK_KINDS.include?(kind) ? kind : abort(BLOCK_USAGE)
|
|
2346
|
+
|
|
2347
|
+
def notify_block(id, kind, text)
|
|
2348
|
+
return deliver("architect", @role, "BLOCKED task #{id} (dependency), #{@worker}: #{text}") if kind == "dependency"
|
|
2349
|
+
|
|
2350
|
+
send_escalation(id, "blocked (#{kind}): #{text}")
|
|
2351
|
+
end
|
|
2352
|
+
|
|
2353
|
+
def cmd_unblock
|
|
2354
|
+
id = @args.require_arg("unblock", "ID")
|
|
2355
|
+
tasks.unblock(id)
|
|
2356
|
+
event_log.append("unblock", @worker, id)
|
|
2357
|
+
puts "unblocked #{id}"
|
|
2358
|
+
end
|
|
2359
|
+
end
|
|
2360
|
+
|
|
2247
2361
|
# Claim commands take, release, note, and finish a task.
|
|
2248
2362
|
module ClaimCommands
|
|
2249
2363
|
def cmd_claim
|
|
@@ -2457,8 +2571,9 @@ end
|
|
|
2457
2571
|
event_log.append("broadcast", @worker, text)
|
|
2458
2572
|
end
|
|
2459
2573
|
|
|
2460
|
-
NO_PUSH = "coord: %<role>s has no live session and no message hook.
|
|
2461
|
-
"
|
|
2574
|
+
NO_PUSH = "coord: %<role>s has no live session and no message hook. " \
|
|
2575
|
+
"The message waits until a session of %<role>s starts. " \
|
|
2576
|
+
"Fix: maf start HARNESS %<role>s --dispatch --detach, " \
|
|
2462
2577
|
"or add an executable .maf/coordination/message-hooks/%<role>s.sh"
|
|
2463
2578
|
|
|
2464
2579
|
# A message is pushed by a hook or read by a live session. Without both,
|
|
@@ -2567,7 +2682,7 @@ end
|
|
|
2567
2682
|
def cmd_land
|
|
2568
2683
|
subject = @args.value_of("--subject")
|
|
2569
2684
|
task = find_task!(@args.require_arg("land", "ID"))
|
|
2570
|
-
land = Land.new(main_root, task)
|
|
2685
|
+
land = Land.new(main_root, task, Git.base_branch(main_root, manifest))
|
|
2571
2686
|
commit = locks.with_mutex("land-#{Goals.short(task["goalid"])}", worker: @worker) { land.run(subject) }
|
|
2572
2687
|
record_landing(task, commit, land)
|
|
2573
2688
|
end
|
|
@@ -2590,7 +2705,7 @@ end
|
|
|
2590
2705
|
setup.ensure_taskrc
|
|
2591
2706
|
goals.find!(id)["uuid"]
|
|
2592
2707
|
end
|
|
2593
|
-
def goal_show = goals.show(@args.require_arg("goal show", "ID"))
|
|
2708
|
+
def goal_show = goals.show(@args.require_arg("goal show", "ID"), File.join(paths.coord_dir, "artifacts"))
|
|
2594
2709
|
|
|
2595
2710
|
def goal_sync
|
|
2596
2711
|
goal = goals.find!(@args.require_arg("goal sync", "ID"))
|
|
@@ -2620,11 +2735,18 @@ end
|
|
|
2620
2735
|
raise Error, "base branch #{base} does not exist" unless Git.branch?(main_root, base)
|
|
2621
2736
|
|
|
2622
2737
|
uuid = tasks.add(title: opts[:title], role: Goals::ROLE, ref: opts[:ref])
|
|
2623
|
-
|
|
2738
|
+
open_goal_branch(uuid, base)
|
|
2624
2739
|
event_log.append("goal", @worker, opts[:title])
|
|
2625
2740
|
puts uuid
|
|
2626
2741
|
end
|
|
2627
2742
|
|
|
2743
|
+
def open_goal_branch(uuid, base)
|
|
2744
|
+
Worktree.new(main_root).checkout(Goals.slug(uuid), Goals.branch(uuid), base)
|
|
2745
|
+
return if !Git.run(main_root, "remote", "get-url", "origin") || Git.remote_branch?(main_root, "origin", base)
|
|
2746
|
+
|
|
2747
|
+
warn "coord: origin has no branch #{base}. coord goal pr pushes the local #{base} again."
|
|
2748
|
+
end
|
|
2749
|
+
|
|
2628
2750
|
UNSYNCED = "coord: %<branch>s lacks the head of %<ref>s. Run coord goal sync %<id>s. " \
|
|
2629
2751
|
"Run the merge suite again. Then run coord goal done again."
|
|
2630
2752
|
|
|
@@ -2718,7 +2840,7 @@ end
|
|
|
2718
2840
|
|
|
2719
2841
|
def publish_goal(goal, entry)
|
|
2720
2842
|
branch = Goals.branch(goal["uuid"])
|
|
2721
|
-
github.
|
|
2843
|
+
github.push_with_base(branch, Git.base_branch(main_root, manifest))
|
|
2722
2844
|
push_memory
|
|
2723
2845
|
entry = entry ? update_pr(goal, entry, branch) : { "url" => open_pr(goal, branch), "seen" => [] }
|
|
2724
2846
|
review_state.put(goal["uuid"], entry.merge("head" => Git.out(main_root, "rev-parse", branch)))
|
|
@@ -2805,6 +2927,7 @@ end
|
|
|
2805
2927
|
def cmd_worktree
|
|
2806
2928
|
role = @args.require_arg("worktree", "ROLE")
|
|
2807
2929
|
worker = @args.shift
|
|
2930
|
+
abort "usage: coord worktree ROLE [WORKER]" if worker&.start_with?("-")
|
|
2808
2931
|
warn_missing_role_files(role)
|
|
2809
2932
|
Worktree.new(@project_root).create(role, worker)
|
|
2810
2933
|
end
|
|
@@ -2900,6 +3023,7 @@ end
|
|
|
2900
3023
|
class CLI
|
|
2901
3024
|
include TaskCommands
|
|
2902
3025
|
include ClaimCommands
|
|
3026
|
+
include BlockCommands
|
|
2903
3027
|
include MessageCommands
|
|
2904
3028
|
include InboxCommands
|
|
2905
3029
|
include GoalCommands
|