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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions first-mate-skill/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`
Expand Down
18 changes: 17 additions & 1 deletion first-mate-skill/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id> ...`.
- `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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
173 changes: 151 additions & 22 deletions first-mate-skill/files/home/.agents/skills/paseo-first-mate/SKILL.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,25 @@
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
first-mate-check.py --show print the full current snapshot, save nothing
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
"""
Expand All @@ -40,9 +50,14 @@
"prs": [],
"issues": [],
"releases": [],
"branches": [],
"workflows": [],
"ignore_agents": ["First Mate"],
}

BRANCH_REF = re.compile(r"^(?P<alias>[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)?)@(?P<branch>.+)$")
WORKFLOW_REF = re.compile(
r"^(?P<alias>[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)?):(?P<workflow>[^@:]+)@(?P<branch>.+)$")
BAD_CONCLUSIONS = {"FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"}
BAD_STATES = {"FAILURE", "ERROR"}
REF = re.compile(r"^(?:(?P<alias>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+|[A-Za-z0-9_-]+)#)(?P<num>\d+)$")
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand All @@ -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 = {}, {}
Expand All @@ -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])
Expand Down
Loading
Loading