diff --git a/first-mate-skill/README.md b/first-mate-skill/README.md index ed46ffc..b8f6549 100644 --- a/first-mate-skill/README.md +++ b/first-mate-skill/README.md @@ -14,9 +14,10 @@ network hosts. ```text ~/.agents/skills/paseo-first-mate/ - SKILL.md the skill (version 1.2.0 at authorship) + SKILL.md the skill (version 1.3.0) assets/first-mate-check.py change detector (prints nothing when nothing changed) - assets/first-mate-wait.sh background wait loop around the detector + assets/first-mate-wait.sh background wait loop around the detector; one loop per + FIRST_MATE_DIR, owned by the starting agent (flock) ``` `~/.agents/skills` is the cross-agent skills root that the supported agents scan, @@ -53,7 +54,7 @@ The detector is a Python 3 script and the wait loop is bash. They call the `gh` and `paseo` CLIs when the agent runs them. This kit installs none of `python3`, `gh`, or `paseo` and does not allow-list `api.github.com`; kits cannot declare dependencies yet, so those must come from the sandbox image, its other kits, and -secret bindings. A missing tool is reported rather than hidden: the detector +secret bindings. The wait loop also needs `flock` (util-linux) and keys its lock on `PASEO_AGENT_ID`. Use `first-mate-wait.sh status` to see whether a loop is running and who owns it. A missing tool is reported rather than hidden: the detector prints an `ERROR` line naming the missing CLI, and the shell reports a missing `python3`. The detector only reads GitHub and Paseo state; its only writes are the state and watch files, and the directory that holds them, `FIRST_MATE_DIR` diff --git a/first-mate-skill/TROUBLESHOOTING.md b/first-mate-skill/TROUBLESHOOTING.md index 1667183..b6ac81c 100644 --- a/first-mate-skill/TROUBLESHOOTING.md +++ b/first-mate-skill/TROUBLESHOOTING.md @@ -37,12 +37,28 @@ That is the intended quiet path: once a baseline exists, it prints nothing when nothing changed, and the wait loop exits 3 at its cap. The very first run prints `BASELINE saved` (even with an empty watch list, because agents and permission prompts are always collected), so the wait loop returns once at first start; -restart it after that. If `paseo` is missing the baseline never settles (see the +start it again after that (check `status` first). If `paseo` is missing the baseline never settles (see the previous section), so the loop returns every time. Run the script with `--show` to see the full current snapshot, and `list` to see what is being watched. State and watch files live in `FIRST_MATE_DIR` (default `./tmp`), relative to where you run the script. +## Several wait loops, or "refusing: a loop is running" + +The wait script allows one loop per `FIRST_MATE_DIR`, owned by the agent whose +`PASEO_AGENT_ID` started it. A second start exits 4 (you own it) or 5 (another +agent owns it) and starts nothing. + +- `first-mate-wait.sh status` prints `none`, `mine ...`, or `other owner= ...`. +- `first-mate-wait.sh stop` stops your loop. `stop --force` stops another owner's. +- `first-mate-wait.sh take-over INTERVAL MAX` replaces another owner's loop. Use + it only when the user has said you are the coordinator. +- The lock is an `flock` on `FIRST_MATE_DIR/.wait.lock`, so it frees itself when the + loop dies. If you still see several loops, they are from an older copy of the + script or from different `FIRST_MATE_DIR` values (each directory has its own lock). +- A forked or resumed session inherits the handoff's claim that a loop is running. + That claim is not evidence; run `status`. + ## An edit to the skill does not show up in a running sandbox The kit is applied from a pinned SHA. Push the change, pin the new SHA, and diff --git a/first-mate-skill/docs/decisions/0001-ship-the-first-mate-skill-as-a-snapshot-kit.md b/first-mate-skill/docs/decisions/0001-ship-the-first-mate-skill-as-a-snapshot-kit.md index 5379c89..3f5181d 100644 --- a/first-mate-skill/docs/decisions/0001-ship-the-first-mate-skill-as-a-snapshot-kit.md +++ b/first-mate-skill/docs/decisions/0001-ship-the-first-mate-skill-as-a-snapshot-kit.md @@ -60,3 +60,11 @@ The skill is still being developed in a separate working copy. SHA. - The spec's `files[]` list and the `files/` tree must be kept equal by hand; `scripts/verify` enforces it, because msb materializes only listed records. + +## Update 2026-10-31: skill 1.3.0 + +The snapshot moved to skill version 1.3.0. `first-mate-wait.sh` now holds an +`flock` keyed on the caller's `PASEO_AGENT_ID`, and `scripts/test-wait-lock` +(run from `scripts/verify`) covers it offline with a stub detector. This changes +the shipped script, not the kit's shape: still files only, no commands, no +egress. The script now also requires `flock` from util-linux on the sandbox image. diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md index d79927f..85c1a1d 100644 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md @@ -1,7 +1,7 @@ --- name: paseo-first-mate id: paseo-first-mate -version: "1.2.0" +version: "1.3.0" title: "Paseo First Mate" description: "Coordinate a multi-issue engineering effort through isolated Paseo workspaces, structured GitHub metadata, dependency-aware delivery, change-detecting follow-up, and a durable handoff." type: skill @@ -71,6 +71,8 @@ portability: chatgpt: false generic_llm: false changelog: + - version: "1.3.0" + changes: "One coordinator and one wait loop per effort (owner-keyed flock in the wait script with status, stop, and take-over; forks and resumed sessions are non-coordinators; never report a loop as running without a status result from this turn). Detector watches branches and workflows. Fresh-base rule, verified agent claims, and reading finish notices against agent status. Runtime acceptance signal, mutation proof, and post-merge proof. Repository PR standards override the template. Pending Decisions and Held Items handoff section, recorded permission policies, shape-only host reads, drafts for human threads, cleanup on merge, and new troubleshooting rows." - version: "1.2.0" changes: "Add a change-detecting check and background wait loop (bundled assets) as the token-cheap default, alongside the Paseo heartbeat; add the end-the-turn rule for quiet wake-ups, standing ready-when-green grant, agent self-review and reuse rules, filing rule, host-run script hygiene, reviewer reply guidance, and new troubleshooting rows." - version: "1.1.0" @@ -135,6 +137,10 @@ Single-change and read-only work are covered by `avoid_when` above. `ready-push` level (see Delivery Levels) is the only exception to waiting for CI before a PR is marked ready. 6. Never present mocked, offline, or simulated evidence as live end-to-end proof. + A green pipeline is not proof that the deployed thing runs: a deploy can + succeed while the application it created crash-loops. Name the runtime signal + up front (for example, the process count and a specific startup log line), and + keep the issue unverified until someone has observed that signal. 7. Label every claim as verified, hypothesis, or unverified. Fix the live acceptance criterion before implementation starts. 8. Never archive dirty, unpushed, unrecoverable, or intentionally retained work. @@ -144,6 +150,11 @@ Single-change and read-only work are covered by `avoid_when` above. privately and ask the user how to proceed. Do not file them as public issues. 11. Never merge, and never change PR draft/ready state, beyond what the user has explicitly granted. +12. One coordinator per effort. A resumed or forked session is a non-coordinator + until the user says it is the coordinator (see section 1). A non-coordinator + starts no wait loop and takes no action on the watched PRs and agents. + `take-over` and `stop --force` act on another agent's loop and need the user's + explicit word each time; a standing grant does not cover them. ## Delivery Levels @@ -181,16 +192,24 @@ the handoff. ### 1. Adopt or Establish the Role -When resuming an existing effort: +When resuming an existing effort, or when this session is a fork of another: 1. Read the handoff. -2. Refresh authoritative remote state before reporting anything. -3. Verify that every control the handoff names actually exists on the accessible - daemon: wait loop, schedules, workspaces, agents. Record any control that is - missing or invisible; do not assume its state, resume it, or recreate it - without authorization. Reconcile workspaces against `paseo workspace ls`; - retained-workspace notes may be stale in a fresh sandbox. -4. Start the wait loop (section 8) if none is running. +2. Compare the coordinator ID in its Controls with your own `$PASEO_AGENT_ID`. + If they differ, or none is recorded, you are a non-coordinator: do steps 3 + onward read-only (use `first-mate-check.py --show`, which saves nothing), + start no loop, and ask the user whether you are taking over. A fork inherits + the handoff, including its claim that a loop is running; that claim is not + evidence. +3. Refresh authoritative remote state before reporting anything. +4. Verify that every control the handoff names actually exists on the accessible + daemon: wait loop (`first-mate-wait.sh status`), schedules, workspaces, + agents. Record any control that is missing or invisible; do not assume its + state, resume it, or recreate it without authorization. Reconcile workspaces + against `paseo workspace ls`; retained-workspace notes may be stale in a fresh + sandbox. +5. As the confirmed coordinator, ensure one wait loop is running (section 8). + Record your agent ID as coordinator in the handoff. ### 2. Establish the Mission @@ -284,11 +303,33 @@ Reuse and self-review: it to commit, self-review, and report. - Identify agents and workspaces you did not create, leave them alone, do not count them against the cap, and mention them once. +- **Verify agent claims about the baseline before repeating them.** "Fails on + main", "pre-existing failure", "CI started", and "no conflicts" are claims about + state the agent may not be able to see (a stale base, a token that cannot read + check runs, a tool missing from its sandbox). Reproduce them on a clean base, + or read the exact-head check results yourself, and label what you could not + confirm. +- **Read a finish notice against the agent's status and your last prompt.** A + finished notice can be the end of an earlier turn, not the reply to your latest + follow-up. If the agent shows as running or idle with no new activity after your + prompt, say that you cannot tell yet; do not describe a result you have not + read. Permissions: auto-accept is usually a per-workspace UI toggle the CLI does not expose, so ask the user to set it after creating a workspace. A provider "build" mode may not suppress prompts; check pending permissions and the daemon log. -Never approve a request unless the user instructs it. +Never approve a request unless the user instructs it. A standing policy the user +has recorded in the handoff (for example, routine web search for public +questions) counts as an instruction, within its stated scope. Some tools need a +specific response shape; if a plain "allow" fails with a validation error, retry +in the shape the error names and record the working form in the handoff. + +Base of a new workspace: `git fetch` first and create the workspace from +`origin/` (or the exact head of the PR you depend on), never from +a local branch that may be stale. Record the base SHA, put it in the delegation +prompt, and tell the agent to confirm `git merge-base HEAD origin/` +equals it before its first edit. A stale base shows up later as a conflicting PR +and as false claims such as "this test already fails on main". Branching from an in-flight PR: branch from its exact head, expect a rebase after it lands, and do not mark the new work ready first. If an SSH fetch fails @@ -301,6 +342,11 @@ required focused tests and agent-launched self-review; the draft/ready/merge gua (push levels only); and "if the result contradicts the premise, report it and do not fix it." +For a new guard, check, or test, also require a mutation proof: the agent reverts +the fix, shows the test fails, restores it, and says what the test does not cover. +A test that cannot fail gives false assurance, and a test that asserts only that a +string exists is not a behavior test. + Use the completion clause that matches the delivery level: ```text @@ -368,7 +414,14 @@ Before significant pushes, ask the reviewer to inspect only the changed risk surface and named prior blockers. Broad reviews that keep inventing unrelated scope make delivery non-terminating. -PR bodies use plain language and stay concise: +Read the target repository's own PR standard first (its contributing guide, agent +instructions, and PR template). Where it conflicts with the template below, the +repository wins: follow its title and body prefixes, closing-keyword rules +(`Refs` versus `Closes`), attribution rules, and required sections, and put +them in every delegation prompt. When the user points at a newer or draft +standard, treat it as the target and bring open PR bodies in line with it. + +Without a repository standard, PR bodies use plain language and stay concise: ```markdown ## Summary @@ -395,10 +448,14 @@ one the user prefers and record the choice and any later change in the handoff: - **Background wait loop (default, cheapest).** A shell loop that runs the check on an interval and exits only when something changed. The harness notifies the - coordinator when it finishes, so waiting costs no model calls. + coordinator when it finishes, so waiting costs no model calls. There is at most + one loop per `FIRST_MATE_DIR`, and it is owned by the agent that started it. - **Paseo heartbeat.** A scheduled prompt that runs the check each time. Use `paseo heartbeat update --cron ...` to change its cadence (for example, hourly while nothing is moving, every 15 minutes once something changes). + Prefer it when the user plans to fork or resume sessions often: a heartbeat is a + named schedule, so it is one object that no fork can duplicate, at the price of a + model call per run. Either way, a quiet wake-up must not turn into filler. Empty calls (`true`, `date > /dev/null`) cost tokens, change nothing, and can run away into hundreds @@ -407,33 +464,61 @@ of calls. Run the check once, then end the turn. Bundled assets: - `assets/first-mate-check.py` queries the watched PRs (head, draft state, reviews, - inline comments, check results, merge state), issues, releases, other agents, - and pending permission prompts in parallel, compares them with a saved + inline comments, check results, merge state), issues, releases, **branches** + (each new commit), **workflows** (latest run on a branch: head, status, + conclusion), other agents, and pending permission prompts in parallel, compares them with a saved snapshot, and prints **nothing** when nothing changed. A transient API error keeps the previous value and is reported. Manage the watch list with `add`, - `remove`, `prune`, `repo`, and `list`; `--show` prints the snapshot without + `remove`, `prune`, `repo`, and `list` (for example `add branch kits@main`, + `add workflow kits:deploy.yml@main`); `--show` prints the snapshot without saving, and `--reset` forgets state. State and watch files live in `FIRST_MATE_DIR` (default `./tmp`, which must be ignored by git). - `assets/first-mate-wait.sh INTERVAL_MIN MAX_MIN` runs the check on an interval and exits as soon as it reports a change (exit 0, report printed) or at the cap - (exit 3, nothing printed). + (exit 3, nothing printed). It holds an `flock` on `FIRST_MATE_DIR/.wait.lock` + and records the owner (`$PASEO_AGENT_ID`), pid, and start time. The lock frees + itself when the process dies, so there is no stale pidfile. Starting while a + loop is running never starts a second one: exit 4 if you own it, exit 5 if + another agent does. Other forms: + - `status` prints `none`, `mine ...`, or `other owner= ...`. + - `stop` stops your own loop; `stop --force` stops another owner's. + - `take-over INTERVAL MAX` stops any running loop and starts yours. Use it only + after the user confirms you are the coordinator. Procedure: 1. Copy both assets next to each other into the coordinator worktree's ignored - `tmp/`, register the repositories, and add the watch list. -2. For the wait loop, start the wait script as a **background** shell command. - For a heartbeat, have its prompt run `first-mate-check.py` once. + `tmp/`, register the repositories, and add the watch list. Always watch the + default branch of every repository in scope, and the deploy or release + workflows that the effort's milestone depends on. A merge you did not make + (another session, another person) and a pipeline that stays red after your + merge are both invisible to a PR-only watch list. +2. For the wait loop, run `first-mate-wait.sh status` first, then start the + script as a **background** shell command only if it says `none`. For a + heartbeat, have its prompt run `first-mate-check.py` once. 3. Use a 5-minute interval while work is in flight (an agent running, CI pending, or a review or live run awaited) and 15 minutes when quiet. Use a cap of about - two hours on the wait loop, and restart it after handling each notification. + two hours on the wait loop. After handling a notification, ensure a loop is + running (`status`, then start if `none`); never start one blindly. To change + the interval, `stop` and start again. Exit code 4 on a start means the loop + you wanted is already there, so do nothing more. 4. **Quiet wake-up rule:** if the check reports nothing, make no further tool calls and end the turn. Whether to say "no change" follows the user's current preference; record it in the handoff. Say it only when it is true: the check ran and reported nothing, never as a guess. 5. On a change, read the report, act only on concrete changes, update the handoff, report meaningful changes or blockers to the user, and start the next wait. + When a branch report shows a merge, reconcile before anything else: which + open recommendations, agents, and PRs does it supersede, conflict with, or + unblock? Another session may have fixed the problem differently. 6. Prune merged and closed items from the watch list. +7. Report a loop as running only from a `status` result obtained this turn, + never from the handoff or from memory. + +The detector saves its snapshot when it reports. Two agents running it against +one `FIRST_MATE_DIR` split the events between them, and one never sees a change +the other consumed. Only the coordinator runs it in saving mode; everyone else +uses `--show`. The check resolves exact-head state, so stale cached PR check fields cannot mislead it. It cannot see GitHub notifications or review requests on PRs outside @@ -470,10 +555,12 @@ Updated: YYYY-MM-DD HH:MM UTC ## Immediate Next Actions ## Workspace Safety ## Fast-Track Grants +## Pending Decisions and Held Items ## Wait Log ``` -- **Controls:** wait mechanism (loop or heartbeat), interval, and cap, schedule status (or "not visible"), +- **Controls:** coordinator agent ID (`$PASEO_AGENT_ID` of the owner), wait + mechanism (loop or heartbeat), interval, and cap, schedule status (or "not visible"), agent cap, coordinator and delegated models, permission setup per workspace. - **Current Coordination State:** the first section read after each wake-up. Keep the whole file near 200 lines. @@ -481,7 +568,16 @@ Updated: YYYY-MM-DD HH:MM UTC durable facts into the state sections, save a gitignored backup under `tmp/archive/`, then empty the log. - **Standing rules:** record every user process rule with its date: delivery - grants, ready-when-green, filing rule, self-review rule, agent reuse. + grants, ready-when-green, filing rule, self-review rule, agent reuse, and + standing answers to routine permission prompts (for example, "web search is + allowed for public questions"). Apply a recorded policy; ask about anything it + does not cover. +- **Pending Decisions and Held Items:** add this section to the template. For each + open question, record your recommendation, the options, and the trigger that + would make it urgent, so the user can ask "what did you think?" and get the + same answer after a compaction. Held security findings are listed by title only, + with a pointer to a private note outside the repository; the note's contents + and any attack detail stay out of the handoff and every public place. - **Prune stale detail** once superseded: old head SHAs, retention notes for workspaces that no longer exist, and debugging narratives. @@ -504,6 +600,11 @@ Archive a workspace only when all conditions are true: Dirty files, untracked files, staged changes, unpushed commits, unavailable owners, or named retention are hard stops. +When a PR merges, retire what it owned in one pass: archive its idle agent and +workspace, drop it from the watch list, and delete the local branch only if it +matches the remote branch exactly. Do not touch agents or workspaces you did not +create. + ### 11. Close the Loop After upstream merges: @@ -520,6 +621,14 @@ After upstream merges: - Close delivered issues through structured metadata. - Update the epic status and handoff. - Surface work that the merge unblocked. +- **Post-merge proof.** Some changes can only be proven by the first real run + after merge (a deploy, a release, a scheduled job). State that in the PR and the + handoff, add the run to the watch list (section 8), and write the pass criterion + as an observable (for example, "two consecutive runs plan zero changes on X" + or "the app shows 1/1 and logs its ready line"). Keep the item open until it is + observed. When it fails, read the failing step's log before reporting, and + separate "my change did not work" from "my change worked and exposed the next + failure". Do not declare the milestone complete until production wiring, downstream consumers, current-head CI, review, and live evidence agree. @@ -530,6 +639,21 @@ consumers, current-head CI, review, and live evidence agree. PR, its docs, and results folders. If earlier runs do not cover the new check, say exactly what was logged, what it did not cover, and why the new run is needed. Results must be tied to the tested revision. +- **Read live systems with shape-only commands.** When you need facts from an + environment only the user can reach, ask for output that cannot carry secrets: + names, counts, and key lists, never values and never an environment dump. Give + one pasteable command that filters to that shape (for example, `jq` selecting + credential key names), and say what the output can and cannot show. +- **Drafts for human threads.** Before posting on a reviewer's or user's thread, + show the draft, give your recommendation and the options (post, change, + remove), and wait. Post with the repository's attribution prefix, and keep the + wording neutral about anything held under invariant 10. +- **Name the cause before blaming it.** A root-cause statement cites the log line + or `file:line` that locates it, and says which resource it concerns. Correct a + mislabeled cause as soon as you notice it, in the report and in the handoff. +- **Report what the sandbox lacks.** A missing tool (a package manager, a + validator, a container runtime) is a fact, not a reason to skip a check + silently. Say which checks did not run, and ask before installing anything. - **Host-run scripts.** When the user must run something on their machine, give one line starting with a `cd` to the right worktree, or a script plus a one-line invocation. Scripts must: @@ -593,6 +717,8 @@ Before declaring coordination complete, verify: - Live results are observed, not simulated. - Required workspaces are retained; safe obsolete workspaces are archived. - The handoff reflects the final state and its log is cleared. +- Exactly one coordinator is recorded, and `first-mate-wait.sh status` was run + this turn before any statement about the loop. - The wait loop or heartbeat is stopped and its state file cleared when coordination ends. ## Troubleshooting @@ -607,6 +733,9 @@ Before declaring coordination complete, verify: | Branches diverge across worktrees | Compare commits and content; replay only missing reviewed commits with lease protection. | | Dependent PR conflicts after upstream merge | Preserve work, restack on current base, rerun focused checks, and refresh draft CI. | | Disk cleanup threatens blocked work | Retain dirty or dependency-blocked workspaces and record them in the handoff. | +| Several wait loops, or duplicate wake-ups | Run `first-mate-wait.sh status`. With the lock there is at most one loop; duplicates come from an older copy of the script or a different `FIRST_MATE_DIR`. Stop the extras, and check for a forked session acting as a second coordinator. | +| A forked session inherits "wait loop running" from the handoff | Treat it as unverified. The fork is a non-coordinator: no loop, read-only `--show`, and ask the user before taking over. | +| Start exits 5 (another owner holds the loop) | Do not force it. Ask the user who the coordinator is; use `take-over` only on their word. | | A quiet turn fills with empty tool calls | Stop. Run the check once and end the turn (section 8). | | Host-run script stalls with processes in state `T` | Job control froze the child. Use `timeout --foreground` and show live output. | | Reviewer's change request remains after the fix | Reply with the commits and wait for re-review; a push does not clear it. | diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py index 15f38f2..4010667 100755 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-check.py @@ -10,7 +10,14 @@ What to watch lives in first-mate-watch.json, not in this script: {"repos": {"qs": "OWNER/repo", ...}, "prs": ["qs#611", ...], "issues": ["qs#503", ...], - "releases": ["qs", ...], "ignore_agents": ["First Mate"]} + "releases": ["qs", ...], + "branches": ["qs@main", ...], + "workflows": ["qs:deploy-dev.yml@main", ...], + "ignore_agents": ["First Mate"]} + +A watched branch reports each new commit on it (so work that lands from other +sessions is seen). A watched workflow reports the latest run on a branch: its +head, status, and conclusion (so a deploy that stays red after a merge is seen). Usage: first-mate-check.py compare to saved state, print changes, save @@ -18,7 +25,10 @@ first-mate-check.py --reset forget saved state (next run is a baseline) first-mate-check.py list print the watch list first-mate-check.py add pr qs#637 start watching (also: issue; also OWNER/repo#N) - first-mate-check.py remove pr qs#611 stop watching and forget its state + first-mate-check.py add branch qs@main watch new commits on a branch + first-mate-check.py add workflow qs:deploy-dev.yml@main + watch the latest run of a workflow on a branch + first-mate-check.py remove pr qs#611 stop watching and forget its state (also: issue, branch, workflow) first-mate-check.py repo qs OWNER/repo register a repo alias first-mate-check.py prune stop watching merged/closed PRs and closed issues """ @@ -40,9 +50,14 @@ "prs": [], "issues": [], "releases": [], + "branches": [], + "workflows": [], "ignore_agents": ["First Mate"], } +BRANCH_REF = re.compile(r"^(?P[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)?)@(?P.+)$") +WORKFLOW_REF = re.compile( + r"^(?P[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)?):(?P[^@:]+)@(?P.+)$") BAD_CONCLUSIONS = {"FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"} BAD_STATES = {"FAILURE", "ERROR"} REF = re.compile(r"^(?:(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+|[A-Za-z0-9_-]+)#)(?P\d+)$") @@ -76,11 +91,35 @@ def resolve(w, ref): return repo, int(m.group("num")) +def repo_of(w, alias): + repo = w["repos"].get(alias, alias if "/" in alias else None) + if not repo: + raise SystemExit(f"unknown repo alias '{alias}'; register it: repo {alias} OWNER/repo") + return repo + + +def parse_branch(w, ref): + m = BRANCH_REF.match(ref) + if not m: + raise SystemExit(f"bad branch reference '{ref}' (want alias@branch)") + repo = repo_of(w, m.group("alias")) + return repo, m.group("branch"), f"{short(w, repo)}@{m.group('branch')}" + + +def parse_workflow(w, ref): + m = WORKFLOW_REF.match(ref) + if not m: + raise SystemExit(f"bad workflow reference '{ref}' (want alias:workflow-file@branch)") + repo = repo_of(w, m.group("alias")) + wf, br = m.group("workflow"), m.group("branch") + return repo, wf, br, f"{short(w, repo)}:{wf}@{br}" + + def short(w, repo): for a, r in w["repos"].items(): if r == repo: return a - return repo.split("/")[-1] + return repo def run(cmd): @@ -134,6 +173,22 @@ def release_snapshot(repo): return gh_json(["release", "view", "--repo", repo, "--json", "tagName"])["tagName"] +def branch_snapshot(repo, branch): + d = gh_json(["api", f"repos/{repo}/commits/{branch}"]) + subject = (d.get("commit", {}).get("message") or "").splitlines()[:1] + return f"{d['sha'][:8]} {(subject[0] if subject else '')[:80]}" + + +def workflow_snapshot(repo, workflow, branch): + rows = gh_json([ + "run", "list", "--repo", repo, "--workflow", workflow, "--branch", branch, + "--limit", "1", "--json", "databaseId,headSha,status,conclusion"]) + if not rows: + return "no runs" + r = rows[0] + return f"run {r['databaseId']} head={r['headSha'][:8]} {r['status']} {r.get('conclusion') or '-'}" + + def agents_snapshot(ignore): rows = json.loads(run(["paseo", "ls", "--json"])) items = sorted( @@ -158,6 +213,12 @@ def collect(w): for alias in w["releases"]: repo = w["repos"].get(alias, alias) jobs[f"release {short(w, repo)}"] = (release_snapshot, repo) + for ref in w["branches"]: + repo, br, label = parse_branch(w, ref) + jobs[f"branch {label}"] = (branch_snapshot, repo, br) + for ref in w["workflows"]: + repo, wf, br, label = parse_workflow(w, ref) + jobs[f"workflow {label}"] = (workflow_snapshot, repo, wf, br) jobs["agents"] = (agents_snapshot, set(w["ignore_agents"])) jobs["permission prompts"] = (permits_snapshot,) results, errors = {}, {} @@ -180,14 +241,32 @@ def load_state(): def save_state(s): - with open(STATE, "w") as fh: + tmp = STATE + ".tmp" + with open(tmp, "w") as fh: json.dump(s, fh, indent=1, sort_keys=True) + os.replace(tmp, STATE) def manage(w, argv): cmd = argv[0] if cmd == "list": print(json.dumps(w, indent=1)) + elif cmd in ("add", "remove") and len(argv) == 3 and argv[1] in ("branch", "workflow"): + kind = argv[1] + key = "branches" if kind == "branch" else "workflows" + label = parse_branch(w, argv[2])[2] if kind == "branch" else parse_workflow(w, argv[2])[3] + if cmd == "add": + if label not in w[key]: + w[key].append(label) + save_watch(w) + print(f"watching {kind} {label}") + else: + w[key] = [x for x in w[key] if x != label] + save_watch(w) + st = load_state() + st.pop(f"{kind} {label}", None) + save_state(st) + print(f"stopped watching {kind} {label}") elif cmd in ("add", "remove") and len(argv) == 3 and argv[1] in ("pr", "issue"): key = "prs" if argv[1] == "pr" else "issues" repo, n = resolve(w, argv[2]) diff --git a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh index 8f15388..59876a7 100755 --- a/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh +++ b/first-mate-skill/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh @@ -1,24 +1,170 @@ #!/usr/bin/env bash -# First Mate wait loop. Runs first-mate-check.py (same directory) every INTERVAL minutes and -# returns as soon as it reports a change, so waiting costs no model tokens. +# First Mate wait loop: one loop per FIRST_MATE_DIR, owned by one agent. +# Runs first-mate-check.py (same directory) every INTERVAL minutes and returns as soon +# as it reports a change, so waiting costs no model tokens. # -# Usage: first-mate-wait.sh INTERVAL_MIN MAX_MIN -# exit 0 -> change found; the change report is printed (state already saved) -# exit 3 -> nothing changed within MAX_MIN (print nothing) -# Run it as a BACKGROUND shell command so the harness notifies on completion. +# Usage: +# first-mate-wait.sh INTERVAL_MIN MAX_MIN start a loop (refuses if one is running) +# first-mate-wait.sh status none | mine | other, with owner, pid, start +# first-mate-wait.sh stop [--force] stop your loop (--force: another owner's) +# first-mate-wait.sh take-over INTERVAL MAX stop any running loop, then start yours +# +# Exit codes of a start: +# 0 change found; the report is printed (state already saved) +# 3 nothing changed within MAX_MIN (prints nothing) +# 4 a loop is already running and you own it; nothing started +# 5 a loop is running and another owner holds it; nothing started +# 2 usage or environment error (for example, flock is missing) +# Run a start as a BACKGROUND shell command so the harness notifies on completion. # Use INTERVAL 5 while work is actively in flight, 15 when it is quiet. +# +# Ownership is the PASEO_AGENT_ID of the caller (override with FIRST_MATE_OWNER). The +# lock is an flock(2) on FIRST_MATE_DIR/.wait.lock, so it frees itself when the loop +# dies, including by SIGKILL; there is no pidfile to go stale. Needs flock: util-linux on +# Linux, or `brew install flock` on macOS. set -uo pipefail -INTERVAL=${1:-15} -MAX=${2:-60} + HERE=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# State and watch files live in FIRST_MATE_DIR (default ./tmp); see first-mate-check.py. -deadline=$(( $(date +%s) + MAX * 60 )) -while :; do - out=$(python3 "$HERE/first-mate-check.py" 2>&1) - if [ -n "$out" ]; then - printf '%s\n' "$out" - exit 0 +DIR=${FIRST_MATE_DIR:-tmp} +mkdir -p -- "$DIR" || exit 2 +DIR=$(CDPATH='' cd -- "$DIR" && pwd) +export FIRST_MATE_DIR=$DIR +LOCK=$DIR/.wait.lock +INFO=$DIR/.wait.owner +ME=${FIRST_MATE_OWNER:-${PASEO_AGENT_ID:-local}} +export FIRST_MATE_OWNER=$ME + +command -v flock >/dev/null 2>&1 || { echo "first-mate-wait.sh needs flock (util-linux)" >&2; exit 2; } + +usage() { sed -n '2,/^set -uo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//' >&2; } +info_get() { sed -n "s/^$1=//p" "$INFO" 2>/dev/null | head -n 1; } + +# True when some process holds the lock. The subshell takes and releases it. +is_running() { if ( exec 8>"$LOCK"; flock -n 8 ); then return 1; else return 0; fi; } + +# True when the owner file names a live first-mate-wait.sh process. A loop killed with +# SIGKILL leaves its owner file behind, so the file alone is not proof. +info_valid() { + local p + p=$(info_get pid) + [ -n "$p" ] && ps -p "$p" -o args= 2>/dev/null | grep -q 'first-mate-wait.sh' +} + +# The loop takes the lock before it writes the owner file. When the lock is held, +# wait (up to about 5s) for a valid owner file so a caller never acts on a half-started +# loop or on a stale file. Returns 1 if the loop died meanwhile, or never published one. +await_info() { + local _ + for _ in $(seq 1 50); do + info_valid && return 0 + is_running || return 1 + sleep 0.1 + done + return 1 +} + +describe() { + if ! is_running; then echo "none"; return 0; fi + local owner kind + await_info + is_running || { echo "none"; return 0; } + owner=$(info_get owner) + if [ "$owner" = "$ME" ]; then kind=mine; else kind=other; fi + echo "$kind owner=${owner:-unknown} pid=$(info_get pid) started=$(info_get started)" \ + "interval=$(info_get interval)m max=$(info_get max)m" +} + +cmd_stop() { + local force=0 owner pid + [ "${1:-}" = "--force" ] && force=1 + if ! is_running; then echo "none running"; return 0; fi + await_info + is_running || { echo "none running"; return 0; } + owner=$(info_get owner); pid=$(info_get pid) + if [ "$owner" != "$ME" ] && [ "$force" -eq 0 ]; then + echo "refusing: the loop is owned by ${owner:-unknown}; use 'stop --force' or 'take-over'" >&2 + return 5 + fi + # ps works on Linux and macOS; /proc exists only on Linux. + if [ -z "$pid" ] || ! ps -p "$pid" -o args= 2>/dev/null | grep -q 'first-mate-wait.sh'; then + echo "cannot verify that pid '${pid:-none}' is the loop; not killing anything" >&2 + return 2 + fi + kill "$pid" + for _ in $(seq 1 50); do + if ! is_running; then echo "stopped (was owned by ${owner:-unknown})"; return 0; fi + sleep 0.1 + done + echo "the loop did not stop; investigate pid $pid" >&2 + return 2 +} + +SLEEP_PID= +DET_PID= +OUTF= +cleanup() { + [ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null + [ -n "$DET_PID" ] && kill "$DET_PID" 2>/dev/null + [ -n "$OUTF" ] && rm -f -- "$OUTF" + [ "$(info_get pid)" = "$$" ] && rm -f -- "$INFO" +} + +cmd_start() { + local interval=${1:-15} max=${2:-60} owner out deadline got _ + case "$interval$max" in *[!0-9]*|'') usage; exit 2 ;; esac + exec 9>"$LOCK" + # status and stop probe the lock by briefly taking it, so a failed attempt is not + # proof that a loop is running. Retry until the owner file names a live loop (then + # it is real), the lock is free (we win it), or about 5s pass. + got=0 + for _ in $(seq 1 50); do + if flock -n 9; then got=1; break; fi + info_valid && break + sleep 0.1 + done + if [ "$got" -eq 0 ]; then + owner=$(info_get owner) + if [ "$owner" = "$ME" ]; then + echo "a loop is already running and you own it ($(describe)); not starting another" + exit 4 + fi + echo "refusing: a loop is running and ${owner:-another owner} holds it ($(describe))." \ + "Use 'take-over' only if the user agreed that you are the coordinator." >&2 + exit 5 fi - [ "$(date +%s)" -ge "$deadline" ] && exit 3 - sleep $(( INTERVAL * 60 )) -done + # Write then rename, so status and stop never read a half-written owner file. + printf 'owner=%s\npid=%s\nstarted=%s\ninterval=%s\nmax=%s\n' \ + "$ME" "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$interval" "$max" > "$INFO.new" \ + && mv -f -- "$INFO.new" "$INFO" + OUTF=$(mktemp "$DIR/.wait.out.XXXXXX") || exit 2 + trap cleanup EXIT + trap 'exit 143' TERM INT + deadline=$(( $(date +%s) + max * 60 )) + while :; do + # Children must not inherit the lock fd, or a killed loop would keep the lock. + # The detector runs in the background and is waited on, so a stop (SIGTERM) is + # handled at once instead of after the detector's network calls finish. + python3 "$HERE/first-mate-check.py" >"$OUTF" 2>&1 9>&- & + DET_PID=$! + wait "$DET_PID" + DET_PID= + out=$(cat -- "$OUTF") + if [ -n "$out" ]; then + printf '%s\n' "$out" + exit 0 + fi + [ "$(date +%s)" -ge "$deadline" ] && exit 3 + sleep $(( interval * 60 )) 9>&- & + SLEEP_PID=$! + wait "$SLEEP_PID" + done +} + +case "${1:-}" in + status) describe ;; + stop) shift; cmd_stop "$@" ;; + take-over) shift; cmd_stop --force || exit $?; cmd_start "$@" ;; + -h|--help) usage ;; + ''|[0-9]*) cmd_start "$@" ;; + *) usage; exit 2 ;; +esac diff --git a/first-mate-skill/scripts/test-wait-lock b/first-mate-skill/scripts/test-wait-lock new file mode 100755 index 0000000..d9f8388 --- /dev/null +++ b/first-mate-skill/scripts/test-wait-lock @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# +# test-wait-lock — offline test of first-mate-wait.sh ownership and locking. +# Uses a stub detector, so it needs no gh, paseo, or network. Needs flock. +# +# Usage: first-mate-skill/scripts/test-wait-lock + +set -uo pipefail + +KIT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WAIT_SRC="$KIT_DIR/files/home/.agents/skills/paseo-first-mate/assets/first-mate-wait.sh" + +if ! command -v flock >/dev/null 2>&1; then + echo "SKIP flock not installed" + exit 0 +fi +# GNU timeout is `timeout` on Linux and `gtimeout` (brew install coreutils) on macOS. +TIMEOUT_BIN=$(command -v timeout || command -v gtimeout || true) +if [ -z "$TIMEOUT_BIN" ]; then + echo "SKIP timeout/gtimeout not installed" + exit 0 +fi + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/first-mate-wait-test.XXXXXX")" || exit 1 +mkdir -p "$WORK/bin" "$WORK/state" +cp "$WAIT_SRC" "$WORK/bin/first-mate-wait.sh" +# Stub detector: quiet until a "change" file appears, then prints once. +cat > "$WORK/bin/first-mate-check.py" <<'PY' +#!/usr/bin/env python3 +import os, time +if os.path.exists(os.path.join(os.environ["FIRST_MATE_DIR"], "slow")): + time.sleep(30) +p = os.path.join(os.environ["FIRST_MATE_DIR"], "change") +if os.path.exists(p): + print("CHANGED stub") +PY +chmod +x "$WORK/bin/first-mate-wait.sh" "$WORK/bin/first-mate-check.py" + +WAIT="$WORK/bin/first-mate-wait.sh" +export FIRST_MATE_DIR="$WORK/state" +pass=0 +fail=0 +check() { # name expected actual + if [ "$2" = "$3" ]; then + printf ' PASS %s\n' "$1"; pass=$((pass+1)) + else + printf ' FAIL %s (expected %q, got %q)\n' "$1" "$2" "$3"; fail=$((fail+1)) + fi +} +as() { local who=$1; shift; FIRST_MATE_OWNER=$who "$WAIT" "$@"; } +# For calls that must return at once; a mutated script would block here. +quick() { local who=$1; shift; FIRST_MATE_OWNER=$who "$TIMEOUT_BIN" --foreground 5 "$WAIT" "$@"; } +# Watchdog: a test must never hang a terminal, whatever the platform does. +WATCHDOG_SECS=${FIRST_MATE_TEST_WATCHDOG:-120} +( sleep "$WATCHDOG_SECS"; echo "FAIL watchdog: test exceeded ${WATCHDOG_SECS}s" >&2; kill -TERM $$ ) & +WATCHDOG=$! +disown "$WATCHDOG" 2>/dev/null # so the bare `wait` calls below do not wait for it +cleanup() { + pkill -P "$WATCHDOG" 2>/dev/null + kill "$WATCHDOG" 2>/dev/null + FIRST_MATE_OWNER=any "$WAIT" stop --force >/dev/null 2>&1 + # Only processes started from this test's own directory. + pkill -f "$WORK/bin/" 2>/dev/null + rm -rf "$WORK" +} +trap cleanup EXIT INT TERM + +echo "status with nothing running" +check "status is none" "none" "$(as alice status)" + +echo "start a loop as alice" +as alice 1 5 >"$WORK/alice.out" 2>&1 & +for _ in $(seq 1 50); do [ "$(as alice status)" != "none" ] && break; sleep 0.1; done +check "alice sees mine" "mine" "$(as alice status | cut -d' ' -f1)" +check "bob sees other" "other" "$(as bob status | cut -d' ' -f1)" + +echo "a second start never stacks" +quick alice 1 5 >/dev/null 2>&1; check "alice restart exits 4" "4" "$?" +quick bob 1 5 >/dev/null 2>&1; check "bob start exits 5" "5" "$?" +check "still exactly one loop" "1" "$(pgrep -f "$WORK/bin/first-mate-wait.sh (take-over|[0-9])" | wc -l | tr -d " ")" + +echo "stop respects ownership" +as bob stop >/dev/null 2>&1; check "bob cannot stop alice" "5" "$?" +check "alice loop still running" "mine" "$(as alice status | cut -d' ' -f1)" +as alice stop >/dev/null 2>&1; check "alice stops her loop" "0" "$?" +check "status is none after stop" "none" "$(as alice status)" +wait + +echo "a SIGKILLed loop frees the lock" +as alice 1 5 >/dev/null 2>&1 & +for _ in $(seq 1 50); do [ "$(as alice status)" != "none" ] && break; sleep 0.1; done +pid="$(sed -n 's/^pid=//p' "$FIRST_MATE_DIR/.wait.owner")" +kill -9 "$pid"; wait 2>/dev/null +sleep 0.3 +check "lock is free after kill -9" "none" "$(as bob status)" + +echo "a slow-starting loop (lock held, owner file not yet written, stale file present)" +# $INFO from the kill -9 case above is still on disk with a dead pid. +# A copy that keeps the real script name (the loop's pid is validated by name). +mkdir -p "$WORK/slow" +cp "$WORK/bin/first-mate-check.py" "$WORK/slow/" +sed 's|^ # Write then rename, so status| sleep 1.5\n # Write then rename, so status|' "$WAIT" > "$WORK/slow/first-mate-wait.sh" +chmod +x "$WORK/slow/first-mate-wait.sh" +FIRST_MATE_OWNER=alice "$WORK/slow/first-mate-wait.sh" 1 5 >/dev/null 2>&1 & +for _ in $(seq 1 50); do + ( exec 8>"$FIRST_MATE_DIR/.wait.lock"; flock -n 8 ) || break; sleep 0.1 +done +check "alice sees mine while still starting" "mine" "$(as alice status | cut -d' ' -f1)" +quick bob 1 5 >/dev/null 2>&1; check "bob is refused (exit 5), not treated as unowned" "5" "$?" +quick alice stop >/dev/null 2>&1; check "alice stops it after a slow start" "0" "$?" +wait 2>/dev/null +check "none after the slow loop is stopped" "none" "$(as alice status)" +rm -rf "$WORK/slow" + +echo "a start that meets a brief lock probe still starts" +# status and stop probe the lock by briefly taking it. Hold it for 1s with no owner +# file, as a probe would, and start in the middle: it must wait and win, not refuse. +rm -f "$FIRST_MATE_DIR/.wait.owner" +( exec 7>"$FIRST_MATE_DIR/.wait.lock"; flock 7; sleep 1 ) & +sleep 0.2 +as alice 1 5 >/dev/null 2>&1 & +for _ in $(seq 1 80); do [ "$(as alice status | cut -d' ' -f1)" = "mine" ] && break; sleep 0.1; done +check "start waited out the probe and owns the loop" "mine" "$(as alice status | cut -d' ' -f1)" +quick alice stop >/dev/null 2>&1 +wait 2>/dev/null + +echo "take-over replaces another owner" +as alice 1 5 >/dev/null 2>&1 & +for _ in $(seq 1 50); do [ "$(as alice status)" != "none" ] && break; sleep 0.1; done +as bob take-over 1 5 >"$WORK/bob.out" 2>&1 & +for _ in $(seq 1 80); do [ "$(as bob status | cut -d' ' -f1)" = "mine" ] && break; sleep 0.1; done +check "bob now owns it" "mine" "$(as bob status | cut -d' ' -f1)" +check "exactly one loop after take-over" "1" "$(pgrep -f "$WORK/bin/first-mate-wait.sh (take-over|[0-9])" | wc -l | tr -d " ")" + +# --force and `any`: end whoever holds it, so the bare wait below can never block on a +# loop an earlier failed step left running. +FIRST_MATE_OWNER=any "$WAIT" stop --force >/dev/null 2>&1; wait 2>/dev/null + +echo "stop works while the detector is mid-run" +touch "$FIRST_MATE_DIR/slow" +as alice 1 5 >/dev/null 2>&1 & +for _ in $(seq 1 50); do [ "$(as alice status)" != "none" ] && break; sleep 0.1; done +sleep 0.5 +start=$SECONDS +quick alice stop >/dev/null 2>&1; check "stop returns 0 during a slow detector" "0" "$?" +check "stop took under 4s" "yes" "$([ $((SECONDS - start)) -lt 4 ] && echo yes || echo no)" +check "status is none after slow stop" "none" "$(as alice status)" +wait 2>/dev/null +rm -f "$FIRST_MATE_DIR/slow" + +echo "a change ends the loop with exit 0 and the report" +touch "$FIRST_MATE_DIR/change" +out="$(as alice 1 5 2>&1)"; rc=$? +check "exit 0 on change" "0" "$rc" +check "report is printed" "CHANGED stub" "$out" +check "lock released on exit" "none" "$(as alice status)" +rm -f "$FIRST_MATE_DIR/change" + +echo "the cap exits 3 and prints nothing" +out="$(as alice 1 0 2>&1)"; rc=$? +check "exit 3 at the cap" "3" "$rc" +check "nothing printed" "" "$out" + +printf '\n %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/first-mate-skill/scripts/verify b/first-mate-skill/scripts/verify index 7f4e8bc..e61200c 100755 --- a/first-mate-skill/scripts/verify +++ b/first-mate-skill/scripts/verify @@ -119,7 +119,7 @@ else sed 's/^/ | /' "$WORK/sh.log" >&2 fi if command -v shellcheck >/dev/null 2>&1; then - if shellcheck --severity=warning "$WAIT_SH" "$0" >"$WORK/shellcheck.log" 2>&1; then + if shellcheck --severity=warning "$WAIT_SH" "$0" "$KIT_DIR/scripts/test-wait-lock" >"$WORK/shellcheck.log" 2>&1; then ok "shellcheck --severity=warning passes" else bad "shellcheck --severity=warning failed" @@ -129,13 +129,27 @@ else skip "shellcheck not installed (install it to run this gate)" fi +info "4b. Wait loop locking and ownership" +# One loop per FIRST_MATE_DIR, keyed on the owner. Uses a stub detector, so it +# needs no gh, paseo, or network. Skips itself when flock is missing. +if command -v flock >/dev/null 2>&1; then + if bash "$KIT_DIR/scripts/test-wait-lock" >"$WORK/waitlock.log" 2>&1; then + ok "first-mate-wait.sh locking and ownership test passes" + else + bad "first-mate-wait.sh locking and ownership test failed" + sed 's/^/ | /' "$WORK/waitlock.log" >&2 + fi +else + skip "flock not installed (wait-loop locking test not run)" +fi + info "5. Shipped detector has no baked-in watch targets" # `list` prints the watch list without calling GitHub or Paseo. It must come # back with no PRs, issues, or releases, so nothing is hard-coded to a repo. if command -v "$PYTHON_BIN" >/dev/null 2>&1; then mkdir -p "$WORK/state" if FIRST_MATE_DIR="$WORK/state" "$PYTHON_BIN" "$CHECK_PY" list >"$WORK/list.out" 2>"$WORK/list.err" \ - && "$PYTHON_BIN" -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if not (d.get("repos") or d.get("prs") or d.get("issues") or d.get("releases")) else 1)' "$WORK/list.out"; then + && "$PYTHON_BIN" -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if not (d.get("repos") or d.get("prs") or d.get("issues") or d.get("releases") or d.get("branches") or d.get("workflows")) else 1)' "$WORK/list.out"; then ok "first-mate-check.py ships with an empty watch list" else bad "first-mate-check.py list failed or returned a non-empty watch list"