Compare commits

..

13 Commits

Author SHA1 Message Date
Jarvis
fd4021eef7 fix(fleet): install executable tmux helpers
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-06-20 17:09:28 -05:00
7498fcb20d fix(fleet): preserve agent env overrides on install (#567)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 21:50:46 +00:00
42d081613f chore(release): bump mosaic cli to 0.0.32 (#566)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 21:15:25 +00:00
b5c1381e45 fix(fleet): harden operator sends for release (#565)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 20:41:11 +00:00
6dfd78f643 feat(fleet): add local canary CLI (#563)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 17:49:01 +00:00
45e2c2aad8 docs: plan durable tmux fleet install (#557)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 16:19:19 +00:00
57919c38d8 fix(framework/tools): wrapper hardening — TLS validation, cred-path fallback, no-CI fast-exit (#551)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 10:16:38 +00:00
87f561c1f8 fix(launch): include Pi native skill roots in 'all' mode; dedup 'discover' force-loads (#556)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-19 19:58:09 +00:00
8c45857859 feat(launch): force-load fleet-critical Pi skills + reconcile skill docs (#555)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-19 18:31:02 +00:00
605221d42f docs(framework/tools): lead TOOLS.md with high-salience fleet-tools cheatsheet (#554)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-19 18:03:03 +00:00
ee584ab48c fix(framework/tools): prettier-format woodpecker README — restore main format gate (#553)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-18 22:39:35 +00:00
ab4e138003 feat(framework/tools): orchestration helpers — lane-brief.sh + ci-wait.sh (#547)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was canceled
2026-06-18 22:08:40 +00:00
719c6ac3db fix(framework/tools): eval injection, broken JSON, tmpfile leak (#549)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-18 21:35:32 +00:00
68 changed files with 4670 additions and 305 deletions

3
.gitignore vendored
View File

@@ -12,3 +12,6 @@ docs/reports/
# Step-CA dev password — real file is gitignored; commit only the .example # Step-CA dev password — real file is gitignored; commit only the .example
infra/step-ca/dev-password infra/step-ca/dev-password
# Scratch dirs created by the framework git-wrapper shell test harnesses
.mosaic-test-work/

View File

@@ -64,6 +64,7 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
21. `@mosaicstack/cli` — unified `mosaic` CLI 21. `@mosaicstack/cli` — unified `mosaic` CLI
22. Docker Compose deployment + bare-metal capability 22. Docker Compose deployment + bare-metal capability
23. Agent log service — ingest, parse, tier, summarize agent interaction logs 23. Agent log service — ingest, parse, tier, summarize agent interaction logs
24. Local durable agent fleet canary — `mosaic fleet` / `mosaic agent` CLI for an isolated tmux-backed canary fleet using a named socket, with roster-driven local customization and rollback-safe verification
### Out of Scope (v0.1.0) ### Out of Scope (v0.1.0)

View File

@@ -7,6 +7,7 @@
3. [Provider Configuration](#provider-configuration) 3. [Provider Configuration](#provider-configuration)
4. [MCP Server Configuration](#mcp-server-configuration) 4. [MCP Server Configuration](#mcp-server-configuration)
5. [Environment Variables Reference](#environment-variables-reference) 5. [Environment Variables Reference](#environment-variables-reference)
6. [Local Fleet Canary](./fleet-local-canary.md)
--- ---

View File

@@ -9,6 +9,7 @@
5. [Adding New MCP Tools](#adding-new-mcp-tools) 5. [Adding New MCP Tools](#adding-new-mcp-tools)
6. [Database Schema and Migrations](#database-schema-and-migrations) 6. [Database Schema and Migrations](#database-schema-and-migrations)
7. [API Endpoint Reference](#api-endpoint-reference) 7. [API Endpoint Reference](#api-endpoint-reference)
8. [Local Fleet Canary](./fleet-local-canary.md)
--- ---

View File

@@ -0,0 +1,144 @@
# Local Fleet Canary
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
isolated tmux socket. The default socket is `mosaic-factory`; the commands do
not use or stop the default tmux server.
## Files
Product-owned defaults:
- `packages/mosaic/framework/fleet/roster.schema.json`
- `packages/mosaic/framework/fleet/examples/minimal.yaml`
- `packages/mosaic/framework/fleet/examples/local-canary.yaml`
- `packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service`
- `packages/mosaic/framework/systemd/user/mosaic-agent@.service`
- `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
- `packages/mosaic/framework/tools/tmux/agent-send.sh`
- `packages/mosaic/framework/tools/tmux/send-message.sh`
These files are published through `packages/mosaic/package.json`, whose `files`
allowlist includes `framework` along with `dist`.
Site-owned local roster:
```text
~/.config/mosaic/fleet/roster.yaml
```
Do not put a host-specific full roster into product defaults. Start from an
example and edit the local roster after `mosaic fleet init --write`.
## Install
Minimal canary:
```bash
mosaic fleet init --profile minimal --write
# If a site-owned roster already exists, inspect it first; overwrite only explicitly:
# mosaic fleet init --profile minimal --write --force
mosaic fleet install-systemd
systemctl --user daemon-reload
mosaic fleet start
mosaic fleet verify
```
Small dogfood roster:
```bash
mosaic fleet init --profile local-canary --write
# Use --force only after preserving any site-owned roster changes.
mosaic fleet install-systemd
systemctl --user daemon-reload
mosaic fleet start
mosaic fleet status
```
## Agent Operations
```bash
mosaic agent roster
mosaic agent status
mosaic agent status canary-pi
mosaic agent send canary-pi --message "status check"
mosaic agent reset canary-pi --new
mosaic agent tail canary-pi -n 80
```
These commands read the roster and target the configured tmux socket. The
generated systemd agent services use `start-agent-session.sh`; message delivery
uses the tmux send tools with `-L mosaic-factory`.
`mosaic agent send` is operator-origin traffic unless a caller explicitly says
otherwise. The CLI always passes a deterministic source label to
`agent-send.sh` with `-S`, defaulting to `<hostname>:operator`, so it does not
query the target tmux socket and accidentally identify as an active agent pane.
Use `--source-label <label>` or `--source <label>` only when deliberately
impersonating a known handoff lane. The lower-level inter-agent wrapper
`agent-send.sh -S <label>` remains the explicit source override for scripts.
## Verification
Use these checks before expanding the roster:
```bash
tmux -L mosaic-factory ls
tmux ls
mosaic fleet verify
systemctl --user status mosaic-tmux-holder.service
```
Expected results:
- `tmux -L mosaic-factory ls` shows `_holder` and roster agent sessions.
- `tmux ls` shows only the default tmux server sessions and is not changed by
fleet start/stop operations.
- `mosaic fleet verify` checks exact session targets on the isolated socket.
- `systemctl --user status ...` may show `active (exited)` for oneshot units;
that means the unit ran, not that an agent pane is live. Treat tmux
`has-session`, `list-panes`, process tree, and logs as the liveness evidence.
## Release Preflight
Run this checklist before cutting or dogfooding a fleet release:
- Real AI dogfood: send at least one task through `mosaic agent send`, then
confirm the agent accepted/responded using pane, process, or log evidence.
- Restart/stop/idempotency: run `mosaic fleet start`, `restart`, `stop`, and a
repeated `start` against the named socket; verify the default tmux server is
unchanged.
- Liveness verification: run `mosaic fleet verify` and confirm roster sessions
with `tmux -L mosaic-factory ls` or exact `has-session` checks.
- Package dry-run: run `npm pack --dry-run --json` from `packages/mosaic` and
confirm `framework/fleet`, `framework/systemd/user`,
`framework/tools/fleet`, and `framework/tools/tmux` assets are included.
- Mosaic update test: install or upgrade from the packed artifact in a temporary
Mosaic home and confirm `mosaic update` or the release upgrade path does not
remove local roster/config files.
## Rollback
Stop the local canary:
```bash
mosaic fleet stop
systemctl --user disable mosaic-agent@canary-pi.service
systemctl --user disable mosaic-tmux-holder.service
systemctl --user daemon-reload
```
For a full local cleanup of generated canary files:
```bash
rm -f ~/.config/systemd/user/mosaic-agent@.service
rm -f ~/.config/systemd/user/mosaic-tmux-holder.service
rm -rf ~/.config/mosaic/fleet
rm -rf ~/.config/mosaic/tools/fleet
```
This rollback leaves the default tmux server untouched. If a canary session is
still present after service stop, remove only the isolated socket server:
```bash
tmux -L mosaic-factory kill-server
```

View File

@@ -10,6 +10,7 @@
6. [CLI Usage](#cli-usage) 6. [CLI Usage](#cli-usage)
7. [Sub-package Commands](#sub-package-commands) 7. [Sub-package Commands](#sub-package-commands)
8. [Telemetry](#telemetry) 8. [Telemetry](#telemetry)
9. [Local Fleet Canary](./fleet-local-canary.md)
--- ---

View File

@@ -0,0 +1,52 @@
# Fleet CLI Local Canary Dogfood — 2026-06-20
## Objective
Move the durable tmux fleet PoC into a functional local canary on this server. This is **not** production deployment. It is a canary/dogfood path for a small local agent fleet using an isolated tmux socket.
## Issue
- Gitea issue: #562`feat(fleet): local CLI canary dogfood`
## Scope
Implement enough product surface to use the fleet locally:
- `mosaic fleet init/install/start/stop/restart/status/verify`
- `mosaic agent roster/status/send/reset/tail`
- roster schema and examples
- local canary docs and rollback instructions
- tests for CLI behavior where practical
- canary verification on named tmux socket `mosaic-factory`
## Non-goals
- No production rollout.
- No migration of existing default tmux sessions.
- No image build/deploy work.
- No hardcoded USC/local roster as product default.
## Acceptance Criteria
- CLI can initialize a minimal roster outside product defaults.
- CLI can install user systemd units and fleet helper scripts to a configurable Mosaic home.
- CLI can start/stop/status/verify a canary fleet using `mosaic-factory`.
- `mosaic agent send` uses existing named-socket/exact-target tmux tooling.
- `mosaic agent reset` targets only the named agent session on the named socket.
- Verification proves default tmux sessions remain untouched.
- Baseline repo gates pass.
- PR CI is green before merge.
- Local canary evidence is captured after merge/install.
## Budget / Routing
- Agent: codex preferred.
- Estimate: 25K-40K tokens.
- Worker owns implementation/tests/docs in branch `feat/fleet-cli-local-canary`.
- Orchestrator owns `docs/TASKS.md`, issue/PR/merge, and local canary install verification.
## Progress
- 2026-06-20: #557 PoC primitives merged to `main` as `45e2c2a`.
- 2026-06-20: issue #562 created for local CLI canary dogfood.
- 2026-06-20: worktree created at `/home/jarvis/src/mosaicstack-stack-worktrees/fleet-cli-local-canary`.

View File

@@ -0,0 +1,35 @@
# Fleet release hardening
## Objective
Harden the Mosaic local fleet release path for operator sends, tmux/systemd verification, package contents, and dogfood release documentation.
## Constraints
- Do not edit `docs/TASKS.md`.
- Do not change production deployment refs.
- Keep fleet transport generic and named-socket safe.
- Preserve strict roster validation.
- Add tests first or alongside fixes.
## Plan
1. Add regression tests for deterministic `mosaic agent send` source labels.
2. Strengthen fleet status/verify/package/install-systemd coverage.
3. Implement focused CLI/source-label changes.
4. Update local canary documentation with dogfood preflight.
5. Run formatting, targeted tests, typecheck, lint, and package dry-run evidence.
## Evidence Log
- Started from existing `docs/PRD.md`; durable local fleet canary is in v0.1.0 scope.
- Loaded `mosaic-fleet-operations` skill; key constraints are isolated tmux sockets, no default tmux positive tests, and `active (exited)` is not liveness.
- TDD red: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts` initially failed because `node_modules` was absent; after `pnpm install`, the new source-label tests failed on missing `-S`, missing helper, and unknown `--source-label`.
- Green implementation: `mosaic agent send` now passes `-S <hostname>:operator` by default and accepts `--source-label` / `--source` overrides.
- Test coverage added for tmux-based fleet verify liveness, package `files` allowlist containing `framework`, and explicit operator source-label command construction.
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/guides/fleet-local-canary.md docs/scratchpads/2026-06-20-fleet-release-hardening.md`.
- Targeted tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts` passed with 49 tests.
- Typecheck: `pnpm typecheck` passed.
- Lint: `pnpm lint` passed.
- Package dry-run: `npm pack --dry-run --json` from `packages/mosaic` included `framework/fleet`, `framework/systemd/user`, `framework/tools/fleet/start-agent-session.sh`, and `framework/tools/tmux/{agent-send.sh,send-message.sh}`.
- Review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` approved the supplied diff with no findings; the review tool noted its read-only sandbox could not inspect files directly.

View File

@@ -0,0 +1,87 @@
# Wrapper hardening fold-in: #559 (eval removal) + #560 (host-derived login)
**Branch:** `fix/wrapper-hardening-tls-credpath-cicwait` (PR #551)
**Worker:** coderlite0 (Sonnet lane) · coordinated by mos-claude
**Date:** 2026-06-20
**Scope:** `packages/mosaic/framework/tools/git/*.sh` only
## What the issues asked for vs. what was already landed
Both issues were largely satisfied by prior merged work; this fold-in closes the
remaining gaps (regression tests + a loud diagnostic + one residual word-split site)
rather than re-implementing finished functionality.
### #559 — remove `eval` from issue-create.sh (and siblings)
- `eval`-based command construction was already removed across the wrapper surface
(landed in #549). A full scan of `tools/git/*.sh` finds **zero** `eval` usages.
- `issue-create.sh`, `pr-create.sh`, `issue-edit.sh`, `issue-assign.sh` already build
their `tea`/`gh` invocations as argv arrays (`CMD=(...)`, `"${CMD[@]}"`), so Markdown
bodies pass through verbatim.
- **Residual found & fixed:** `issue-comment.sh` still used unquoted
`$(get_gitea_repo_args)` word-splitting (the comment body itself was already safely
quoted, so no injection bug — but it was the inconsistent, fragile pattern #559 targets,
and it failed silently when no login resolved). Converted to an argv array with an
explicit, loud login-resolution error.
- **Added regression test:** `test-issue-create-body-safety.sh` — feeds a hostile
Markdown body (`$(touch SENTINEL)`, backticks, single/double quotes, `$HOME`/`${PATH}`,
pipes/`&&`/`;`) through `issue-create.sh` and asserts (1) no command substitution
executes (sentinel file never created) and (2) the `--description` `tea` receives is
byte-for-byte the original body.
### #560 — auto-detect Gitea `--login` from repo origin host
- Centralized host→login resolution already exists in `detect-platform.sh`
(`get_gitea_login_for_host``find_tea_login_for_host`, matching `urlparse(url).hostname`).
Every wrapper routes through it (or `get_gitea_login` / `get_gitea_login_for_repo_override`);
**no wrapper hardcodes `${GITEA_LOGIN:-mosaicstack}`**. Explicit `GITEA_LOGIN` wins only
when it matches the host (`tea_login_matches_host`), so stale overrides are rejected.
- **Gap fixed — silent failure → loud diagnostic:** the failure path of
`get_gitea_login_for_host` returned non-zero with no message. Added
`print_gitea_login_diagnostic`, emitted to **stderr** on resolution failure: names the
unresolved host, lists available tea logins (name + host), and gives the `GITEA_LOGIN`
override + `tea login add` fix. Stderr-only, so it never contaminates stdout (the
resolved login name) or the log-grep assertions in the existing harnesses. Callers with
an API fallback (pr-merge, issue-close, pr-create, issue-create) still follow with their
own "using API fallback" line, giving a clear "no login → fallback" trail.
- **Extended test:** `test-gitea-login-resolution.sh` now also asserts (a) the loud
diagnostic fires and lists available logins for an unresolved host, (b) login is derived
from origin host for **both** instances (mosaicstack + usc) via a scoped second `tea`
mock, and (c) a valid `GITEA_LOGIN` override is honored. The scoped mock keeps the
existing API-fallback assertions (which require mosaicstack to have _no_ tea login) valid.
## Files changed (wrapper surface only)
- `detect-platform.sh` — add `print_gitea_login_diagnostic`; call it on the
`get_gitea_login_for_host` failure path.
- `issue-comment.sh` — argv array + loud login-resolution error (was unquoted
`$(get_gitea_repo_args)`).
- `test-issue-create-body-safety.sh`**new** (#559 regression).
- `test-gitea-login-resolution.sh` — extended (#560 diagnostic + both-host + override).
## Verification
All wrapper harnesses pass locally:
- `test-issue-create-body-safety.sh` — PASS
- `test-gitea-login-resolution.sh` — PASS
- `test-pr-merge-gitea-empty-uid.sh` — PASS
- `test-pr-metadata-gitea.sh` — PASS
- `test-lane-brief-pr-linkage.sh` — PASS
## Open items flagged to mos-claude (orchestrator decisions)
1. **CHANGELOG absent.** The task said "update CHANGELOG (append-only), keep the existing
#550/#551 entry." No CHANGELOG file exists anywhere in the repo, and #550/#551 are not
recorded in one. **ASSUMPTION:** documenting #559/#560 in this scratchpad + the PR
description (`Closes #559 Closes #560`) follows the repo's actual convention
(`docs/scratchpads/`). Did not invent a new CHANGELOG structure.
2. **`docs/TASKS.md` is orchestrator single-writer.** It carries a "Workers read but never
modify" banner. As a worker I did **not** edit it; task tracking is via the linked Gitea
issues #559/#560 + this scratchpad. Orchestrator may add a rollup row if desired.
3. **Wrapper `test-*.sh` are not CI-wired.** `.woodpecker/ci.yml` runs `pnpm
typecheck/lint/format:check/test` (`turbo run test`); the framework dir has no
`package.json`, so these shell harnesses run **locally/manually only** — they do not gate
the PR in Woodpecker. **ASSUMPTION:** out of scope to wire a shell-test step into CI in
this PR (would broaden the diff beyond the wrapper surface). Flagging for a follow-up if
the fleet wants these gated.

View File

@@ -0,0 +1,54 @@
# Fleet CLI Local Canary Review Fixes
## Objective
Fix only the two should-fix code review findings:
1. Ensure `@mosaicstack/mosaic` declares `yaml` and lockfile state is current.
2. Validate `mosaic agent status [agent]` against the fleet roster before constructing/running the tmux target.
## Constraints
- Do not modify `docs/TASKS.md`.
- Leave changes uncommitted.
- Run requested formatting and quality gates.
## Plan
1. Inspect manifest/lockfile state for `yaml`.
2. Add failing regression test for `mosaic agent status typo`.
3. Patch `registerFleetAgentCommands` status validation.
4. Format touched files.
5. Run requested tests, typecheck, and lint.
6. Review final diff.
## Progress
- Loaded required repo/global/runtime instructions.
- Confirmed `packages/mosaic/package.json` already declares `yaml`.
- Confirmed `pnpm-lock.yaml` already has `packages/mosaic` importer entry for `yaml`.
- Found `registerFleetAgentCommands` status path does not validate agent before building tmux target.
## Verification
- TDD red check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
failed before the production fix because `mosaic agent status typo` resolved instead of
rejecting.
- Focused green check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
passed after adding roster validation.
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/scratchpads/fleet-cli-local-canary-review-fixes.md`
completed with all files unchanged.
- Requested tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts`
passed with 36 tests.
- Baseline typecheck: `pnpm typecheck` passed.
- Baseline lint: `pnpm lint` passed.
- Independent review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
returned approve with 0 findings. Note: reviewer reported broader context inspection was limited
by its read-only sandbox, so review was based on the supplied diff.
- `docs/TASKS.md` has no diff.
## Risks
- `docs/TASKS.md` intentionally untouched per user instruction.
- Review finding 1 required no file edit: `packages/mosaic/package.json` already declares
`yaml`, and the `packages/mosaic` importer in `pnpm-lock.yaml` already includes `yaml`.

View File

@@ -51,3 +51,48 @@ This repository currently has no root `CHANGELOG.md`; the scratchpad and `docs/T
- PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).` - PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).`
- PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`. - PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`.
- CI: Recent PR/push pipelines failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean. - CI: Recent PR/push pipelines failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean.
## 2026-06-18 — PR #549 functional blocker remediation
### Assignment
Coordinator `mos-claude` assigned remediation for PR #549: fix `packages/mosaic/framework/tools/git/pr-metadata.sh` tmpfile cleanup where an `EXIT` trap references function-local `body_file` after the function returns inside `RAW=$(...)`, producing `body_file: unbound variable` on the authenticated success path and failing to clean up safely on early `set -e` exits.
### Plan
1. Add a non-vacuous Gitea test that exercises `curl_gitea_pull` with stubbed `curl` and `GITEA_TOKEN` instead of `MOSAIC_GITEA_PR_METADATA_RAW_FILE`.
2. Prove the new test is RED against the current PR head.
3. Replace the function-local `EXIT` cleanup with robust function-scoped tmpfile cleanup.
4. Re-run targeted tests, `bash -n`, and review gates; commit and push branch only. Do not merge.
### Constraints / assumptions
- Do not modify prior injection/JSON fixes in `issue-edit`, `issue-assign`, or `milestone-create`.
- Worker role: do not modify `docs/TASKS.md`; orchestrator remains the single writer.
- Budget: no explicit token cap provided; keep scope to shell wrapper + targeted regression harness.
### Remediation results
- Rebased `fix/tooling-eval-injection-jq-json` onto `origin/main`; branch was already current.
- Added a curl-stub regression path that does not use `MOSAIC_GITEA_PR_METADATA_RAW_FILE`, so it exercises `curl_gitea_pull` and its temp body file.
- RED evidence: copied the new harness next to the pre-fix `HEAD` version of `pr-metadata.sh`; `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-red-work .../test-pr-metadata-gitea.sh` failed with `body_file: unbound variable` on the curl success path.
- Fix: replaced `EXIT` temp-file cleanup with a `RETURN`-scoped cleanup function that removes the body file while the function-local variable is still in scope, preserves the original return status, and clears the `RETURN` trap.
- GREEN evidence:
- `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-gitea-current packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
- `bash -n packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
- `shellcheck -x -P . -e SC1090 packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
### Review remediation
- Codex review returned one should-fix: the early-exit test used `chmod 000`, which is not root-safe in container CI.
- Remediation: changed the stubbed 2xx/cat-failure mode to replace the curl output with a broken symlink, which fails deterministically even as root and still validates cleanup via `rm -f -- "$body_file"`.
### Second review remediation
- Codex review found the 2xx `cat "$body_file"` read could be masked under command substitution semantics because the branch returned 0 unconditionally.
- Remediation: both authenticated 2xx branches now use `cat "$body_file" || return $?` before returning success.
- Strengthened the broken-symlink test to require the body-read failure and reject the later `Gitea API returned non-JSON` parse-failure path, so the test verifies the helper-level failure propagation rather than eventual downstream failure.
### Final review gate
- Codex review after remediation: approved (`0 blockers, 0 should-fix, 0 suggestions`).

View File

@@ -5,10 +5,39 @@ Tool suites live at `~/.config/mosaic/tools/<suite>/`. This is the index only.
read it (or the relevant service guide) when your task actually touches that service. read it (or the relevant service guide) when your task actually touches that service.
Project-specific tooling belongs in the project's `AGENTS.md`, not here. Project-specific tooling belongs in the project's `AGENTS.md`, not here.
## ⚡ Most-used fleet tools (reach for these FIRST — don't hand-roll)
You are a Mosaic fleet agent. These cover the highest-frequency cross-agent and git-provider
tasks — use them before improvising with raw `tmux send-keys`, raw `tea`/`gh`/`glab`, or `curl`.
**1. Message another agent**`tools/tmux/agent-send.sh` (NOT raw `tmux send-keys`):
```bash
tools/tmux/agent-send.sh -s <target-session> -m "message" # or -f <file> to send a file's contents
```
The coordinator session is `mos-claude` — send status, findings, and questions there.
**2. Issues / PRs / milestones**`tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
```bash
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
tools/git/ci-queue-wait.sh --purpose push|merge # REQUIRED before any push/merge
```
**GITEA_LOGIN gotcha** — the wrappers default to login `mosaicstack`; on a USC repo that fails with
`gitea / Error: GetUserByName ... not found`. Pick the login from the repo's `origin` host first:
| origin host | login |
| --------------------- | ---------------------------------------- |
| `git.uscllc.com` | `export GITEA_LOGIN=usc` |
| `git.mosaicstack.dev` | default `mosaicstack` (no export needed) |
## Suites (use wrappers first) ## Suites (use wrappers first)
| Suite | Path | Purpose | | Suite | Path | Purpose |
| ---------- | ------------------------------------------------ | ------------------------------------------------------------------------ | | ---------- | ------------------------------------------------ | ------------------------------------------------------------------------ |
| tmux | `tools/tmux/agent-send.sh` | inter-agent messaging (see "Most-used" above) |
| git | `tools/git/*.sh` | issues, PRs, milestones, CI queue guard (platform-auto-detected) | | git | `tools/git/*.sh` | issues, PRs, milestones, CI queue guard (platform-auto-detected) |
| woodpecker | `tools/woodpecker/*.sh` | CI pipelines (`-a mosaic`\|`usc`; match git remote host) | | woodpecker | `tools/woodpecker/*.sh` | CI pipelines (`-a mosaic`\|`usc`; match git remote host) |
| portainer | `tools/portainer/*.sh` | Docker Swarm stacks (status/redeploy/list) | | portainer | `tools/portainer/*.sh` | Docker Swarm stacks (status/redeploy/list) |

View File

@@ -0,0 +1,26 @@
# Mosaic Fleet Rosters
The local fleet canary uses a product-owned roster schema with site-owned roster
files. Product examples live here; active local rosters should live outside the
package, normally at:
```text
~/.config/mosaic/fleet/roster.yaml
```
The default tmux socket is `mosaic-factory` so fleet commands do not touch the
default tmux server.
## Examples
- `examples/minimal.yaml` starts one local canary slot.
- `examples/local-canary.yaml` starts a small generic dogfood fleet.
Initialize a roster:
```bash
mosaic fleet init --profile minimal --write
mosaic fleet install-systemd
mosaic fleet start
mosaic fleet verify
```

View File

@@ -0,0 +1,27 @@
version: 1
transport: tmux
tmux:
socket_name: mosaic-factory
holder_session: _holder
defaults:
working_directory: ~/src
runtimes:
claude:
reset_command: /clear
codex:
reset_command: /clear
pi:
reset_command: /new
agents:
- name: lead
runtime: claude
class: orchestrator
persistent_persona: true
- name: coder0
runtime: codex
class: implementer
reset_between_tasks: true
- name: reviewer0
runtime: pi
class: reviewer
reset_between_tasks: true

View File

@@ -0,0 +1,15 @@
version: 1
transport: tmux
tmux:
socket_name: mosaic-factory
holder_session: _holder
defaults:
working_directory: ~/src
runtimes:
pi:
reset_command: /new
agents:
- name: canary-pi
runtime: pi
class: canary
reset_between_tasks: true

View File

@@ -0,0 +1,118 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://mosaicstack.dev/schemas/fleet-roster.schema.json",
"title": "Mosaic Fleet Roster",
"type": "object",
"required": ["version", "transport", "agents"],
"additionalProperties": false,
"properties": {
"version": {
"const": 1
},
"transport": {
"const": "tmux"
},
"tmux": {
"type": "object",
"additionalProperties": false,
"properties": {
"socket_name": {
"type": "string",
"default": "mosaic-factory"
},
"socketName": {
"type": "string",
"default": "mosaic-factory"
},
"holder_session": {
"type": "string",
"default": "_holder"
},
"holderSession": {
"type": "string",
"default": "_holder"
}
}
},
"defaults": {
"type": "object",
"additionalProperties": false,
"properties": {
"working_directory": {
"type": "string",
"default": "~/src"
},
"workingDirectory": {
"type": "string",
"default": "~/src"
}
}
},
"runtimes": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"reset_command": {
"type": "string"
},
"resetCommand": {
"type": "string"
}
}
}
},
"agents": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["name", "runtime"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+$"
},
"runtime": {
"type": "string"
},
"class": {
"type": "string"
},
"working_directory": {
"type": "string"
},
"workingDirectory": {
"type": "string"
},
"model_hint": {
"type": "string"
},
"modelHint": {
"type": "string"
},
"persistent_persona": {
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
},
"persistentPersona": {
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
},
"reset_between_tasks": {
"type": "boolean"
},
"resetBetweenTasks": {
"type": "boolean"
},
"kickstart_template": {
"type": "string"
},
"kickstartTemplate": {
"type": "string"
}
}
}
}
}
}

View File

@@ -29,7 +29,21 @@ Pi supports `--models` for Ctrl+P model cycling during a session. Use cheaper mo
### Skills ### Skills
Mosaic skills are loaded natively via Pi's `--skill` flag. Skills are discovered from: By default the launcher starts Pi with `--no-skills` to keep startup context small, then
force-loads a small set of fleet-critical skills via explicit `--skill` flags (an explicit
`--skill` overrides `--no-skills` for that path). The default forced set is `mosaic-tools`
(the must-use `~/.config/mosaic/tools/` cheatsheet: inter-agent messaging + git wrappers).
Tune skill loading with environment variables:
- `MOSAIC_PI_FORCE_SKILLS` — colon-separated skill dir names to force-load (default: `mosaic-tools`;
set to an empty string to disable force-loading). Missing skills are skipped silently.
- `MOSAIC_PI_SKILL_MODE=all` — link every skill found in `~/.config/mosaic/{skills,skills-local}/`
(full catalog; larger context).
- `MOSAIC_PI_SKILL_MODE=discover` — let Pi discover skills natively (no `--no-skills`), still
force-loading the fleet set on top.
Skills are discovered from:
- `~/.config/mosaic/skills/` (Mosaic global skills) - `~/.config/mosaic/skills/` (Mosaic global skills)
- `~/.pi/agent/skills/` (Pi global skills) - `~/.pi/agent/skills/` (Pi global skills)

View File

@@ -0,0 +1,57 @@
# Mosaic tmux Fleet PoC
This directory contains the first durable tmux-backed fleet primitives for the
Mosaic software-factory model.
The lifecycle model follows the organization-neutral AI Guide playbook
`mosaicstack/aiguide:playbooks/tmux-fleet.md` (commit `2a0b0b5`): a dedicated
holder owns the tmux server/socket; agent units join it and stop only their own
exact-match session.
## Layout
- `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server.
- `mosaic-agent@.service` — user-mode template for one reusable agent session.
- `test-fleet-units.sh` — validates unit syntax and required relationships.
The agent template calls:
```text
~/.config/mosaic/tools/fleet/start-agent-session.sh <agent-name>
```
which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`.
## Local customization
Per-agent overrides live outside the package in:
```text
~/.config/mosaic/fleet/agents/<agent>.env
```
Example:
```dotenv
MOSAIC_TMUX_SOCKET=mosaic-factory
MOSAIC_AGENT_RUNTIME=claude
MOSAIC_AGENT_WORKDIR=/home/jarvis/src/mosaic-stack
# Optional escape hatch for PoC/canary agents:
# MOSAIC_AGENT_COMMAND=mosaic yolo claude
```
## Manual canary sequence
```bash
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
cp packages/mosaic/framework/tools/fleet/start-agent-session.sh ~/.config/mosaic/tools/fleet/
chmod +x ~/.config/mosaic/tools/fleet/start-agent-session.sh
systemctl --user daemon-reload
systemctl --user start mosaic-tmux-holder.service
systemctl --user start mosaic-agent@canary.service
tmux -L mosaic-factory ls
```
Do not use `tmux kill-server` without `-L mosaic-factory`; this pattern is meant
to avoid disturbing the user's default tmux server.

View File

@@ -0,0 +1,20 @@
[Unit]
Description=Mosaic tmux fleet agent %i
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
Requires=mosaic-tmux-holder.service
After=mosaic-tmux-holder.service
PartOf=mosaic-tmux-holder.service
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory
Environment=MOSAIC_AGENT_NAME=%i
Environment=MOSAIC_AGENT_RUNTIME=pi
Environment=MOSAIC_AGENT_WORKDIR=%h
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
ExecStop=-/bin/bash -lc 'tmux -L "${MOSAIC_TMUX_SOCKET:-mosaic-factory}" kill-session -t "=%i"'
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Mosaic tmux fleet holder
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
After=default.target
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory
Environment=MOSAIC_TMUX_HOLDER=_holder
ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" "while true; do sleep 3600; done"'
ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
if command -v systemd-analyze >/dev/null 2>&1; then
systemd-analyze verify --user "$HOLDER" "$AGENT" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
cat /tmp/mosaic-fleet-systemd-verify.log >&2
fail "systemd-analyze verify failed"
}
fi
echo "ok - fleet systemd unit templates"

View File

@@ -9,8 +9,8 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -58,7 +58,7 @@ ${QUALITY_GATES}
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`. 2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`). 3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
4. Keep `docs/TASKS.md` status in sync with actual progress until completion. 4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice). 5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion). 6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
## Documentation Contract ## Documentation Contract
@@ -88,7 +88,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)
@@ -138,8 +138,8 @@ When completing an orchestrated task:
### Post-Coding Review ### Post-Coding Review
After implementing changes, code review is REQUIRED for any source-code modification. After implementing changes, code review is REQUIRED for any source-code modification.
For orchestrated tasks, the orchestrator will run: For orchestrated tasks, the orchestrator will run:
1. **Codex code review** — `~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted` 1. **Codex code review** — `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
2. **Codex security review** — `~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted` 2. **Codex security review** — `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted`
3. If blockers/critical findings: remediation task created 3. If blockers/critical findings: remediation task created
4. If clean: task marked done 4. If clean: task marked done

View File

@@ -135,7 +135,7 @@ ${QUALITY_GATES}
## Issue Tracking ## Issue Tracking
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work. Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice. For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop. If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow. Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -176,10 +176,10 @@ Run independent reviews:
```bash ```bash
# Code quality review (Codex) # Code quality review (Codex)
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
# Security review (Codex) # Security review (Codex)
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
``` ```
**Fallback:** If Codex is unavailable, use Claude's built-in review skills. **Fallback:** If Codex is unavailable, use Claude's built-in review skills.

View File

@@ -9,8 +9,8 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -68,7 +68,7 @@ ruff check . && mypy . && pytest tests/
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`. 2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`). 3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
4. Keep `docs/TASKS.md` status in sync with actual progress until completion. 4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice). 5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion). 6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
## Documentation Contract ## Documentation Contract
@@ -97,7 +97,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)
@@ -139,8 +139,8 @@ Use `${TASK_PREFIX}` for orchestrated tasks (e.g., `${TASK_PREFIX}-SEC-001`).
### Post-Coding Review ### Post-Coding Review
After implementing changes, code review is REQUIRED for any source-code modification. After implementing changes, code review is REQUIRED for any source-code modification.
For orchestrated tasks, the orchestrator will run: For orchestrated tasks, the orchestrator will run:
1. **Codex code review** — `~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted` 1. **Codex code review** — `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
2. **Codex security review** — `~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted` 2. **Codex security review** — `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted`
3. If blockers/critical findings: remediation task created 3. If blockers/critical findings: remediation task created
4. If clean: task marked done 4. If clean: task marked done

View File

@@ -159,10 +159,10 @@ Run independent reviews:
```bash ```bash
# Code quality review (Codex) # Code quality review (Codex)
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
# Security review (Codex) # Security review (Codex)
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
``` ```
See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist. See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist.
@@ -186,7 +186,7 @@ See `~/.config/mosaic/guides/DOCUMENTATION.md` for required documentation delive
## Issue Tracking ## Issue Tracking
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work. Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice. For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop. If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow. Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.

View File

@@ -9,8 +9,8 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -72,7 +72,7 @@ pnpm typecheck && pnpm lint && pnpm test
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`. 2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`). 3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
4. Keep `docs/TASKS.md` status in sync with actual progress until completion. 4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice). 5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion). 6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
## Documentation Contract ## Documentation Contract
@@ -101,7 +101,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)
@@ -143,8 +143,8 @@ Use `${TASK_PREFIX}` for orchestrated tasks (e.g., `${TASK_PREFIX}-SEC-001`).
### Post-Coding Review ### Post-Coding Review
After implementing changes, code review is REQUIRED for any source-code modification. After implementing changes, code review is REQUIRED for any source-code modification.
For orchestrated tasks, the orchestrator will run: For orchestrated tasks, the orchestrator will run:
1. **Codex code review** — `~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted` 1. **Codex code review** — `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
2. **Codex security review** — `~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted` 2. **Codex security review** — `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted`
3. If blockers/critical findings: remediation task created 3. If blockers/critical findings: remediation task created
4. If clean: task marked done 4. If clean: task marked done

View File

@@ -191,10 +191,10 @@ Run independent reviews:
```bash ```bash
# Code quality review (Codex) # Code quality review (Codex)
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
# Security review (Codex) # Security review (Codex)
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
``` ```
See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist. See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist.
@@ -218,7 +218,7 @@ See `~/.config/mosaic/guides/DOCUMENTATION.md` for required documentation delive
## Issue Tracking ## Issue Tracking
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work. Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice. For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop. If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow. Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.

View File

@@ -9,8 +9,8 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -58,7 +58,7 @@ uv run ruff check src/ tests/ && uv run ruff format --check src/ && uv run mypy
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`. 2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`). 3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
4. Keep `docs/TASKS.md` status in sync with actual progress until completion. 4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice). 5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion). 6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
## Documentation Contract ## Documentation Contract
@@ -87,7 +87,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)

View File

@@ -135,7 +135,7 @@ uv run ruff check src/ tests/ && uv run ruff format --check src/ && uv run mypy
## Issue Tracking ## Issue Tracking
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work. Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice. For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop. If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow. Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -171,8 +171,8 @@ If you modify source code, independent code review is REQUIRED before completion
Run independent reviews: Run independent reviews:
```bash ```bash
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
``` ```
See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist. See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist.

View File

@@ -9,8 +9,8 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -55,7 +55,7 @@ uv run ruff check src/ tests/ && uv run ruff format --check src/ && uv run mypy
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`. 2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`). 3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
4. Keep `docs/TASKS.md` status in sync with actual progress until completion. 4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice). 5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion). 6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
## Documentation Contract ## Documentation Contract
@@ -84,7 +84,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)

View File

@@ -125,7 +125,7 @@ uv run ruff check src/ tests/ && uv run ruff format --check src/ && uv run mypy
## Issue Tracking ## Issue Tracking
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work. Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice. For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop. If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow. Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -161,8 +161,8 @@ If you modify source code, independent code review is REQUIRED before completion
Run independent reviews: Run independent reviews:
```bash ```bash
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
``` ```
See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist. See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist.

View File

@@ -9,8 +9,8 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage. 3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`). 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -56,7 +56,7 @@ ${QUALITY_GATES}
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`. 2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`). 3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
4. Keep `docs/TASKS.md` status in sync with actual progress until completion. 4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice). 5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion). 6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
## Documentation Contract ## Documentation Contract
@@ -85,7 +85,7 @@ Reference:
5. Do not mark implementation complete until PR is merged. 5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green. 6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI. 7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`. 8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable) ## Container Release Strategy (When Applicable)

View File

@@ -122,7 +122,7 @@ ${QUALITY_GATES}
## Issue Tracking ## Issue Tracking
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work. Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice. For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop. If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow. Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding. 7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`. 8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`). 9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`. 10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only. 11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -159,10 +159,10 @@ Run independent reviews:
```bash ```bash
# Code quality review (Codex) # Code quality review (Codex)
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
# Security review (Codex) # Security review (Codex)
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted ~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
``` ```
**Fallback:** If Codex is unavailable, use Claude's built-in review skills. **Fallback:** If Codex is unavailable, use Claude's built-in review skills.

View File

@@ -16,7 +16,12 @@
# After loading, service-specific env vars are exported. # After loading, service-specific env vars are exported.
# Run `load_credentials --help` for details. # Run `load_credentials --help` for details.
MOSAIC_CREDENTIALS_FILE="${MOSAIC_CREDENTIALS_FILE:-$HOME/src/jarvis-brain/credentials.json}" if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "$HOME/src/jarvis-brain/credentials.json"; do
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
done
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/src/jarvis-brain/credentials.json}"
fi
_mosaic_require_jq() { _mosaic_require_jq() {
if ! command -v jq &>/dev/null; then if ! command -v jq &>/dev/null; then
@@ -34,6 +39,19 @@ _mosaic_read_cred() {
jq -r "$jq_path // empty" "$MOSAIC_CREDENTIALS_FILE" jq -r "$jq_path // empty" "$MOSAIC_CREDENTIALS_FILE"
} }
# Decide curl TLS flag for a target URL: validate public hosts (MITM matters on
# WAN); allow self-signed only for private-network IP literals (trusted LAN) or an
# explicit $MOSAIC_INSECURE_TLS opt-in. Echoes "-k" or "" (empty).
_mosaic_tls_opt() {
local url="$1" host
[[ -n "${MOSAIC_INSECURE_TLS:-}" ]] && { echo "-k"; return; }
host=$(printf '%s' "$url" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#')
if [[ "$host" =~ ^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.) ]]; then
echo "-k"; return
fi
echo ""
}
# Sync Woodpecker credentials to ~/.woodpecker/<instance>.env # Sync Woodpecker credentials to ~/.woodpecker/<instance>.env
# Only writes when values differ to avoid unnecessary disk writes. # Only writes when values differ to avoid unnecessary disk writes.
_mosaic_sync_woodpecker_env() { _mosaic_sync_woodpecker_env() {
@@ -261,7 +279,8 @@ mosaic_http() {
local base_url="${4:-}" local base_url="${4:-}"
local response local response
response=$(curl -sk -w "\n%{http_code}" -X "$method" \ local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X "$method" \
-H "$auth_header" \ -H "$auth_header" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${base_url}${endpoint}") "${base_url}${endpoint}")
@@ -279,7 +298,8 @@ mosaic_http_post() {
local base_url="${4:-}" local base_url="${4:-}"
local response local response
response=$(curl -sk -w "\n%{http_code}" -X POST \ local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X POST \
-H "$auth_header" \ -H "$auth_header" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$data" \ -d "$data" \
@@ -297,7 +317,8 @@ mosaic_http_patch() {
local base_url="${4:-}" local base_url="${4:-}"
local response local response
response=$(curl -sk -w "\n%{http_code}" -X PATCH \ local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X PATCH \
-H "$auth_header" \ -H "$auth_header" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$data" \ -d "$data" \

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-factory}
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
if [ -z "$AGENT_NAME" ]; then
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
exit 64
fi
if ! command -v tmux >/dev/null 2>&1; then
echo "ERROR: tmux is required" >&2
exit 69
fi
if tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
echo "Mosaic agent session already running: $AGENT_NAME on socket $MOSAIC_TMUX_SOCKET"
exit 0
fi
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME"
fi
mkdir -p "$MOSAIC_AGENT_WORKDIR"
exec tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" "$MOSAIC_AGENT_COMMAND"

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
START="$SCRIPT_DIR/start-agent-session.sh"
SOCKET="mosaic-agent-test-$RANDOM-$$"
AGENT="agent-$RANDOM"
WORKDIR=$(mktemp -d)
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$WORKDIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
MOSAIC_TMUX_SOCKET="$SOCKET" \
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
"$START" "$AGENT"
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir"
MOSAIC_TMUX_SOCKET="$SOCKET" \
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
"$START" "$AGENT" >/tmp/mosaic-start-agent-idempotent.out
grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent"
echo "ok - start-agent-session"

View File

@@ -169,6 +169,43 @@ raise SystemExit(1)
PY PY
} }
# Emit an actionable diagnostic to stderr when no tea login resolves for a host.
# Callers that have a working API fallback may ignore the non-zero return of
# get_gitea_login_for_host; this turns the previously SILENT failure into a loud,
# greppable hint (available logins + override + add-login instructions). Printed to
# stderr only, so it never contaminates stdout (the resolved login name) or log
# assertions that capture tea/curl invocations.
print_gitea_login_diagnostic() {
local host="${1:-<unknown>}"
local available
available=$(
command -v tea >/dev/null 2>&1 || { echo "(tea CLI not installed)"; exit 0; }
logins_json=$(tea login list --output json 2>/dev/null) || { echo "(could not query tea login list)"; exit 0; }
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
import json, os
from urllib.parse import urlparse
try:
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
except Exception:
logins = []
rows = []
for login in logins if isinstance(logins, list) else []:
name = str(login.get("name") or login.get("Name") or "")
url = str(login.get("url") or login.get("URL") or "")
host = urlparse(url).hostname or "?"
if name:
rows.append(f"{name} (host: {host})")
print("; ".join(rows) if rows else "(none configured)")
PY
)
{
echo "Error: no Gitea tea login matches host '$host'."
echo " Available tea logins: ${available}"
echo " Fix: set GITEA_LOGIN to a login whose URL host is '$host',"
echo " or add one: tea login add --name <name> --url https://$host --token <token>"
} >&2
}
get_gitea_login_for_host() { get_gitea_login_for_host() {
local host="${1:-}" local host="${1:-}"
local login local login
@@ -190,6 +227,7 @@ get_gitea_login_for_host() {
return 0 return 0
fi fi
print_gitea_login_diagnostic "$host"
return 1 return 1
} }

View File

@@ -98,27 +98,32 @@ case "$PLATFORM" in
;; ;;
gitea) gitea)
# tea issue edit syntax # tea issue edit syntax
REPO_ARGS=$(get_gitea_repo_args) || { REPO_SLUG=$(get_repo_slug) || {
echo "Error: Could not resolve Gitea repo/login args for remote host" >&2 echo "Error: Could not resolve Gitea repo slug from remote" >&2
exit 1 exit 1
} }
CMD="tea issue edit $ISSUE $REPO_ARGS" REPO_LOGIN=$(get_gitea_login) || {
echo "Error: Could not resolve Gitea login for remote host" >&2
exit 1
}
REPO_ARGS=(--repo "$REPO_SLUG" --login "$REPO_LOGIN")
CMD=(tea issue edit "$ISSUE" "${REPO_ARGS[@]}")
NEEDS_EDIT=false NEEDS_EDIT=false
if [[ -n "$ASSIGNEE" ]]; then if [[ -n "$ASSIGNEE" ]]; then
# tea uses --assignees flag # tea uses --assignees flag
CMD="$CMD --assignees \"$ASSIGNEE\"" CMD+=(--assignees "$ASSIGNEE")
NEEDS_EDIT=true NEEDS_EDIT=true
fi fi
if [[ -n "$LABELS" ]]; then if [[ -n "$LABELS" ]]; then
# tea uses --labels flag (replaces existing) # tea uses --labels flag (replaces existing)
CMD="$CMD --labels \"$LABELS\"" CMD+=(--labels "$LABELS")
NEEDS_EDIT=true NEEDS_EDIT=true
fi fi
if [[ -n "$MILESTONE" ]]; then if [[ -n "$MILESTONE" ]]; then
MILESTONE_ID=$(tea milestones list $REPO_ARGS 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1) MILESTONE_ID=$(tea milestones list "${REPO_ARGS[@]}" 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
if [[ -n "$MILESTONE_ID" ]]; then if [[ -n "$MILESTONE_ID" ]]; then
CMD="$CMD --milestone $MILESTONE_ID" CMD+=(--milestone "$MILESTONE_ID")
NEEDS_EDIT=true NEEDS_EDIT=true
else else
echo "Warning: Could not find milestone '$MILESTONE'" >&2 echo "Warning: Could not find milestone '$MILESTONE'" >&2
@@ -126,7 +131,7 @@ case "$PLATFORM" in
fi fi
if [[ "$NEEDS_EDIT" == true ]]; then if [[ "$NEEDS_EDIT" == true ]]; then
eval "$CMD" "${CMD[@]}"
echo "Issue #$ISSUE updated successfully" echo "Issue #$ISSUE updated successfully"
else else
echo "No changes specified" echo "No changes specified"

View File

@@ -53,7 +53,15 @@ if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER" echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args) # Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
# word-splitting) so the comment body — including Markdown backticks, $(...),
# and quotes — is passed verbatim and never re-split or shell-evaluated.
REPO_SLUG=$(get_repo_slug)
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
exit 1
}
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
echo "Added comment to Gitea issue #$ISSUE_NUMBER" echo "Added comment to Gitea issue #$ISSUE_NUMBER"
else else
echo "Error: Unknown platform" echo "Error: Unknown platform"

View File

@@ -63,24 +63,28 @@ fi
detect_platform >/dev/null detect_platform >/dev/null
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
CMD="gh issue edit $ISSUE_NUMBER" CMD=(gh issue edit "$ISSUE_NUMBER")
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\"" [[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
[[ -n "$BODY" ]] && CMD="$CMD --body \"$BODY\"" [[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$LABELS" ]] && CMD="$CMD --add-label \"$LABELS\"" [[ -n "$LABELS" ]] && CMD+=(--add-label "$LABELS")
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\"" [[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
eval $CMD "${CMD[@]}"
echo "Updated GitHub issue #$ISSUE_NUMBER" echo "Updated GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
REPO_ARGS=$(get_gitea_repo_args) || { REPO_SLUG=$(get_repo_slug) || {
echo "Error: Could not resolve Gitea repo/login args for remote host" >&2 echo "Error: Could not resolve Gitea repo slug from remote" >&2
exit 1 exit 1
} }
CMD="tea issue edit $ISSUE_NUMBER $REPO_ARGS" REPO_LOGIN=$(get_gitea_login) || {
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\"" echo "Error: Could not resolve Gitea login for remote host" >&2
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\"" exit 1
[[ -n "$LABELS" ]] && CMD="$CMD --add-labels \"$LABELS\"" }
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\"" CMD=(tea issue edit "$ISSUE_NUMBER" --repo "$REPO_SLUG" --login "$REPO_LOGIN")
eval $CMD [[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
[[ -n "$LABELS" ]] && CMD+=(--add-labels "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
"${CMD[@]}"
echo "Updated Gitea issue #$ISSUE_NUMBER" echo "Updated Gitea issue #$ISSUE_NUMBER"
else else
echo "Error: Unknown platform" echo "Error: Unknown platform"

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env bash
#
# lane-brief.sh — live dispatch brief for a repo "lane" (milestone/label), straight
# from current Gitea state. Defeats stale worker self-report: workers brief from
# static notes and routinely report issues "todo" that are already CLOSED, forcing
# the orchestrator to re-verify each one before dispatch. This returns the CURRENT
# open set, classified for dispatch, in one call.
#
# Usage:
# lane-brief.sh -r <owner/repo> [-m <milestone>] [-l <label>] [-L <login>] [-n <limit>]
# lane-brief.sh -r usc/uconnect -m "M2M Part Search (0.0.45)"
# lane-brief.sh -r usc/uconnect -l domain/6-security
#
# Reliable signals (closed issues are excluded by definition — that's the point):
# - open-vs-closed : authoritative; this is the stale-intake failure mode.
# - PR-linkage : an open PR referencing the issue = work underway.
# Assignees/dependencies are intentionally NOT trusted as "available" signals —
# fleets that track work-state out-of-band (tmux board, issue text) leave them
# empty in Gitea. Output therefore partitions by PR presence and the OPEN-NO-PR set
# is "dispatch candidates to cross-check against the live fleet", not a blind list.
#
# Login resolution order: -L flag > $GITEA_LOGIN > owner inference (usc->usc,
# mosaicstack/mosaic->mosaicstack) > detect-platform.sh default-login fallback.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
source "$SCRIPT_DIR/detect-platform.sh"
REPO="" MILESTONE="" LABEL="" LOGIN="" LIMIT=100
while getopts "r:m:l:L:n:h" opt; do
case "$opt" in
r) REPO="$OPTARG" ;;
m) MILESTONE="$OPTARG" ;;
l) LABEL="$OPTARG" ;;
L) LOGIN="$OPTARG" ;;
n) LIMIT="$OPTARG" ;;
h) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "see -h" >&2; exit 2 ;;
esac
done
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
# Resolve login: explicit -L, then $GITEA_LOGIN, then owner inference, then the
# shared default-login resolver. Owner inference comes before the shared fallback
# because the latter is not owner-aware (picks the default tea login), which is
# wrong for cross-instance lanes.
if [[ -z "$LOGIN" ]]; then
if [[ -n "${GITEA_LOGIN:-}" ]]; then
LOGIN="$GITEA_LOGIN"
else
case "${REPO%%/*}" in
usc|USC) LOGIN=usc ;;
mosaicstack|mosaic) LOGIN=mosaicstack ;;
*) LOGIN="$(get_gitea_login_for_repo_override 2>/dev/null || true)" ;;
esac
fi
fi
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
command -v tea >/dev/null || { echo "FATAL: tea not found" >&2; exit 1; }
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 1; }
ISSUES_JSON="$(tea issues list --repo "$REPO" --login "$LOGIN" --state open --limit "$LIMIT" \
--fields index,title,assignees,milestone,labels --output json 2>/dev/null)" || {
echo "FATAL: tea issues list failed for $REPO (login=$LOGIN)" >&2; exit 1; }
# Open PRs, to cross-ref which issues already have work in flight. An issue is
# "work underway" if an open PR links to it. Two link signals are honored:
# (a) a closing keyword in the PR BODY — Gitea's auto-close set (close/closes/
# closed, fix/fixes/fixed, resolve/resolves/resolved), case-insensitive,
# directly preceding `#N`. This is the AUTHORITATIVE link Gitea itself uses
# to associate a PR with the issue it resolves; a body-only "Closes #546"
# is the common case and MUST count. The earlier version inspected only the
# PR index/title/head TSV (never the body or Gitea linkage), so a body-only
# reference was invisible and the linked OPEN issue was misclassified as a
# dispatch candidate — re-dispatchable in-flight work (the #546/#547 defect).
# (b) a bare #N in the PR title, or an issue number embedded in the head branch
# (feat/546-x, fix-546) — the weaker heuristic preserved from prior behavior.
# Bare #N mentions in the BODY are deliberately NOT treated as links: PR bodies
# routinely name unrelated issues in prose ("relevant to the #538 line of work"),
# and counting those would wrongly mark live, dispatchable issues as in-flight.
# Only the closing-keyword form is a commitment to resolve that issue. Requiring
# `#` to directly follow the keyword also keeps cross-repo `owner/repo#N` forms
# from leaking a foreign issue number into this per-repo lane (cross-repo lanes
# are run per-repo). JSON (not TSV) is used so multi-line bodies parse cleanly.
PRS_JSON="$(tea pulls list --repo "$REPO" --login "$LOGIN" --state open \
--fields index,title,head,body --output json 2>/dev/null || echo '[]')"
[[ -n "$PRS_JSON" ]] || PRS_JSON='[]'
# \b anchors the keyword to a word start so embedded substrings do not match
# (e.g. "prefix #5", "disclosed #7" must NOT be read as "fix #5" / "closed #7").
GITEA_CLOSE_KW='close[sd]?|fix(e[sd])?|resolve[sd]?'
PR_BODY_REFS="$(printf '%s' "$PRS_JSON" | jq -r '.[] | .body // ""' 2>/dev/null \
| grep -oiE "\\b(${GITEA_CLOSE_KW})[[:space:]:]+#[0-9]+" | grep -oE '[0-9]+' || true)"
PR_TITLE_HEAD_REFS="$(printf '%s' "$PRS_JSON" \
| jq -r '.[] | [ (.title // ""), (.head // "" | if type=="object" then (.ref // "") else . end) ] | join(" ")' 2>/dev/null \
| grep -oE '#[0-9]+|[/-][0-9]{3,}' | grep -oE '[0-9]+' || true)"
PR_ISSUE_REFS="$(printf '%s\n%s\n' "$PR_BODY_REFS" "$PR_TITLE_HEAD_REFS" | grep -E '^[0-9]+$' | sort -u || true)"
ts="$(date -u '+%Y-%m-%d %H:%MZ' 2>/dev/null || echo '?')"
filt="$REPO"; [[ -n "$MILESTONE" ]] && filt="$filt · milestone:'$MILESTONE'"; [[ -n "$LABEL" ]] && filt="$filt · label:'$LABEL'"
echo "LANE BRIEF — $filt · $ts (login=$LOGIN)"
echo "(open issues only; closed are excluded by definition — that's the point)"
echo
# Label match is exact-token against tea's space-separated labels string (so -l
# "security" does NOT match label "domain/6-security"). Caveat: label names that
# themselves contain spaces aren't distinguishable in tea's string form.
printf '%s' "$ISSUES_JSON" | jq -r --arg ms "$MILESTONE" --arg lb "$LABEL" --arg prs "$PR_ISSUE_REFS" '
($prs | split("\n") | map(select(length>0))) as $prrefs
| map(
select( ($ms=="" or .milestone==$ms)
and ($lb=="" or ((.labels//"") | split(" ") | index($lb) != null)) )
| . + { assigned: ((.assignees//"")|length>0),
haspr: (.index as $ix | ($prrefs | index($ix)) != null) }
)
| (map(select(.haspr|not))) as $candidates
| (map(select(.haspr))) as $inflight
| "DISPATCH CANDIDATES (open · no open PR) — \($candidates|length) [cross-check vs live fleet]:",
( $candidates[] | " #\(.index) \(.title[0:90])\(if .assigned then " (gitea-assignee set)" else "" end)" ),
"",
"WORK UNDERWAY (open · PR in flight) — \($inflight|length):",
( $inflight[] | " #\(.index) \(.title[0:80]) [PR open]" )
'
echo
echo "Closed issues are excluded — do NOT take a worker's self-reported 'todo' on faith."
echo "Candidates = open + no PR; confirm against the live fleet before dispatch"
echo "(fleets that don't self-assign in Gitea leave 'unassigned' meaningless)."

View File

@@ -99,10 +99,15 @@ fi
case "$PLATFORM" in case "$PLATFORM" in
github) github)
# GitHub uses the API for milestone creation # GitHub uses the API for milestone creation
JSON_PAYLOAD="{\"title\":\"$TITLE\"" # Use jq to safely construct JSON so titles/descriptions containing
[[ -n "$DESCRIPTION" ]] && JSON_PAYLOAD="$JSON_PAYLOAD,\"description\":\"$DESCRIPTION\"" # quotes or special characters do not corrupt the payload (F-07).
[[ -n "$DUE_DATE" ]] && JSON_PAYLOAD="$JSON_PAYLOAD,\"due_on\":\"${DUE_DATE}T00:00:00Z\"" JSON_PAYLOAD=$(jq -n \
JSON_PAYLOAD="$JSON_PAYLOAD}" --arg t "$TITLE" \
--arg d "$DESCRIPTION" \
--arg due "${DUE_DATE}" \
'{"title": $t}
+ (if $d != "" then {"description": $d} else {} end)
+ (if $due != "" then {"due_on": ($due + "T00:00:00Z")} else {} end)')
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD" gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD"
echo "Milestone '$TITLE' created successfully" echo "Milestone '$TITLE' created successfully"

View File

@@ -72,6 +72,11 @@ elif values and all(v == "success" for v in values):
print("success") print("success")
elif any(v in {"pending", "running", "queued", "waiting"} for v in values): elif any(v in {"pending", "running", "queued", "waiting"} for v in values):
print("pending") print("pending")
elif not values and not state:
# No pipeline/status of any kind reported for this commit. Distinct from
# "unknown" (an ambiguous/unrecognized status that should keep polling):
# this signals a repo/commit that simply has no CI configured.
print("no-status")
else: else:
print("unknown") print("unknown")
PY PY
@@ -142,6 +147,21 @@ gitea_get_commit_status_json() {
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
} }
gitea_get_default_branch() {
local host="$1"
local repo="$2"
local token="$3"
local url="https://${host}/api/v1/repos/${repo}"
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
import json, sys
print((json.load(sys.stdin) or {}).get("default_branch", ""))
'
}
github_get_default_branch() {
gh api "repos/${OWNER}/${REPO}" --jq '.default_branch'
}
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
-n|--number) -n|--number)
@@ -245,6 +265,51 @@ else
exit 1 exit 1
fi fi
# No-CI determination is TWO-TIER (primary: CI history; secondary: empty-poll streak).
#
# PRIMARY — "does this repo run CI at all?" Probed once, up front, from the DEFAULT
# BRANCH's commit status. A repo whose default branch carries CI statuses
# demonstrably runs CI, so an EMPTY status on the PR head means the pipeline simply
# has not registered YET (webhook/queue lag) — NOT that the repo is CI-less. In that
# case we must NEVER fast-green; we keep polling until the pipeline registers or the
# timeout fires (both safe). This closes the webhook-lag false-green: a slow-to-
# register pipeline feeding a merge gate can no longer be mistaken for "no CI".
#
# SECONDARY — the empty-poll streak below applies ONLY to genuinely CI-less repos
# (default branch also has no CI history, e.g. device-imaging class), where burning
# the full timeout would be pure waste. There, NO_CI_MAX empty polls => fast-exit 0.
#
# Probe failure is treated conservatively as REPO_HAS_CI=1 (assume CI present): we
# would rather wait-then-timeout than risk a false-green, per the merge-gate priority.
REPO_HAS_CI=1
detect_repo_ci() {
local def_branch def_status
# Every early exit returns 0: a probe miss must leave the conservative
# REPO_HAS_CI=1 default in place, never abort the caller under `set -e`.
if [[ "$PLATFORM" == "github" ]]; then
def_branch=$(github_get_default_branch 2>/dev/null) || {
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
[[ -n "$def_branch" ]] || return 0
def_status=$(github_get_commit_status_json "$OWNER" "$REPO" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
else
def_branch=$(gitea_get_default_branch "$HOST" "$OWNER/$REPO" "$TOKEN" 2>/dev/null) || {
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
[[ -n "$def_branch" ]] || return 0
def_status=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
fi
if [[ "$def_status" == "no-status" || -z "$def_status" ]]; then
REPO_HAS_CI=0
echo "[pr-ci-wait] default branch '${def_branch}' has no CI status history — treating repo as CI-less (empty-poll fast-exit enabled)."
else
REPO_HAS_CI=1
echo "[pr-ci-wait] default branch '${def_branch}' has CI history (state=${def_status}) — repo runs CI; empty status on PR head => awaiting registration, will not fast-green."
fi
}
detect_repo_ci || true
NO_CI_STREAK=0
NO_CI_MAX=3
while true; do while true; do
NOW_TS=$(date +%s) NOW_TS=$(date +%s)
if (( NOW_TS > DEADLINE_TS )); then if (( NOW_TS > DEADLINE_TS )); then
@@ -272,11 +337,35 @@ while true; do
echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2 echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2
exit 1 exit 1
;; ;;
no-status)
if [[ "$REPO_HAS_CI" == "1" ]]; then
# PRIMARY tier: repo demonstrably runs CI but this commit's pipeline
# has not registered yet (webhook/queue lag). Do NOT fast-green — keep
# polling until it registers or the timeout fires. Reset the streak so
# a later genuine CI-less misread can't accumulate across this state.
NO_CI_STREAK=0
echo "[pr-ci-wait] empty status on PR head but repo runs CI — awaiting pipeline registration (webhook lag), not fast-greening."
else
# SECONDARY tier: genuinely CI-less repo (default branch has no CI
# history either). Empty polls => fast-exit green after NO_CI_MAX.
NO_CI_STREAK=$((NO_CI_STREAK + 1))
if (( NO_CI_STREAK >= NO_CI_MAX )); then
echo "[INFO] no CI configured for this repo/commit (PR #$PR_NUMBER, ${NO_CI_STREAK} consecutive empty polls, default branch also CI-less); treating as green."
exit 0
fi
fi
sleep "$INTERVAL_SEC"
;;
pending|unknown) pending|unknown)
# A pipeline exists but hasn't reached a terminal state (or is
# transiently ambiguous) — keep waiting, and reset the no-CI streak
# since this commit is not in the "no CI at all" condition.
NO_CI_STREAK=0
sleep "$INTERVAL_SEC" sleep "$INTERVAL_SEC"
;; ;;
*) *)
echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..." echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..."
NO_CI_STREAK=0
sleep "$INTERVAL_SEC" sleep "$INTERVAL_SEC"
;; ;;
esac esac

View File

@@ -57,12 +57,20 @@ curl_gitea_pull() {
local token basic_auth raw_code body_file http_code local token basic_auth raw_code body_file http_code
body_file=$(mktemp) body_file=$(mktemp)
# shellcheck disable=SC2329 # Invoked by the RETURN trap below.
cleanup_gitea_pull_body() {
local status=$?
rm -f -- "$body_file"
trap - RETURN
return "$status"
}
trap cleanup_gitea_pull_body RETURN
token=$(get_gitea_token "$HOST" || true) token=$(get_gitea_token "$HOST" || true)
if [[ -n "$token" ]]; then if [[ -n "$token" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" -H "Authorization: token $token" "$api_url" || true) raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" -H "Authorization: token $token" "$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then if [[ "$raw_code" =~ ^2 ]]; then
cat "$body_file" cat "$body_file" || return $?
rm -f "$body_file"
return 0 return 0
fi fi
http_code="$raw_code" http_code="$raw_code"
@@ -72,8 +80,7 @@ curl_gitea_pull() {
if [[ -n "$basic_auth" ]]; then if [[ -n "$basic_auth" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" -H "User-Agent: curl/8" "$api_url" || true) raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" -H "User-Agent: curl/8" "$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then if [[ "$raw_code" =~ ^2 ]]; then
cat "$body_file" cat "$body_file" || return $?
rm -f "$body_file"
return 0 return 0
fi fi
http_code="$raw_code" http_code="$raw_code"
@@ -96,7 +103,6 @@ except Exception:
message = open(path, encoding="utf-8", errors="replace").read()[:200] or "empty response" message = open(path, encoding="utf-8", errors="replace").read()[:200] or "empty response"
print(f"Error: Gitea pull request API request failed with HTTP {code}: {message}") print(f"Error: Gitea pull request API request failed with HTTP {code}: {message}")
PY PY
rm -f "$body_file"
return 1 return 1
} }

View File

@@ -230,4 +230,81 @@ if grep -q -- 'tea issue close 536 .*--login mosaicstack' "$LOG_FILE"; then
exit 1 exit 1
fi fi
# ---------------------------------------------------------------------------
# #560: loud diagnostic + host-derived login for BOTH instances + override-wins
# ---------------------------------------------------------------------------
# Loud diagnostic: a host with no matching tea login must emit an actionable
# error to stderr (the previous behavior was a SILENT failure). The original
# mock defines only usc/evil-usc logins, so mosaicstack resolution fails here.
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
diag_stderr=$(run_in_repo bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
' 2>&1 1>/dev/null || true)
if ! grep -q "no Gitea tea login matches host 'git.mosaicstack.dev'" <<<"$diag_stderr"; then
echo "Expected loud diagnostic naming the unresolved host; got: $diag_stderr" >&2
exit 1
fi
if ! grep -q "Available tea logins:" <<<"$diag_stderr"; then
echo "Expected diagnostic to list available tea logins; got: $diag_stderr" >&2
exit 1
fi
# Both-instance host derivation + override-wins, using a mock that DOES define a
# mosaicstack login. Scoped to this section so the API-fallback assertions above
# (which rely on mosaicstack having NO tea login) remain valid.
BIN_DIR2="$WORK_DIR/bin2"
mkdir -p "$BIN_DIR2"
cp "$BIN_DIR/curl" "$BIN_DIR2/curl"
cat > "$BIN_DIR2/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"jason.woltje"},
{"name":"usc","url":"https://git.uscllc.com","user":"jason.woltje"}
]
JSON
exit 0
fi
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
exit 0
SH
chmod +x "$BIN_DIR2/tea"
run_in_repo2() {
(
cd "$REPO_DIR"
PATH="$BIN_DIR2:$PATH" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
)
}
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
mosaic_login=$(run_in_repo2 bash -c 'source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
if [[ "$mosaic_login" != "mosaicstack" ]]; then
echo "Expected mosaicstack origin to derive login 'mosaicstack'; got '$mosaic_login'" >&2
exit 1
fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
usc_login_derived=$(run_in_repo2 bash -c 'source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
if [[ "$usc_login_derived" != "usc" ]]; then
echo "Expected usc origin to derive login 'usc'; got '$usc_login_derived'" >&2
exit 1
fi
# Explicit GITEA_LOGIN override is honored when it matches the host.
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
override_wins=$(run_in_repo2 bash -c 'export GITEA_LOGIN=mosaicstack; source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
if [[ "$override_wins" != "mosaicstack" ]]; then
echo "Expected valid GITEA_LOGIN override to win on mosaicstack host; got '$override_wins'" >&2
exit 1
fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
echo "Gitea login resolution regression harness passed" echo "Gitea login resolution regression harness passed"

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Regression harness for issue-create.sh Markdown-body safety (#559).
#
# Guards against reintroduction of eval-based command construction. The wrapper
# builds its tea/gh invocation as an argv array, so a body containing command
# substitution ($(...)), backticks, quotes, and dollar signs MUST reach tea
# verbatim and MUST NOT be shell-evaluated. This test asserts both:
# 1. No command-substitution side effect (an injected `touch SENTINEL` never runs).
# 2. The --description value tea receives is byte-for-byte the original body.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-body-safety}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
SENTINEL="$WORK_DIR/INJECTION_SENTINEL"
BODY_FILE="$WORK_DIR/body.txt"
RECEIVED_FILE="$WORK_DIR/received-description.txt"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$BIN_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# Hostile Markdown body. The unquoted heredoc expands $SENTINEL (a real path we
# want embedded) but every shell metacharacter we care about is backslash-escaped
# so the TEST shell writes them literally into the file — the bytes the wrapper
# must then preserve.
cat > "$BODY_FILE" <<EOF
# Release notes
Inline code: \`rm -rf /\` must stay literal.
Command sub attempt: \$(touch $SENTINEL)
Backtick cmd attempt: \`touch $SENTINEL\`
Dollars: \$HOME \${PATH} \$5.00 and 100% done
Quotes: "double" and 'single' and \`mixed\`
Trailing pipe-ish: foo | bar && baz ; qux
EOF
BODY="$(cat "$BODY_FILE")"
# Mock tea: resolve a mosaicstack login, then capture the --description verbatim.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"jason.woltje"}
]
JSON
exit 0
fi
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
desc=""
while [[ $# -gt 0 ]]; do
case "$1" in
--description) desc="$2"; shift 2 ;;
*) shift ;;
esac
done
printf '%s' "$desc" > "$MOSAIC_TEST_RECEIVED"
echo "#1 created"
exit 0
fi
exit 0
SH
chmod +x "$BIN_DIR/tea"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
) >/dev/null
# 1. No command substitution executed anywhere in the pipeline.
if [[ -e "$SENTINEL" ]]; then
echo "FAIL: injected command substitution executed (sentinel file created): $SENTINEL" >&2
exit 1
fi
# 2. tea actually received the body (issue create path taken, not silently dropped).
if [[ ! -f "$RECEIVED_FILE" ]]; then
echo "FAIL: tea issue create was never invoked with a --description" >&2
exit 1
fi
# 3. The description tea received is byte-for-byte the original body.
if [[ "$(cat "$RECEIVED_FILE")" != "$BODY" ]]; then
echo "FAIL: body was not preserved verbatim through issue-create.sh" >&2
echo "--- expected ---" >&2; printf '%s\n' "$BODY" >&2
echo "--- received ---" >&2; cat "$RECEIVED_FILE" >&2
exit 1
fi
echo "issue-create.sh Markdown body-safety regression harness passed"

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Regression harness for lane-brief.sh PR->issue linkage classification.
#
# Covers the #546/#547 defect: lane-brief.sh inspected only the PR index/title/head
# fields and never the PR BODY, so an open PR whose body says "Closes #546" did not
# mark issue #546 as work-underway — #546 was listed as a DISPATCH CANDIDATE and was
# re-dispatchable in-flight work.
#
# Asserts:
# 1. an open issue closed-keyword-linked from a PR BODY ("Closes #546") is
# classified WORK UNDERWAY, not a dispatch candidate.
# 2. a BARE "#777" prose mention in a PR body does NOT classify #777 as
# work-underway (only Gitea closing keywords are a real link) — #777 stays a
# dispatch candidate.
# 3. NON-VACUITY / RED-ON-REVERT: a copy of the script with the body-scan removed
# misclassifies #546 as a dispatch candidate — proving the body-scan is exactly
# what fixes the defect and that assertion 1 fails if the fix is reverted.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LANE_BRIEF="$SCRIPT_DIR/lane-brief.sh"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/lane-brief-pr-linkage}"
BIN_DIR="$WORK_DIR/bin"
rm -rf "$WORK_DIR"
mkdir -p "$BIN_DIR"
# --- fake `tea`: serves a fixed open-issue set and one open PR. ----------------
# PR #547 body uses a closing keyword for #546 ("Closes #546") and a BARE mention
# of #777 ("the #777 line of work"). #777 must NOT be treated as linked.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-} ${2:-}" in
"issues list")
cat <<'JSON'
[
{"index":"546","title":"lane-brief + ci-wait orchestration tooling","assignees":[],"milestone":null,"labels":""},
{"index":"777","title":"unrelated downstream item","assignees":[],"milestone":null,"labels":""},
{"index":"999","title":"item only named inside the word hotfix","assignees":[],"milestone":null,"labels":""}
]
JSON
;;
"pulls list")
cat <<'JSON'
[
{"index":"547","title":"feat(framework/tools): orchestration helpers","head":"feat/orchestration-tools-lane-brief-ci-wait","body":"Two additive orchestration tools.\n\nCloses #546.\n\nLogin resolution is relevant to the #777 line of work but does not touch it.\nThis shipped as a hotfix #999 earlier — that bare reference must not link it.\n\nFixes #546\n"}
]
JSON
;;
*)
echo "fake-tea: unhandled: $*" >&2; exit 1 ;;
esac
SH
chmod +x "$BIN_DIR/tea"
run_brief() { # $1 = script path
PATH="$BIN_DIR:$PATH" "$1" -r mosaic/stack -L test-login 2>/dev/null
}
# Extract the issue numbers under a named section header until the next blank line.
section_nums() { # $1 = output $2 = header-prefix
printf '%s\n' "$1" | awk -v h="$2" '
index($0,h)==1 {grab=1; next}
grab && /^[[:space:]]*$/ {grab=0}
grab && match($0, /#[0-9]+/) { print substr($0, RSTART+1, RLENGTH-1) }
'
}
fail() { echo "FAIL: $1" >&2; exit 1; }
contains() { printf '%s\n' "$1" | grep -qx "$2"; }
# ---------------------------------------------------------------------------
# Fixed (current) script behavior
# ---------------------------------------------------------------------------
OUT="$(run_brief "$LANE_BRIEF")"
CAND="$(section_nums "$OUT" 'DISPATCH CANDIDATES')"
UNDER="$(section_nums "$OUT" 'WORK UNDERWAY')"
echo "--- lane-brief output (fixed) ---"; printf '%s\n' "$OUT"
echo "--- candidates: [$(printf '%s' "$CAND" | tr '\n' ' ')] underway: [$(printf '%s' "$UNDER" | tr '\n' ' ')] ---"
contains "$UNDER" 546 || fail "#546 (PR body 'Closes #546') should be WORK UNDERWAY"
contains "$CAND" 546 && fail "#546 must NOT be a dispatch candidate (it has an open PR)"
contains "$CAND" 777 || fail "#777 (only a bare prose mention) should remain a dispatch candidate"
contains "$UNDER" 777 && fail "#777 must NOT be work-underway — bare body mentions are not links"
contains "$CAND" 999 || fail "#999 ('hotfix #999' — keyword is a substring) should remain a candidate"
contains "$UNDER" 999 && fail "#999 must NOT be work-underway — word-boundary must reject 'hotfix'"
echo "PASS: body closing-keyword link classifies #546 underway; bare #777 / substring #999 stay candidates"
# ---------------------------------------------------------------------------
# NON-VACUITY: revert the body-scan and prove #546 regresses to a candidate.
# ---------------------------------------------------------------------------
REVERTED="$SCRIPT_DIR/.lane-brief.reverted.$$.sh"
trap 'rm -f "$REVERTED"' EXIT
# Drop the PR_BODY_REFS contribution from the union (simulates the pre-fix script
# that only looked at index/title/head). Sibling `source detect-platform.sh` still
# resolves because the copy lives in the same dir.
# shellcheck disable=SC2016 # single-quoted on purpose: sed needs the literal $PR_BODY_REFS
sed 's/"\$PR_BODY_REFS"/""/' "$LANE_BRIEF" > "$REVERTED"
chmod +x "$REVERTED"
grep -q 'PR_BODY_REFS' "$REVERTED" || fail "revert sed anchor not found — test is stale"
ROUT="$(run_brief "$REVERTED")"
RCAND="$(section_nums "$ROUT" 'DISPATCH CANDIDATES')"
RUNDER="$(section_nums "$ROUT" 'WORK UNDERWAY')"
echo "--- candidates(reverted): [$(printf '%s' "$RCAND" | tr '\n' ' ')] underway: [$(printf '%s' "$RUNDER" | tr '\n' ' ')] ---"
contains "$RCAND" 546 || fail "non-vacuity broken: reverted script should misclassify #546 as a candidate"
contains "$RUNDER" 546 && fail "non-vacuity broken: reverted script should NOT mark #546 underway"
echo "PASS (RED-on-revert): without the body-scan, #546 regresses to a dispatch candidate"
echo "ALL PASS: test-lane-brief-pr-linkage.sh"

View File

@@ -7,9 +7,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
REPO_DIR="$WORK_DIR/repo" REPO_DIR="$WORK_DIR/repo"
FIXTURE_DIR="$WORK_DIR/fixtures" FIXTURE_DIR="$WORK_DIR/fixtures"
STUB_DIR="$WORK_DIR/stubs"
rm -rf "$WORK_DIR" rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
@@ -56,6 +57,150 @@ cat > "$FIXTURE_DIR/gitea-error.json" <<'JSON'
{"message": "user does not exist [uid: 0, name: ]", "url": "https://git.uscllc.com/api/swagger"} {"message": "user does not exist [uid: 0, name: ]", "url": "https://git.uscllc.com/api/swagger"}
JSON JSON
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
output_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o)
output_file="$2"
shift 2
;;
-w|-H|-u)
shift 2
;;
-s|-S|-sS)
shift
;;
*)
shift
;;
esac
done
if [[ -z "$output_file" ]]; then
echo "curl stub expected -o <output_file>" >&2
exit 2
fi
case "${MOSAIC_STUB_CURL_MODE:-success}" in
success)
cat > "$output_file" <<'JSON'
{
"number": 1910,
"title": "Live curl path",
"state": "open",
"user": {"login": "edith"},
"head": {"ref": "fix/live-curl-path"},
"base": {"ref": "main"},
"html_url": "https://git.example.test/acme/widgets/pulls/1910"
}
JSON
printf '200'
;;
cat-fails-after-2xx)
rm -f -- "$output_file"
ln -s /nonexistent/pr-metadata-body "$output_file"
printf '200'
;;
*)
echo "unknown MOSAIC_STUB_CURL_MODE=${MOSAIC_STUB_CURL_MODE:-}" >&2
exit 2
;;
esac
SH
chmod +x "$STUB_DIR/curl"
assert_tmpdir_empty() {
local tmpdir="$1" leftover
leftover=$(find "$tmpdir" -mindepth 1 -print -quit)
if [[ -n "$leftover" ]]; then
echo "Expected tmpfile cleanup, found leftover: $leftover" >&2
find "$tmpdir" -mindepth 1 -maxdepth 1 -ls >&2
exit 1
fi
}
run_curl_success_case() {
local tmpdir="$WORK_DIR/tmp-success" stderr_file="$WORK_DIR/curl-success.stderr"
local output status
mkdir -p "$tmpdir"
set +e
output=$(cd "$REPO_DIR" && \
PATH="$STUB_DIR:$PATH" \
TMPDIR="$tmpdir" \
GITEA_TOKEN="stub-token" \
GITEA_URL="https://git.example.test" \
MOSAIC_STUB_CURL_MODE="success" \
"$SCRIPT_DIR/pr-metadata.sh" -n 1910 2>"$stderr_file")
status=$?
set -e
if [[ "$status" -ne 0 ]]; then
echo "Expected curl success path to pass, got status $status" >&2
cat "$stderr_file" >&2
exit 1
fi
if grep -q "unbound variable" "$stderr_file"; then
echo "curl success path emitted unbound-variable cleanup noise" >&2
cat "$stderr_file" >&2
exit 1
fi
assert_tmpdir_empty "$tmpdir"
PR_METADATA_OUTPUT="$output" python3 - <<'PY'
import json
import os
data = json.loads(os.environ["PR_METADATA_OUTPUT"])
assert data["number"] == 1910, data
assert data["baseRefName"] == "main", data
assert data["headRefName"] == "fix/live-curl-path", data
PY
}
run_curl_early_exit_cleanup_case() {
local tmpdir="$WORK_DIR/tmp-early-exit" stderr_file="$WORK_DIR/curl-early-exit.stderr"
local output status
mkdir -p "$tmpdir"
set +e
output=$(cd "$REPO_DIR" && \
PATH="$STUB_DIR:$PATH" \
TMPDIR="$tmpdir" \
GITEA_TOKEN="stub-token" \
GITEA_URL="https://git.example.test" \
MOSAIC_STUB_CURL_MODE="cat-fails-after-2xx" \
"$SCRIPT_DIR/pr-metadata.sh" -n 1910 2>"$stderr_file")
status=$?
set -e
if [[ "$status" -eq 0 ]]; then
echo "Expected unreadable 2xx body path to fail" >&2
printf '%s\n' "$output" >&2
exit 1
fi
if grep -q "unbound variable" "$stderr_file"; then
echo "curl early-exit path emitted unbound-variable cleanup noise" >&2
cat "$stderr_file" >&2
exit 1
fi
if ! grep -q "No such file or directory" "$stderr_file"; then
echo "Expected body-read failure from broken symlink path" >&2
cat "$stderr_file" >&2
exit 1
fi
if grep -q "Gitea API returned non-JSON" "$stderr_file"; then
echo "curl helper masked body-read failure as later JSON parsing failure" >&2
cat "$stderr_file" >&2
exit 1
fi
assert_tmpdir_empty "$tmpdir"
}
run_case() { run_case() {
local fixture="$1" expected_number="$2" expected_head="$3" local fixture="$1" expected_number="$2" expected_head="$3"
local output local output
@@ -77,6 +222,8 @@ PY
run_case "$FIXTURE_DIR/gitea-standard.json" 1905 edith/t_39ce717c-authentik-smoke-gate run_case "$FIXTURE_DIR/gitea-standard.json" 1905 edith/t_39ce717c-authentik-smoke-gate
run_case "$FIXTURE_DIR/gitea-fallback.json" 1908 fix/fallback-head run_case "$FIXTURE_DIR/gitea-fallback.json" 1908 fix/fallback-head
run_case "$FIXTURE_DIR/gitea-refs-pull-label.json" 1908 fix/t_23fa9e1d-portal-health-backend run_case "$FIXTURE_DIR/gitea-refs-pull-label.json" 1908 fix/t_23fa9e1d-portal-health-backend
run_curl_success_case
run_curl_early_exit_cleanup_case
if cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then if cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then
echo "Expected API error fixture to fail" >&2 echo "Expected API error fixture to fail" >&2

View File

@@ -31,9 +31,12 @@ Prepends the preamble automatically (auto-detecting your own `host:session`) and
delivers reliably to local OR remote panes. delivers reliably to local OR remote panes.
```bash ```bash
# Local target (same host) # Local target (same host, default tmux server)
agent-send.sh -s <dst_session> -m "message" agent-send.sh -s <dst_session> -m "message"
# Local target on a Mosaic fleet socket
agent-send.sh -L mosaic-factory -s '=coder0' -m "message"
# Remote target (over ssh) # Remote target (over ssh)
agent-send.sh -H user@host -s <dst_session> -m "message" agent-send.sh -H user@host -s <dst_session> -m "message"
@@ -42,10 +45,27 @@ agent-send.sh -H user@host -s <dst_session> -f msg.txt
echo "msg" | agent-send.sh -s <dst_session> echo "msg" | agent-send.sh -s <dst_session>
``` ```
Key flags: `-s` dst session (required) · `-H` ssh target for remote · `-n` dst Key flags: `-L` named tmux socket · `-s` dst session (required) · `-H` ssh target for remote · `-n` dst
hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S` hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S`
override source label · `-v` verbose · `-r N` Enter-flush attempts. override source label · `-v` verbose · `-r N` Enter-flush attempts.
For durable fleet use, prefer exact tmux targets such as `=coder0`. The helper
normalizes exact session targets to pane-qualified targets internally so pane
commands do not fall back to tmux's prefix matching behavior.
## Named socket isolation
Durable Mosaic fleets should use a dedicated tmux socket, for example:
```bash
tmux -L mosaic-factory ls
agent-send.sh -L mosaic-factory -s '=coder0' -m "status?"
send-message.sh -L mosaic-factory -t '=coder0' -m "raw pane message"
```
This keeps fleet operations away from the user's default tmux server. It is the
safe rollout path on hosts that already have manual tmux sessions.
## Why a helper exists (the submission gotcha) ## Why a helper exists (the submission gotcha)
Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a
@@ -67,6 +87,7 @@ message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch). - `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input). - `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
## Distribution ## Distribution

View File

@@ -12,10 +12,6 @@
# ambiguity about lanes or origin. Recipients replying should FLIP the # ambiguity about lanes or origin. Recipients replying should FLIP the
# preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply). # preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply).
# #
# Optionally tags the message with a TRIAGE CLASS (see -C / --class) so a
# comms daemon can route it (deliver-to-agent vs log-and-drop) from an exact
# field instead of re-deriving intent from the body.
#
# WHY A WRAPPER # WHY A WRAPPER
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly: # Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
# a trailing Enter is often swallowed and the message sits as an unsubmitted # a trailing Enter is often swallowed and the message sits as an unsubmitted
@@ -27,73 +23,40 @@
# the remote host; only bash + tmux + base64 (standard). # the remote host; only bash + tmux + base64 (standard).
# #
# USAGE # USAGE
# agent-send.sh -s <dst_session> -m "message" # local target # agent-send.sh [-L socket] -s <dst_session> -m "message" # local target
# agent-send.sh -H user@host -s <dst_session> -m "message" # remote target # agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target
# agent-send.sh -H user@host -n <dst_hostname> -s <sess> -f msg.txt # agent-send.sh [-L socket] -H user@host -n <dst_hostname> -s <sess> -f msg.txt
# agent-send.sh -s mos-claude --class terminal-log -m "ACK — received" # echo "msg" | agent-send.sh [-L socket] -H user@host -s <dst_session>
# echo "msg" | agent-send.sh -H user@host -s <dst_session>
# #
# OPTIONS # OPTIONS
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
# -s DST_SESSION target tmux session (or session:window.pane) [required] # -s DST_SESSION target tmux session (or session:window.pane) [required]
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local # -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
# -n DST_HOST hostname to show in the preamble for the target. # -n DST_HOST hostname to show in the preamble for the target.
# Default: local hostname, or (remote) resolved via one ssh. # Default: local hostname, or (remote) resolved via one ssh.
# -m MESSAGE message text (single- or multi-line) # -m MESSAGE message text (single- or multi-line)
# -f FILE read message from FILE instead of -m # -f FILE read message from FILE instead of -m
# -C CLASS triage class for a comms daemon. One of:
# terminal-log log-only; never needs the agent's attention
# actionable carries a decision/blocker/gate — deliver
# human from a human operator — deliver
# reaction an emoji/ack reaction
# Long form: --class CLASS (or --class=CLASS). When SET, the
# preamble carries a ` class=<CLASS>` token INSIDE the bracket:
# [<src> -> <dst> class=terminal-log] <message>
# When OMITTED, NO token is emitted and the preamble is
# byte-for-byte identical to the classic format. Consumers MUST
# treat an absent class as 'actionable' (fail-safe: agent sees it).
# -S SRC_LABEL override source label "<host>:<session>" (default: auto) # -S SRC_LABEL override source label "<host>:<session>" (default: auto)
# -r N Enter-flush attempts passed through (default 2) # -r N Enter-flush attempts passed through (default 2)
# -v verbose: print pane tail after delivery # -v verbose: print pane tail after delivery
# -h help # -h help
# #
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
# ^\[(\S+) -> (\S+?)(?: class=(terminal-log|actionable|human|reaction))?\] (.*)$
# group 1 = src label group 2 = dst host:session
# group 3 = class (absent => actionable) group 4 = message body
#
# EXIT CODES (passed through from send-message.sh) # EXIT CODES (passed through from send-message.sh)
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error # 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
set -uo pipefail set -uo pipefail
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd) SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
# Sender is overridable via env purely for testing (inject a capture stub). The SENDER="$SELF_DIR/send-message.sh"
# default is the canonical send-message.sh beside this script; production callers
# never set AGENT_SEND_SENDER, so behavior is unchanged.
SENDER="${AGENT_SEND_SENDER:-$SELF_DIR/send-message.sh}"
# Translate the long option --class[=value] into "-C value" so getopts (which is DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME=""
# short-option-only) can parse it. Every other argument passes through untouched, SRC_LABEL=""; RETRIES=2; VERBOSE=0
# so callers that never use --class hit the exact original getopts path. usage() { sed -n '2,44p' "$0"; exit "${1:-3}"; }
args=()
while [ $# -gt 0 ]; do
case "$1" in
--class) [ $# -ge 2 ] || { echo "ERROR: --class requires a value" >&2; exit 3; }
args+=(-C "$2"); shift 2 ;;
--class=*) args+=(-C "${1#*=}"); shift ;;
*) args+=("$1"); shift ;;
esac
done
set -- ${args[@]+"${args[@]}"}
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE="" while getopts "L:s:H:n:m:f:S:r:vh" o; do
SRC_LABEL=""; RETRIES=2; VERBOSE=0; CLASS=""
usage() { sed -n '2,/^set -uo pipefail/{/^set -uo pipefail/d;p}' "$0"; exit "${1:-3}"; }
while getopts "s:H:n:m:f:S:r:C:vh" o; do
case "$o" in case "$o" in
L) SOCKET_NAME=$OPTARG ;;
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;; s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
C) CLASS=$OPTARG ;;
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;; r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
esac esac
done done
@@ -101,17 +64,6 @@ done
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; } [ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; } [ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
# Validate the triage class only when one was given. An absent class emits NO
# token (preamble byte-identical to the classic format); the consumer defaults
# absent => actionable.
CLASS_TOKEN=""
if [ -n "$CLASS" ]; then
case "$CLASS" in
terminal-log|actionable|human|reaction) CLASS_TOKEN=" class=${CLASS}" ;;
*) echo "ERROR: invalid --class '$CLASS' (allowed: terminal-log, actionable, human, reaction)" >&2; exit 3 ;;
esac
fi
# Message body from -f / -m / stdin. # Message body from -f / -m / stdin.
if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE") if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat) elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
@@ -120,8 +72,12 @@ fi
# Source label: this agent's host:session (auto-detected, overridable). # Source label: this agent's host:session (auto-detected, overridable).
if [ -z "$SRC_LABEL" ]; then if [ -z "$SRC_LABEL" ]; then
tmux_cmd=(tmux)
if [ -n "$SOCKET_NAME" ]; then
tmux_cmd+=(-L "$SOCKET_NAME")
fi
src_host=$(hostname -s 2>/dev/null || echo "?") src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?") src_sess=$("${tmux_cmd[@]}" display-message -p '#S' 2>/dev/null || echo "?")
SRC_LABEL="${src_host}:${src_sess}" SRC_LABEL="${src_host}:${src_sess}"
fi fi
@@ -134,17 +90,21 @@ if [ -z "$DST_HOST" ]; then
fi fi
fi fi
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}${CLASS_TOKEN}]" PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}]"
FULL="${PREAMBLE} ${MSG}" FULL="${PREAMBLE} ${MSG}"
B64=$(printf '%s' "$FULL" | base64 -w0) B64=$(printf '%s' "$FULL" | base64 -w0)
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v" vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
socket_args=()
if [ -n "$SOCKET_NAME" ]; then
socket_args=(-L "$SOCKET_NAME")
fi
if [ -z "$SSH_TARGET" ]; then if [ -z "$SSH_TARGET" ]; then
# Local pane: call the canonical sender directly. # Local pane: call the canonical sender directly.
exec "$SENDER" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag exec "$SENDER" "${socket_args[@]}" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag
else else
# Remote pane: ship the sender over ssh and run it local to the target. # Remote pane: ship the sender over ssh and run it local to the target.
ssh -o ConnectTimeout=10 "$SSH_TARGET" \ ssh -o ConnectTimeout=10 "$SSH_TARGET" \
"bash -s -- -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER" "bash -s -- ${socket_args[*]@Q} -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
fi fi

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env bash
# agent-send.test.sh — regression + grammar lock for agent-send.sh --class.
#
# Strategy: inject a capture stub via AGENT_SEND_SENDER that decodes the -b
# base64 payload and prints the FULL message (preamble + body) so we can assert
# the exact bytes on the wire. Local path only (no ssh), -n pins the dst host so
# the preamble is deterministic across machines.
#
# Guarantees locked here:
# 1. REGRESSION BAR — no --class => preamble byte-for-byte identical to classic.
# 2. --class <c> => ` class=<c>` token emitted inside the bracket.
# 3. --class=<c> (equals form) parses identically to the space form.
# 4. -C <c> short form parses identically.
# 5. invalid class => exit 3, nothing sent.
# 6. --class with no value => exit 3.
# 7. the documented consumer regex parses producer output for every class.
set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
TOOL="$HERE/agent-send.sh"
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
STUB=$(mktemp)
trap 'rm -f "$STUB"' EXIT
cat >"$STUB" <<'STUB_EOF'
#!/usr/bin/env bash
set -uo pipefail
b64=""
while getopts "t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
printf '%s' "$b64" | base64 -d
STUB_EOF
chmod +x "$STUB"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
# Run the tool with the stub injected; echoes captured payload on stdout.
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
# Documented consumer grammar — the daemon will mirror exactly this.
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction)\] (.*)$'
GRAMMAR_NOCLASS='^\[(\S+) -> (\S+)\] (.*)$'
# 1. REGRESSION BAR: classic preamble, byte-for-byte.
got=$(run -s mos -m "hello world")
want='[a:src -> dsthost:mos] hello world'
[ "$got" = "$want" ] && ok "regression: no --class is byte-identical" \
|| no "regression: no --class is byte-identical" "got=[$got] want=[$want]"
# 2. --class space form emits the token.
got=$(run -s mos --class terminal-log -m "ACK")
want='[a:src -> dsthost:mos class=terminal-log] ACK'
[ "$got" = "$want" ] && ok "--class terminal-log emits token" \
|| no "--class terminal-log emits token" "got=[$got] want=[$want]"
# 3. --class=value equals form.
got=$(run -s mos --class=actionable -m "decide X")
want='[a:src -> dsthost:mos class=actionable] decide X'
[ "$got" = "$want" ] && ok "--class=actionable (equals form)" \
|| no "--class=actionable (equals form)" "got=[$got] want=[$want]"
# 4. -C short form.
got=$(run -s mos -C human -m "from a person")
want='[a:src -> dsthost:mos class=human] from a person'
[ "$got" = "$want" ] && ok "-C human (short form)" \
|| no "-C human (short form)" "got=[$got] want=[$want]"
# 5. invalid class => exit 3, no send.
if out=$(run -s mos --class bogus -m "x" 2>/dev/null); then
no "invalid class rejected" "expected non-zero exit, got 0 (out=[$out])"
else
rc=$?
[ "$rc" = 3 ] && [ -z "$out" ] && ok "invalid class => exit 3, nothing sent" \
|| no "invalid class => exit 3, nothing sent" "rc=$rc out=[$out]"
fi
# 6. --class with no value => exit 3.
if run -s mos -m "x" --class 2>/dev/null; then
no "--class with no value rejected" "expected non-zero exit, got 0"
else
[ "$?" = 3 ] && ok "--class with no value => exit 3" || no "--class with no value => exit 3" "wrong rc"
fi
# 7. consumer grammar parses every class + classic line.
for c in terminal-log actionable human reaction; do
line=$(run -s mos --class "$c" -m "body $c")
[[ "$line" =~ $GRAMMAR ]] && [ "${BASH_REMATCH[3]}" = "$c" ] && [ "${BASH_REMATCH[4]}" = "body $c" ] \
&& ok "grammar parses class=$c" || no "grammar parses class=$c" "line=[$line]"
done
classic=$(run -s mos -m "plain body")
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
echo "---"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]

View File

@@ -13,12 +13,13 @@
# no-op in Claude Code, so the double-Enter is safe. # no-op in Claude Code, so the double-Enter is safe.
# #
# USAGE # USAGE
# send-message.sh -t <target> -m "message" # send-message.sh [-L socket_name] -t <target> -m "message"
# send-message.sh -t <target> -f <file> # send-message.sh [-L socket_name] -t <target> -f <file>
# echo "message" | send-message.sh -t <target> # echo "message" | send-message.sh [-L socket_name] -t <target>
# ssh host bash -s -- -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh # ssh host bash -s -- -L socket -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
# #
# OPTIONS # OPTIONS
# -L NAME tmux socket name passed to `tmux -L NAME` (optional)
# -t TARGET tmux target: session, or session:window.pane [required] # -t TARGET tmux target: session, or session:window.pane [required]
# -m MESSAGE message text (single- or multi-line) # -m MESSAGE message text (single- or multi-line)
# -f FILE read message from FILE instead of -m # -f FILE read message from FILE instead of -m
@@ -34,11 +35,12 @@
# 3 usage error # 3 usage error
set -uo pipefail set -uo pipefail
TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0 SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
usage() { sed -n '2,34p' "$0"; exit "${1:-3}"; } usage() { sed -n '2,34p' "$0"; exit "${1:-3}"; }
while getopts "t:m:f:b:r:vh" o; do while getopts "L:t:m:f:b:r:vh" o; do
case "$o" in case "$o" in
L) SOCKET_NAME=$OPTARG ;;
t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;; t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;; r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
esac esac
@@ -51,8 +53,21 @@ elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
fi fi
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; } [ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
tmux_cmd=(tmux)
if [ -n "$SOCKET_NAME" ]; then
tmux_cmd+=(-L "$SOCKET_NAME")
fi
# tmux accepts `=session` for some commands, but pane-level commands such as
# capture-pane require a pane-qualified target. Keep exact-session addressing
# convenient while avoiding accidental prefix matches.
EFFECTIVE_TARGET=$TARGET
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
EFFECTIVE_TARGET="${TARGET}:0.0"
fi
# Target must resolve to a live pane. # Target must resolve to a live pane.
if ! tmux list-panes -t "$TARGET" >/dev/null 2>&1; then if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
echo "ERROR: tmux target not found: $TARGET" >&2; exit 1 echo "ERROR: tmux target not found: $TARGET" >&2; exit 1
fi fi
@@ -62,18 +77,18 @@ snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g'
# 1) Paste the body as a bracketed paste so multi-line content does not submit # 1) Paste the body as a bracketed paste so multi-line content does not submit
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`. # line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
printf '%s' "$MSG" | tmux load-buffer -b __mosaic_send - printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send -
# -p = bracketed paste when the client supports it; fall back if not. # -p = bracketed paste when the client supports it; fall back if not.
tmux paste-buffer -d -p -b __mosaic_send -t "$TARGET" 2>/dev/null \ "${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \
|| tmux paste-buffer -d -b __mosaic_send -t "$TARGET" || "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET"
sleep 0.5 sleep 0.5
# 2) Submit, then verify; flush with another Enter if it is still a draft. # 2) Submit, then verify; flush with another Enter if it is still a draft.
status="sent" status="sent"
for attempt in $(seq 1 $((RETRIES + 1))); do for attempt in $(seq 1 $((RETRIES + 1))); do
tmux send-keys -t "$TARGET" Enter "${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
sleep 1.2 sleep 1.2
pane=$(tmux capture-pane -t "$TARGET" -p 2>/dev/null) pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then
status="queued"; break status="queued"; break

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
SEND_MESSAGE="$SCRIPT_DIR/send-message.sh"
AGENT_SEND="$SCRIPT_DIR/agent-send.sh"
SOCKET="mosaic-test-$RANDOM-$$"
TARGET="target-$RANDOM"
DEFAULT_TARGET="default-target-$RANDOM"
TMPDIR=$(mktemp -d)
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; rm -rf "$TMPDIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
require_tmux() {
command -v tmux >/dev/null 2>&1 || fail "tmux is required"
}
capture_named() {
tmux -L "$SOCKET" capture-pane -t "=$TARGET:0.0" -p
}
capture_default() {
tmux capture-pane -t "=$DEFAULT_TARGET:0.0" -p
}
require_tmux
tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'bash --noprofile --norc -i'
tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'bash --noprofile --norc -i'
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
sleep 0.2
capture_named | grep -qF "named socket hello" || fail "send-message.sh did not deliver to named socket"
if capture_default | grep -qF "named socket hello"; then
fail "send-message.sh leaked named-socket message to default tmux server"
fi
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out
sleep 0.2
capture_named | grep -qF "[tester:source ->" || fail "agent-send.sh did not include preamble"
capture_named | grep -qF "agent socket hello" || fail "agent-send.sh did not deliver to named socket"
if capture_default | grep -qF "agent socket hello"; then
fail "agent-send.sh leaked named-socket message to default tmux server"
fi
echo "ok - named tmux socket send tools"

View File

@@ -26,11 +26,12 @@ A Woodpecker API token is required. To configure:
## Scripts ## Scripts
| Script | Purpose | | Script | Purpose |
| --------------------- | ------------------------------------------- | | --------------------- | -------------------------------------------- |
| `pipeline-list.sh` | List recent pipelines for a repo | | `pipeline-list.sh` | List recent pipelines for a repo |
| `pipeline-status.sh` | Get status of a specific or latest pipeline | | `pipeline-status.sh` | Get status of a specific or latest pipeline |
| `pipeline-trigger.sh` | Trigger a new pipeline build | | `pipeline-trigger.sh` | Trigger a new pipeline build |
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
## Common Options ## Common Options
@@ -55,4 +56,7 @@ A Woodpecker API token is required. To configure:
# Trigger a build on a specific branch # Trigger a build on a specific branch
~/.config/mosaic/tools/woodpecker/pipeline-trigger.sh -b feature/my-branch ~/.config/mosaic/tools/woodpecker/pipeline-trigger.sh -b feature/my-branch
# Block until one or more pipelines finish (event-driven CI wait)
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
``` ```

View File

@@ -12,7 +12,7 @@ wp_resolve_repo_id() {
local full_name="$1" local full_name="$1"
local response http_code body repo_id local response http_code body repo_id
response=$(curl -sk -w "\n%{http_code}" \ response=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"${WOODPECKER_URL}/api/repos/lookup/${full_name}") "${WOODPECKER_URL}/api/repos/lookup/${full_name}")

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# ci-wait.sh — block until one or more Woodpecker pipelines reach terminal state.
#
# Problem it solves: orchestrators hand-author a `while true; curl .../repos/1/pipelines/$n
# ...; sleep` loop for every CI wait. Those loops HARDCODE Woodpecker repo id 1 (only
# correct for whichever repo happens to be id 1), re-implement URL building with raw
# curl, and tend to get armed as tight <300s ScheduleWakeup polls (each poll = a full
# wake+reload+recheck cycle). This encapsulates the loop once, on top of the existing
# `pipeline-status.sh` wrapper (which resolves repo->id correctly and is instance-aware),
# so a CI wait becomes a one-liner.
#
# Intended use: as the COMMAND of a Monitor / event-driven re-invoke (primary), paired
# with a single long (>=1500s) timed fallback — NOT as a tight standalone poll.
#
# Usage:
# ci-wait.sh -r <owner/repo> -n <num> [-n <num> ...] [-a <instance>] [-i <interval>] [-t <timeout>]
# ci-wait.sh -r usc/uconnect -n 3917 -n 3918 # wait for both, infer instance
# ci-wait.sh -r usc/uconnect -n 3922 -a usc -i 30 -t 2400
#
# Instance is inferred from the owner (usc->usc, mosaicstack/mosaic->mosaic) unless -a given.
# Exit: 0 = all pipelines terminal AND all 'success'; 1 = >=1 terminal non-success;
# 2 = usage/precondition error; 3 = timeout before all terminal.
set -euo pipefail
# Resolve pipeline-status.sh as a sibling, matching how the woodpecker tools source
# _lib.sh — works under the installed runtime AND an in-repo checkout, no MOSAIC_HOME dep.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PS="$SCRIPT_DIR/pipeline-status.sh"
REPO="" INSTANCE="" INTERVAL=30 TIMEOUT=3600
NUMS=()
while getopts "r:n:a:i:t:h" opt; do
case "$opt" in
r) REPO="$OPTARG" ;;
n) NUMS+=("$OPTARG") ;;
a) INSTANCE="$OPTARG" ;;
i) INTERVAL="$OPTARG" ;;
t) TIMEOUT="$OPTARG" ;;
h) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "see -h" >&2; exit 2 ;;
esac
done
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
[[ ${#NUMS[@]} -gt 0 ]] || { echo "FATAL: at least one -n <pipeline-number> required" >&2; exit 2; }
[[ -x "$PS" ]] || { echo "FATAL: pipeline-status.sh not found/executable at $PS" >&2; exit 2; }
# Infer Woodpecker instance from owner unless overridden (matches the git-wrapper convention).
if [[ -z "$INSTANCE" ]]; then
case "${REPO%%/*}" in
usc|USC) INSTANCE=usc ;;
mosaicstack|mosaic) INSTANCE=mosaic ;;
*) echo "FATAL: cannot infer Woodpecker instance for owner '${REPO%%/*}' — pass -a <instance>" >&2; exit 2 ;;
esac
fi
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 2; }
TERMINAL_RE='^(success|failure|error|killed|declined|blocked)$'
declare -A STATE=() # num -> terminal status, once reached
start=$(date +%s 2>/dev/null || echo 0)
echo "ci-wait: $REPO pipelines [${NUMS[*]}] (instance=$INSTANCE, every ${INTERVAL}s, timeout ${TIMEOUT}s)"
while true; do
for n in "${NUMS[@]}"; do
[[ -n "${STATE[$n]:-}" ]] && continue
s=$("$PS" -r "$REPO" -n "$n" -a "$INSTANCE" -f json 2>/dev/null | jq -r '.status // empty' 2>/dev/null || true)
if [[ "$s" =~ $TERMINAL_RE ]]; then
STATE[$n]="$s"
echo " pipeline $n TERMINAL: $s"
fi
done
# all terminal?
if [[ ${#STATE[@]} -eq ${#NUMS[@]} ]]; then
bad=0
for n in "${NUMS[@]}"; do [[ "${STATE[$n]}" == "success" ]] || bad=1; done
if [[ $bad -eq 0 ]]; then echo "ci-wait: ALL SUCCESS"; exit 0; fi
echo "ci-wait: all terminal, NOT all success — $(for n in "${NUMS[@]}"; do printf '%s=%s ' "$n" "${STATE[$n]}"; done)"
exit 1
fi
now=$(date +%s 2>/dev/null || echo 0)
if [[ "$start" != 0 && $((now - start)) -ge $TIMEOUT ]]; then
echo "ci-wait: TIMEOUT after ${TIMEOUT}s — pending: $(for n in "${NUMS[@]}"; do [[ -z "${STATE[$n]:-}" ]] && printf '%s ' "$n"; done)"
exit 3
fi
sleep "$INTERVAL"
done

View File

@@ -48,7 +48,7 @@ fi
# Resolve owner/repo to numeric ID (Woodpecker v3 API) # Resolve owner/repo to numeric ID (Woodpecker v3 API)
REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1 REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
response=$(curl -sk -w "\n%{http_code}" \ response=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}") "${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}")

View File

@@ -50,7 +50,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
_wp_fetch() { _wp_fetch() {
local ep="$1" local ep="$1"
local resp http_code body local resp http_code body
resp=$(curl -sk -w "\n%{http_code}" \ resp=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"$ep") "$ep")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)

View File

@@ -46,7 +46,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
echo "Triggering pipeline for $REPO on branch $BRANCH..." echo "Triggering pipeline for $REPO on branch $BRANCH..."
response=$(curl -sk -w "\n%{http_code}" -X POST \ response=$(curl -sS -w "\n%{http_code}" -X POST \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \ -d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Regression harness for ci-wait.sh terminal-state aggregation and exit codes.
#
# ci-wait.sh wraps pipeline-status.sh and blocks until every requested pipeline
# reaches a terminal Woodpecker state, then maps the aggregate to an exit code.
# That contract is what callers arm a Monitor/timed-fallback around, so it must be
# exact. This harness drives ci-wait.sh against a stub pipeline-status.sh whose
# per-pipeline status is fixture-controlled, and asserts the full exit matrix:
#
# 0 = every pipeline terminal AND all 'success'
# 1 = every pipeline terminal, at least one non-success
# 2 = usage/precondition error (missing -n)
# 3 = timeout before all pipelines terminal
#
# Non-vacuity: each case pins a DISTINCT exit code to a distinct fixture, so a
# regression in success-aggregation (case 0 vs 1), terminal detection (case 3),
# or arg validation (case 2) flips exactly one assertion RED.
set -euo pipefail
CIW_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ci-wait.sh"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-wait-exit-matrix}"
TOOL_DIR="$WORK_DIR/tool"
rm -rf "$WORK_DIR"
mkdir -p "$TOOL_DIR"
# ci-wait.sh resolves pipeline-status.sh as a sibling ($SCRIPT_DIR/pipeline-status.sh),
# so we run a COPY of ci-wait.sh next to a stub sibling we control.
cp "$CIW_SRC" "$TOOL_DIR/ci-wait.sh"
chmod +x "$TOOL_DIR/ci-wait.sh"
# Stub pipeline-status.sh: emits {"status":"<s>"} where <s> comes from env
# CIW_STATUS_<num> (default "running" = non-terminal, drives the timeout path).
cat > "$TOOL_DIR/pipeline-status.sh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
num=""
while getopts "r:n:a:f:" opt; do case "$opt" in n) num="$OPTARG" ;; *) : ;; esac; done
var="CIW_STATUS_${num}"
printf '{"status":"%s"}\n' "${!var:-running}"
SH
chmod +x "$TOOL_DIR/pipeline-status.sh"
CIW="$TOOL_DIR/ci-wait.sh"
run_expect() { # $1 = expected exit $2 = label ; rest = args
local want="$1" label="$2"; shift 2
local rc=0
"$CIW" "$@" >/dev/null 2>&1 || rc=$?
if [[ "$rc" -ne "$want" ]]; then
echo "FAIL [$label]: expected exit $want, got $rc" >&2; exit 1
fi
echo "PASS [$label]: exit $rc"
}
# 0 — both pipelines terminal + success
CIW_STATUS_100=success CIW_STATUS_101=success \
run_expect 0 "all-success" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
# 1 — both terminal, one failure
CIW_STATUS_100=success CIW_STATUS_101=failure \
run_expect 1 "terminal-not-success" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
# 1 — other terminal non-success states still map to 1 (error/killed)
CIW_STATUS_100=error CIW_STATUS_101=killed \
run_expect 1 "terminal-error-killed" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
# 3 — a pipeline never reaches terminal state before timeout
CIW_STATUS_100=success CIW_STATUS_101=running \
run_expect 3 "timeout-pending" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 0
# 2 — usage error: no -n
run_expect 2 "usage-missing-n" -r mosaic/stack -a mosaic
echo "ALL PASS: test-ci-wait-exit-matrix.sh"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/mosaic", "name": "@mosaicstack/mosaic",
"version": "0.0.31", "version": "0.0.34",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/stack.git",

View File

@@ -13,6 +13,7 @@ import { registerStorageCommand } from '@mosaicstack/storage';
import { registerTelemetryCommand } from './commands/telemetry.js'; import { registerTelemetryCommand } from './commands/telemetry.js';
import { registerAgentCommand } from './commands/agent.js'; import { registerAgentCommand } from './commands/agent.js';
import { registerConfigCommand } from './commands/config.js'; import { registerConfigCommand } from './commands/config.js';
import { registerFleetCommand } from './commands/fleet.js';
import { registerMissionCommand } from './commands/mission.js'; import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js'; import { registerUninstallCommand } from './commands/uninstall.js';
// prdy is registered via launch.ts // prdy is registered via launch.ts
@@ -57,7 +58,7 @@ Command Groups:
Runtime: tui, login, sessions Runtime: tui, login, sessions
Gateway: gateway Gateway: gateway
Framework: agent, bootstrap, coord, doctor, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
Platform: update Platform: update
Runtimes: claude, codex, opencode, pi Runtimes: claude, codex, opencode, pi
`, `,
@@ -345,6 +346,10 @@ registerFederationCommand(program);
registerAgentCommand(program); registerAgentCommand(program);
// ─── fleet ─────────────────────────────────────────────────────────────
registerFleetCommand(program);
// ─── config ──────────────────────────────────────────────────────────── // ─── config ────────────────────────────────────────────────────────────
registerConfigCommand(program); registerConfigCommand(program);

View File

@@ -1,4 +1,5 @@
import type { Command } from 'commander'; import type { Command } from 'commander';
import { registerFleetAgentCommands, type FleetCommandDeps } from './fleet.js';
import { withAuth } from './with-auth.js'; import { withAuth } from './with-auth.js';
import { selectItem } from './select-dialog.js'; import { selectItem } from './select-dialog.js';
import { import {
@@ -30,11 +31,13 @@ function showAgentDetail(a: AgentConfigInfo) {
console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`); console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`);
} }
export function registerAgentCommand(program: Command) { export function registerAgentCommand(program: Command, fleetDeps: FleetCommandDeps = {}) {
const cmd = program const cmd = program
.command('agent') .command('agent')
.description('Manage agent configurations') .description('Manage agent configurations and local fleet agents')
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--mosaic-home <path>', 'Mosaic home directory')
.option('--roster <path>', 'Local fleet roster path')
.option('--list', 'List all agents') .option('--list', 'List all agents')
.option('--new', 'Create a new agent') .option('--new', 'Create a new agent')
.option('--show <idOrName>', 'Show agent details') .option('--show <idOrName>', 'Show agent details')
@@ -72,6 +75,8 @@ export function registerAgentCommand(program: Command) {
}, },
); );
registerFleetAgentCommands(cmd, fleetDeps);
return cmd; return cmd;
} }

View File

@@ -0,0 +1,738 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
buildAgentSendCommand,
buildFleetServiceCommand,
generateAgentEnv,
getDefaultOperatorSourceLabel,
getRosterAgent,
loadFleetRoster,
mergeAgentEnv,
registerFleetCommand,
resolveFleetPaths,
type CommandRunner,
} from './fleet.js';
import { registerAgentCommand } from './agent.js';
function buildProgram(): Command {
const program = new Command();
program.exitOverride();
registerFleetCommand(program);
registerAgentCommand(program);
return program;
}
async function tempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
}
describe('registerFleetCommand', () => {
it('registers local canary fleet subcommands', () => {
const program = buildProgram();
const fleet = program.commands.find((command) => command.name() === 'fleet');
expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'init',
'install',
'install-systemd',
'restart',
'start',
'status',
'stop',
'verify',
]);
});
it('adds fleet-backed agent subcommands without removing existing options', () => {
const program = buildProgram();
const agent = program.commands.find((command) => command.name() === 'agent');
expect(agent).toBeDefined();
expect(agent!.options.map((option) => option.long)).toContain('--list');
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
'reset',
'roster',
'send',
'status',
'tail',
]);
});
});
describe('fleet roster parsing', () => {
let cleanup: string | undefined;
afterEach(async () => {
if (cleanup) {
await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
}
});
it('defaults local canary rosters to the isolated mosaic-factory socket', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: canary-pi',
' runtime: pi',
' class: canary',
].join('\n'),
);
const roster = await loadFleetRoster(rosterPath);
expect(roster.tmux.socketName).toBe('mosaic-factory');
expect(roster.tmux.holderSession).toBe('_holder');
expect(roster.agents).toHaveLength(1);
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
});
it('generates deterministic per-agent EnvironmentFile content', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
tmux: { socket_name: 'mosaic-factory' },
defaults: { working_directory: '/srv/mosaic' },
agents: [{ name: 'coder0', runtime: 'codex', class: 'implementer' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
'MOSAIC_TMUX_SOCKET=mosaic-factory',
'',
].join('\n'),
);
});
it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => {
const generated = [
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_WORKDIR=/srv/new',
'MOSAIC_TMUX_SOCKET=mosaic-factory',
'',
].join('\n');
const existing = [
'MOSAIC_AGENT_NAME=old-name',
'MOSAIC_AGENT_RUNTIME=old-runtime',
'MOSAIC_AGENT_WORKDIR=/srv/old',
'MOSAIC_TMUX_SOCKET=old-socket',
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
'# site note',
'',
].join('\n');
expect(mergeAgentEnv(generated, existing)).toBe(
[
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_WORKDIR=/srv/new',
'MOSAIC_TMUX_SOCKET=mosaic-factory',
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
'# site note',
'',
].join('\n'),
);
});
it('rejects unknown roster fields instead of silently defaulting', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'tmux:',
' socketNamee: prod-fleet',
'agents:',
' - name: canary-pi',
' runtime: pi',
].join('\n'),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster tmux has unknown field(s): socketNamee.',
);
});
it('rejects wrong-typed roster fields instead of silently defaulting', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
tmux: { socket_name: 123 },
defaults: { working_directory: '/srv/mosaic' },
agents: [{ name: 'canary-pi', runtime: 'pi' }],
}),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster tmux socket_name must be a string.',
);
});
it('rejects wrong-typed agent fields', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'canary-pi', runtime: 42 }],
}),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster agent "canary-pi" runtime must be a string.',
);
});
it('rejects duplicate agent names before install can overwrite env files', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: canary-pi',
' runtime: pi',
' - name: canary-pi',
' runtime: codex',
].join('\n'),
);
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
'Fleet roster has duplicate agent name: canary-pi.',
);
});
it('ships generic minimal and local-canary examples without site-specific defaults', async () => {
const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples');
const minimal = await loadFleetRoster(join(examplesDir, 'minimal.yaml'));
const localCanaryText = await readFile(join(examplesDir, 'local-canary.yaml'), 'utf8');
const localCanary = await loadFleetRoster(join(examplesDir, 'local-canary.yaml'));
expect(minimal.agents.map((agent) => agent.name)).toEqual(['canary-pi']);
expect(localCanary.tmux.socketName).toBe('mosaic-factory');
expect(localCanary.agents.map((agent) => agent.name)).toEqual(['lead', 'coder0', 'reviewer0']);
expect(localCanaryText).not.toMatch(/usc|ultron|secrev/i);
});
});
describe('fleet command construction', () => {
it('builds exact systemd user commands for holder and agent operations', () => {
expect(buildFleetServiceCommand('status')).toEqual([
'systemctl',
'--user',
'status',
'mosaic-tmux-holder.service',
]);
expect(buildFleetServiceCommand('restart', 'coder0')).toEqual([
'systemctl',
'--user',
'restart',
'mosaic-agent@coder0.service',
]);
});
it('builds socket-scoped agent send commands', () => {
const paths = resolveFleetPaths('/home/test/.config/mosaic');
expect(
buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory', 'operator:mosaic-cli'),
).toEqual([
'/home/test/.config/mosaic/tools/tmux/agent-send.sh',
'-L',
'mosaic-factory',
'-S',
'operator:mosaic-cli',
'-s',
'coder0',
'-m',
'hello',
]);
});
it('runs fleet status through injected runner without touching tmux in tests', async () => {
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: 'ok\n', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner });
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
});
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: 'active (exited)\n', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']);
expect(calls).toEqual([
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=_holder:0.0'],
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=coder0:0.0'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('writes init output to the explicit roster path', async () => {
const home = await tempDir();
const rosterPath = join(home, 'custom', 'roster.yaml');
const frameworkRoot = resolve(process.cwd(), 'framework');
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'minimal',
'--write',
]);
const content = await readFile(rosterPath, 'utf8');
expect(content).toContain('name: canary-pi');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('refuses to overwrite an existing roster unless --force is provided', async () => {
const home = await tempDir();
const rosterPath = join(home, 'custom', 'roster.yaml');
await mkdir(dirname(rosterPath), { recursive: true });
await writeFile(rosterPath, 'site-owned: true\n');
const frameworkRoot = resolve(process.cwd(), 'framework');
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
try {
await expect(
program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'minimal',
'--write',
]),
).rejects.toThrow('Fleet roster already exists');
expect(await readFile(rosterPath, 'utf8')).toBe('site-owned: true\n');
await program.parseAsync([
'node',
'mosaic',
'fleet',
'--roster',
rosterPath,
'init',
'--profile',
'minimal',
'--write',
'--force',
]);
expect(await readFile(rosterPath, 'utf8')).toContain('name: canary-pi');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('rejects unknown init profiles instead of silently falling back', async () => {
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') });
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'typo']),
).rejects.toThrow('Unsupported fleet profile');
});
it('sets process exitCode when status runner fails', async () => {
const originalExitCode = process.exitCode;
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(process.exitCode).toBe(3);
} finally {
process.exitCode = originalExitCode;
stderrSpy.mockRestore();
}
});
it('loads default fleet/roster.json when roster.yaml is absent', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.json'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'json-canary', runtime: 'pi' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'status', 'json-canary']);
expect(calls).toEqual([
['systemctl', '--user', 'status', 'mosaic-agent@json-canary.service'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('starts the holder before agents and stops agents before the holder', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
await program.parseAsync(['node', 'mosaic', 'fleet', 'stop']);
expect(calls).toEqual([
['systemctl', '--user', 'start', 'mosaic-tmux-holder.service'],
['systemctl', '--user', 'start', 'mosaic-agent@coder0.service'],
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: coder0',
' runtime: codex',
' - name: reviewer0',
' runtime: pi',
].join('\n'),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
if (args.includes('mosaic-agent@coder0.service')) {
return { stdout: '', stderr: 'coder0 failed\n', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await expect(program.parseAsync(['node', 'mosaic', 'fleet', 'stop'])).rejects.toThrow(
'Fleet stop completed with 1 failure(s)',
);
expect(calls).toEqual([
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
['systemctl', '--user', 'stop', 'mosaic-agent@reviewer0.service'],
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('rejects install-systemd with a non-default Mosaic home because units use %h/.config/mosaic', async () => {
const home = await tempDir();
const program = new Command();
program.exitOverride();
registerFleetCommand(program, {
mosaicHome: home,
frameworkRoot: resolve(process.cwd(), 'framework'),
});
try {
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'install-systemd']),
).rejects.toThrow('install-systemd only supports the default Mosaic home');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it.each(['start', 'stop', 'restart', 'status'] as const)(
'rejects single-agent %s for agents outside the roster',
async (action) => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
try {
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', action, 'typo']),
).rejects.toThrow('Agent "typo" is not in the fleet roster');
expect(runner).not.toHaveBeenCalled();
} finally {
await rm(home, { recursive: true, force: true });
}
},
);
it('loads default fleet/roster.json for agent commands when roster.yaml is absent', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.json'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'json-agent', runtime: 'pi' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']);
expect(calls).toEqual([
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=json-agent:0.0'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('passes a deterministic operator source label for agent sends', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'json-agent', runtime: 'pi' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'json-agent',
'--message',
'status check',
]);
expect(calls).toEqual([
[
join(home, 'tools', 'tmux', 'agent-send.sh'),
'-L',
'mosaic-factory',
'-S',
getDefaultOperatorSourceLabel(),
'-s',
'json-agent',
'-m',
'status check',
],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('allows agent sends to override the source label explicitly', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'coder0', runtime: 'codex' }],
}),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await program.parseAsync([
'node',
'mosaic',
'agent',
'send',
'coder0',
'--message',
'handoff',
'--source-label',
'lead:manual',
]);
expect(calls).toEqual([
[
join(home, 'tools', 'tmux', 'agent-send.sh'),
'-L',
'mosaic-factory',
'-S',
'lead:manual',
'-s',
'coder0',
'-m',
'handoff',
],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('rejects agent status typos before invoking the runner', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
const program = new Command();
program.exitOverride();
registerAgentCommand(program, { runner, mosaicHome: home });
try {
await expect(
program.parseAsync(['node', 'mosaic', 'agent', 'status', 'typo']),
).rejects.toThrow('Agent "typo" is not in the fleet roster');
expect(runner).not.toHaveBeenCalled();
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('keeps fleet framework assets in the published package file list', async () => {
const packageJson = JSON.parse(
await readFile(resolve(process.cwd(), 'package.json'), 'utf8'),
) as {
files?: string[];
};
expect(packageJson.files).toEqual(expect.arrayContaining(['dist', 'framework']));
});
});

View File

@@ -0,0 +1,889 @@
import { constants } from 'node:fs';
import { access, chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir, hostname } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import type { Command } from 'commander';
import YAML from 'yaml';
export interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
export type CommandRunner = (command: string, args: string[]) => Promise<CommandResult>;
export interface FleetCommandDeps {
runner?: CommandRunner;
mosaicHome?: string;
frameworkRoot?: string;
}
interface RawFleetRoster {
version?: unknown;
transport?: unknown;
tmux?: {
socket_name?: unknown;
socketName?: unknown;
holder_session?: unknown;
holderSession?: unknown;
};
defaults?: {
working_directory?: unknown;
workingDirectory?: unknown;
};
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
agents?: Array<{
name?: unknown;
runtime?: unknown;
class?: unknown;
working_directory?: unknown;
workingDirectory?: unknown;
model_hint?: unknown;
modelHint?: unknown;
persistent_persona?: unknown;
persistentPersona?: unknown;
reset_between_tasks?: unknown;
resetBetweenTasks?: unknown;
kickstart_template?: unknown;
kickstartTemplate?: unknown;
}>;
}
export interface FleetAgent {
name: string;
runtime: string;
className: string;
workingDirectory?: string;
modelHint?: string;
persistentPersona?: boolean | string;
resetBetweenTasks?: boolean;
kickstartTemplate?: string;
}
export interface FleetRoster {
version: 1;
transport: 'tmux';
tmux: {
socketName: string;
holderSession: string;
};
defaults: {
workingDirectory: string;
};
runtimes: Record<string, { resetCommand: string }>;
agents: FleetAgent[];
}
export interface FleetPaths {
mosaicHome: string;
rosterPath: string;
toolsDir: string;
fleetToolsDir: string;
tmuxToolsDir: string;
systemdUserDir: string;
agentEnvDir: string;
}
type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
const DEFAULT_SOCKET_NAME = 'mosaic-factory';
const DEFAULT_HOLDER_SESSION = '_holder';
const DEFAULT_WORKING_DIRECTORY = '~/src';
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
claude: { resetCommand: '/clear' },
codex: { resetCommand: '/clear' },
opencode: { resetCommand: '/clear' },
pi: { resetCommand: '/new' },
};
export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
return {
mosaicHome,
rosterPath: join(mosaicHome, 'fleet', 'roster.yaml'),
toolsDir: join(mosaicHome, 'tools'),
fleetToolsDir: join(mosaicHome, 'tools', 'fleet'),
tmuxToolsDir: join(mosaicHome, 'tools', 'tmux'),
systemdUserDir: join(homedir(), '.config', 'systemd', 'user'),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
};
}
function defaultMosaicHome(): string {
return join(homedir(), '.config', 'mosaic');
}
function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
if (resolve(mosaicHome) !== resolve(defaultMosaicHome())) {
throw new Error(
`install-systemd only supports the default Mosaic home (${defaultMosaicHome()}) because the user systemd units use %h/.config/mosaic paths.`,
);
}
}
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
const rawText = await readFile(path, 'utf8');
const parsed = parseRosterText(rawText, path);
return normalizeRoster(parsed);
}
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
const agent = roster.agents.find((candidate) => candidate.name === name);
if (!agent) {
throw new Error(`Agent "${name}" is not in the fleet roster.`);
}
return agent;
}
export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string {
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
return [
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
'',
].join('\n');
}
export function mergeAgentEnv(generatedEnv: string, existingEnv?: string): string {
if (!existingEnv?.trim()) {
return generatedEnv;
}
const generatedKeys = new Set(
generatedEnv
.split('\n')
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1])
.filter((key): key is string => key !== undefined),
);
const preservedLines = existingEnv.split('\n').filter((line) => {
if (!line.trim()) {
return false;
}
const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1];
return key === undefined || !generatedKeys.has(key);
});
if (preservedLines.length === 0) {
return generatedEnv;
}
return [generatedEnv.trimEnd(), ...preservedLines, ''].join('\n');
}
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
const service = agentName ? `mosaic-agent@${agentName}.service` : 'mosaic-tmux-holder.service';
return ['systemctl', '--user', action, service];
}
export function buildAgentSendCommand(
paths: FleetPaths,
agentName: string,
message: string,
socketName = DEFAULT_SOCKET_NAME,
sourceLabel = getDefaultOperatorSourceLabel(),
): string[] {
return [
join(paths.tmuxToolsDir, 'agent-send.sh'),
'-L',
socketName,
'-S',
sourceLabel,
'-s',
agentName,
'-m',
message,
];
}
export function getDefaultOperatorSourceLabel(): string {
const shortHostname = hostname().split('.')[0] || 'localhost';
return `${shortHostname}:operator`;
}
export function buildAgentResetCommand(
paths: FleetPaths,
agentName: string,
resetCommand: string,
socketName = DEFAULT_SOCKET_NAME,
): string[] {
return [
join(paths.tmuxToolsDir, 'send-message.sh'),
'-L',
socketName,
'-t',
`=${agentName}`,
'-m',
resetCommand,
];
}
export function buildAgentTailCommand(
agentName: string,
lines: number,
socketName = DEFAULT_SOCKET_NAME,
): string[] {
return [
'tmux',
'-L',
socketName,
'capture-pane',
'-t',
`=${agentName}:0.0`,
'-p',
'-S',
`-${lines}`,
];
}
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
const runner = deps.runner ?? runCommand;
const paths = resolveFleetPaths(deps.mosaicHome);
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
const cmd = program
.command('fleet')
.description('Manage the local Mosaic tmux fleet canary')
.option('--mosaic-home <path>', 'Mosaic home directory', paths.mosaicHome)
.option('--roster <path>', 'Fleet roster path');
cmd
.command('init')
.description('Initialize a local fleet roster')
.option('--profile <name>', 'Roster profile: minimal or local-canary', 'minimal')
.option('--write', 'Write the roster to Mosaic home')
.option('--force', 'Overwrite an existing roster when used with --write')
.action(async (opts: { profile: string; write?: boolean; force?: boolean }) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const profile = parseInitProfile(opts.profile);
const source = join(frameworkRoot, 'fleet', 'examples', `${profile}.yaml`);
const content = await readFile(source, 'utf8');
if (!opts.write) {
console.log(content.trimEnd());
return;
}
const destination = commandOpts.roster ?? activePaths.rosterPath;
if (!opts.force && (await canRead(destination))) {
throw new Error(
`Fleet roster already exists: ${destination}. Re-run with --force to overwrite.`,
);
}
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, content);
console.log(`Wrote fleet roster: ${destination}`);
});
cmd
.command('install')
.description('Install local fleet tools and user systemd units')
.action(async () => installFleet(cmd, frameworkRoot));
cmd
.command('install-systemd')
.description('Install local fleet tools and user systemd units')
.action(async () => installFleet(cmd, frameworkRoot));
for (const action of ['start', 'stop', 'restart'] as const) {
cmd
.command(`${action} [agent]`)
.description(`${action} the fleet holder or one agent`)
.action(async (agent?: string) => {
const roster = await loadRosterForCommand(cmd);
if (agent) {
getRosterAgent(roster, agent);
await runChecked(runner, buildFleetServiceCommand(action, agent));
return;
}
if (action === 'stop') {
await stopFleetBestEffort(
runner,
roster.agents.map((rosterAgent) => rosterAgent.name),
);
return;
}
await runChecked(runner, buildFleetServiceCommand(action));
for (const rosterAgent of roster.agents) {
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
}
});
}
cmd
.command('status [agent]')
.description('Show fleet holder or agent systemd status')
.option('--json', 'Print JSON status')
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
if (agent) {
const roster = await loadRosterForCommand(cmd);
getRosterAgent(roster, agent);
}
const result = await runner(...splitCommand(buildFleetServiceCommand('status', agent)));
if (opts.json) {
console.log(
JSON.stringify({
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
}),
);
setExitCodeFromResult(result);
return;
}
writeCommandOutput(result);
});
cmd
.command('verify')
.description('Verify the local canary holder and roster sessions on the isolated socket')
.action(async () => {
const roster = await loadRosterForCommand(cmd);
const socketName = roster.tmux.socketName;
await runChecked(runner, [
'tmux',
'-L',
socketName,
'has-session',
'-t',
`=${roster.tmux.holderSession}:0.0`,
]);
for (const agent of roster.agents) {
await runChecked(runner, [
'tmux',
'-L',
socketName,
'has-session',
'-t',
`=${agent.name}:0.0`,
]);
}
console.log(`Verified fleet on tmux socket ${socketName}.`);
});
return cmd;
}
export function registerFleetAgentCommands(
agentCommand: Command,
deps: FleetCommandDeps = {},
): void {
const runner = deps.runner ?? runCommand;
agentCommand
.command('roster')
.description('List agents from the local fleet roster')
.option('--json', 'Print JSON')
.action(async (opts: { json?: boolean }) => {
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
if (opts.json) {
console.log(JSON.stringify(roster, null, 2));
return;
}
for (const agent of roster.agents) {
console.log(`${agent.name}\t${agent.runtime}\t${agent.className}`);
}
});
agentCommand
.command('status [agent]')
.description('Show tmux status for the local fleet or one agent')
.option('--json', 'Print JSON')
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
if (agent) {
getRosterAgent(roster, agent);
}
const command = agent
? ['tmux', '-L', roster.tmux.socketName, 'has-session', '-t', `=${agent}:0.0`]
: ['tmux', '-L', roster.tmux.socketName, 'ls'];
const result = await runner(...splitCommand(command));
if (opts.json) {
console.log(
JSON.stringify({
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
}),
);
setExitCodeFromResult(result);
return;
}
writeCommandOutput(result);
});
agentCommand
.command('send <agent>')
.description('Send a message to a local fleet agent')
.requiredOption('--message <text>', 'Message text')
.option('--source-label <label>', 'Source label for the message preamble')
.option('--source <label>', 'Alias for --source-label')
.action(
async (agent: string, opts: { message: string; sourceLabel?: string; source?: string }) => {
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
getRosterAgent(roster, agent);
const paths = resolveFleetPaths(
resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome),
);
const sourceLabel = opts.sourceLabel ?? opts.source ?? getDefaultOperatorSourceLabel();
await runChecked(
runner,
buildAgentSendCommand(paths, agent, opts.message, roster.tmux.socketName, sourceLabel),
);
},
);
agentCommand
.command('reset <agent>')
.description('Reset a local fleet agent by sending the runtime reset command')
.option('--clear', 'Send /clear')
.option('--new', 'Send /new')
.action(async (agent: string, opts: { clear?: boolean; new?: boolean }) => {
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
const rosterAgent = getRosterAgent(roster, agent);
const paths = resolveFleetPaths(resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome));
const resetCommand = opts.clear
? '/clear'
: opts.new
? '/new'
: (roster.runtimes[rosterAgent.runtime]?.resetCommand ?? '/clear');
await runChecked(
runner,
buildAgentResetCommand(paths, agent, resetCommand, roster.tmux.socketName),
);
});
agentCommand
.command('tail <agent>')
.description('Print recent pane output for a local fleet agent')
.option('-n, --lines <number>', 'Number of pane history lines', '80')
.action(async (agent: string, opts: { lines: string }) => {
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
getRosterAgent(roster, agent);
const lines = Number.parseInt(opts.lines, 10);
const result = await runner(
...splitCommand(
buildAgentTailCommand(agent, Number.isFinite(lines) ? lines : 80, roster.tmux.socketName),
),
);
writeCommandOutput(result);
});
}
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
const roster = await loadRosterForCommand(cmd);
await mkdir(activePaths.fleetToolsDir, { recursive: true });
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
await mkdir(activePaths.systemdUserDir, { recursive: true });
await mkdir(activePaths.agentEnvDir, { recursive: true });
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
const sendMessagePath = join(activePaths.tmuxToolsDir, 'send-message.sh');
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
const executableToolPaths = [startAgentSessionPath, sendMessagePath, agentSendPath];
await copyFile(
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
startAgentSessionPath,
);
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'), sendMessagePath);
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'), agentSendPath);
for (const toolPath of executableToolPaths) {
await chmod(toolPath, 0o755);
}
await copyFile(
join(frameworkRoot, 'systemd', 'user', 'mosaic-tmux-holder.service'),
join(activePaths.systemdUserDir, 'mosaic-tmux-holder.service'),
);
await copyFile(
join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'),
join(activePaths.systemdUserDir, 'mosaic-agent@.service'),
);
for (const agent of roster.agents) {
const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`);
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined;
await writeFile(envPath, mergeAgentEnv(generateAgentEnv(roster, agent), existingEnv));
}
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
}
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
}
async function loadRosterFromAgentCommand(
command: Command,
mosaicHomeOverride?: string,
): Promise<FleetRoster> {
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster));
}
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
const opts = command.optsWithGlobals<{ mosaicHome?: string }>();
return opts.mosaicHome ?? override ?? defaultMosaicHome();
}
function parseRosterText(text: string, path: string): RawFleetRoster {
const trimmed = text.trim();
if (path.endsWith('.json')) {
return JSON.parse(trimmed) as RawFleetRoster;
}
return YAML.parse(trimmed) as RawFleetRoster;
}
function normalizeRoster(raw: RawFleetRoster): FleetRoster {
assertObject(raw, 'Fleet roster');
assertKnownKeys(raw, 'Fleet roster', [
'version',
'transport',
'tmux',
'defaults',
'runtimes',
'agents',
]);
if (raw.tmux !== undefined) {
assertObject(raw.tmux, 'Fleet roster tmux');
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
'socket_name',
'socketName',
'holder_session',
'holderSession',
]);
}
if (raw.defaults !== undefined) {
assertObject(raw.defaults, 'Fleet roster defaults');
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
'working_directory',
'workingDirectory',
]);
}
if (raw.runtimes !== undefined) {
assertObject(raw.runtimes, 'Fleet roster runtimes');
for (const [runtime, config] of Object.entries(raw.runtimes)) {
assertObject(config, `Fleet roster runtime "${runtime}"`);
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
'reset_command',
'resetCommand',
]);
}
}
if (raw.version !== 1) {
throw new Error('Fleet roster version must be 1.');
}
if (raw.transport !== 'tmux') {
throw new Error('Fleet roster transport must be "tmux".');
}
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
throw new Error('Fleet roster must define at least one agent.');
}
const agents = raw.agents.map(normalizeAgent);
assertUniqueAgentNames(agents);
return {
version: 1,
transport: 'tmux',
tmux: {
socketName: stringValue(
raw.tmux?.socket_name ?? raw.tmux?.socketName,
DEFAULT_SOCKET_NAME,
'Fleet roster tmux socket_name',
),
holderSession: stringValue(
raw.tmux?.holder_session ?? raw.tmux?.holderSession,
DEFAULT_HOLDER_SESSION,
'Fleet roster tmux holder_session',
),
},
defaults: {
workingDirectory: stringValue(
raw.defaults?.working_directory ?? raw.defaults?.workingDirectory,
DEFAULT_WORKING_DIRECTORY,
'Fleet roster defaults working_directory',
),
},
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
agents,
};
}
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
assertObject(raw, 'Fleet roster agent');
assertKnownKeys(raw, 'Fleet roster agent', [
'name',
'runtime',
'class',
'working_directory',
'workingDirectory',
'model_hint',
'modelHint',
'persistent_persona',
'persistentPersona',
'reset_between_tasks',
'resetBetweenTasks',
'kickstart_template',
'kickstartTemplate',
]);
const name = stringValue(raw.name, '', 'Fleet roster agent name');
const runtime = stringValue(
raw.runtime,
'',
`Fleet roster agent "${name || '<unknown>'}" runtime`,
);
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
}
if (!runtime) {
throw new Error(`Fleet agent "${name}" must define a runtime.`);
}
return {
name,
runtime,
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
workingDirectory: optionalString(
raw.working_directory ?? raw.workingDirectory,
`Fleet roster agent "${name}" working_directory`,
),
modelHint: optionalString(
raw.model_hint ?? raw.modelHint,
`Fleet roster agent "${name}" model_hint`,
),
persistentPersona: optionalBooleanOrString(
raw.persistent_persona ?? raw.persistentPersona,
`Fleet roster agent "${name}" persistent_persona`,
),
resetBetweenTasks: optionalBoolean(
raw.reset_between_tasks ?? raw.resetBetweenTasks,
`Fleet roster agent "${name}" reset_between_tasks`,
),
kickstartTemplate: optionalString(
raw.kickstart_template ?? raw.kickstartTemplate,
`Fleet roster agent "${name}" kickstart_template`,
),
};
}
function normalizeRuntimes(
raw: RawFleetRoster['runtimes'] | undefined,
): Record<string, { resetCommand: string }> {
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
for (const [runtime, config] of Object.entries(raw ?? {})) {
result[runtime] = {
resetCommand: stringValue(
config.reset_command ?? config.resetCommand,
'/clear',
`Fleet roster runtime "${runtime}" reset_command`,
),
};
}
return result;
}
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must be an object.`);
}
}
function assertKnownKeys(
value: Record<string, unknown>,
label: string,
allowedKeys: readonly string[],
): void {
const allowed = new Set(allowedKeys);
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
if (unknownKeys.length > 0) {
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
}
}
function assertUniqueAgentNames(agents: FleetAgent[]): void {
const seen = new Set<string>();
for (const agent of agents) {
if (seen.has(agent.name)) {
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
}
seen.add(agent.name);
}
}
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
if (value === undefined) {
return fallback;
}
if (typeof value !== 'string') {
throw new Error(`${label} must be a string.`);
}
return value;
}
function optionalString(value: unknown, label = 'Value'): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string') {
throw new Error(`${label} must be a string.`);
}
return value;
}
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'boolean') {
throw new Error(`${label} must be a boolean.`);
}
return value;
}
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'boolean' && typeof value !== 'string') {
throw new Error(`${label} must be a boolean or string.`);
}
return value;
}
function expandHome(path: string): string {
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
}
function shellEnvValue(value: string): string {
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
return value;
}
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
const failures: string[] = [];
for (const agentName of agentNames) {
const command = buildFleetServiceCommand('stop', agentName);
const result = await runner(...splitCommand(command));
writeSuccessfulCommandOutput(result);
if (result.exitCode !== 0) {
failures.push(result.stderr || result.stdout || `Command failed: ${command.join(' ')}`);
}
}
const holderCommand = buildFleetServiceCommand('stop');
const holderResult = await runner(...splitCommand(holderCommand));
writeSuccessfulCommandOutput(holderResult);
if (holderResult.exitCode !== 0) {
failures.push(
holderResult.stderr || holderResult.stdout || `Command failed: ${holderCommand.join(' ')}`,
);
}
if (failures.length > 0) {
throw new Error(
`Fleet stop completed with ${failures.length} failure(s): ${failures.join('; ')}`,
);
}
}
async function runChecked(runner: CommandRunner, command: string[]): Promise<void> {
const result = await runner(...splitCommand(command));
if (result.exitCode !== 0) {
throw new Error(result.stderr || result.stdout || `Command failed: ${command.join(' ')}`);
}
if (result.stdout) {
process.stdout.write(result.stdout);
}
}
function splitCommand(command: string[]): [string, string[]] {
const [bin, ...args] = command;
if (!bin) {
throw new Error('Cannot run an empty command.');
}
return [bin, args];
}
function parseInitProfile(profile: string): 'minimal' | 'local-canary' {
if (profile === 'minimal' || profile === 'local-canary') {
return profile;
}
throw new Error(`Unsupported fleet profile "${profile}". Use: minimal, local-canary.`);
}
function writeCommandOutput(result: CommandResult): void {
if (result.stdout) {
process.stdout.write(result.stdout);
} else if (result.stderr) {
process.stderr.write(result.stderr);
}
setExitCodeFromResult(result);
}
function writeSuccessfulCommandOutput(result: CommandResult): void {
if (result.exitCode !== 0) {
return;
}
if (result.stdout) {
process.stdout.write(result.stdout);
}
}
function setExitCodeFromResult(result: CommandResult): void {
if (result.exitCode !== 0) {
process.exitCode = result.exitCode;
}
}
function runCommand(command: string, args: string[]): Promise<CommandResult> {
return new Promise((resolvePromise) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (error) => {
resolvePromise({ stdout, stderr: error.message, exitCode: 127 });
});
child.on('close', (code) => {
resolvePromise({ stdout, stderr, exitCode: code ?? 1 });
});
});
}
function resolveFrameworkRoot(): string {
const currentFile = fileURLToPath(import.meta.url);
return resolve(dirname(currentFile), '..', '..', 'framework');
}
async function canRead(path: string): Promise<boolean> {
try {
await access(path, constants.R_OK);
return true;
} catch {
return false;
}
}
export async function resolveRosterPath(
mosaicHome: string,
explicitPath?: string,
): Promise<string> {
if (explicitPath) {
return explicitPath;
}
const yamlPath = resolveFleetPaths(mosaicHome).rosterPath;
if (await canRead(yamlPath)) {
return yamlPath;
}
const jsonPath = join(mosaicHome, 'fleet', 'roster.json');
return jsonPath;
}

View File

@@ -1,6 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { Command } from 'commander'; import { Command } from 'commander';
import { buildPiSkillArgs, registerRuntimeLaunchers, type RuntimeLaunchHandler } from './launch.js'; import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
buildPiSkillArgs,
enumerateSkillDirs,
piForceSkillNames,
registerRuntimeLaunchers,
type RuntimeLaunchHandler,
} from './launch.js';
/** /**
* Tests for the commander wiring between `mosaic <runtime>` / `mosaic yolo <runtime>` * Tests for the commander wiring between `mosaic <runtime>` / `mosaic yolo <runtime>`
@@ -23,6 +32,7 @@ function buildProgram(handler: RuntimeLaunchHandler): Command {
} }
const fakeSkills = ['--skill', '/skills/test-driven-development', '--skill', '/skills/pdf']; const fakeSkills = ['--skill', '/skills/test-driven-development', '--skill', '/skills/pdf'];
const fakeForced = ['--skill', '/skills/mosaic-tools'];
// `process.exit` returns `never`, so vi.spyOn demands a replacement with the // `process.exit` returns `never`, so vi.spyOn demands a replacement with the
// same signature. We throw from the mock to short-circuit into test-land. // same signature. We throw from the mock to short-circuit into test-land.
@@ -66,16 +76,42 @@ describe('registerRuntimeLaunchers — non-yolo subcommands', () => {
}); });
describe('buildPiSkillArgs', () => { describe('buildPiSkillArgs', () => {
it('defaults to disabling Pi skill discovery to keep startup context small', () => { it('disables auto-discovery but force-loads fleet-critical skills by default', () => {
expect(buildPiSkillArgs([], {}, fakeSkills)).toEqual(['--no-skills']); expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([
'--no-skills',
'--skill',
'/skills/mosaic-tools',
]);
}); });
it('keeps explicit user skills while disabling automatic discovery', () => { it('ignores _runtimeArgs (user --skill flags reach Pi via the launch handler, not here)', () => {
expect(buildPiSkillArgs(['--skill', '/tmp/custom'], {}, fakeSkills)).toEqual(['--no-skills']); expect(buildPiSkillArgs(['--skill', '/tmp/custom'], {}, fakeSkills, fakeForced)).toEqual([
'--no-skills',
'--skill',
'/skills/mosaic-tools',
]);
}); });
it('supports legacy all-skills mode without double-loading settings skills', () => { it('emits only --no-skills when no forced skills are present on disk', () => {
expect(buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'all' }, fakeSkills)).toEqual([ expect(buildPiSkillArgs([], {}, fakeSkills, [])).toEqual(['--no-skills']);
});
it('all-skills mode merges the forced set in without duplicating discovered skills', () => {
expect(buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'all' }, fakeSkills, fakeForced)).toEqual([
'--no-skills',
'--skill',
'/skills/test-driven-development',
'--skill',
'/skills/pdf',
'--skill',
'/skills/mosaic-tools',
]);
});
it('all-skills mode does not double-load a forced skill already discovered', () => {
expect(
buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'all' }, fakeSkills, ['--skill', '/skills/pdf']),
).toEqual([
'--no-skills', '--no-skills',
'--skill', '--skill',
'/skills/test-driven-development', '/skills/test-driven-development',
@@ -84,8 +120,117 @@ describe('buildPiSkillArgs', () => {
]); ]);
}); });
it('supports native Pi discovery when explicitly requested', () => { it('force-loads fleet skills under native Pi discovery when not already discoverable', () => {
expect(buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'discover' }, fakeSkills)).toEqual([]); // Empty native set => Pi would not find mosaic-tools on its own, so force it.
expect(
buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'discover' }, fakeSkills, fakeForced, new Set()),
).toEqual(['--skill', '/skills/mosaic-tools']);
});
it('discover mode drops a forced skill Pi already discovers natively (no double-load)', () => {
// mosaic-tools is reachable from a Pi native root, so native discovery
// covers it — forcing it again would register the same skill twice.
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
fakeForced,
new Set(['/skills/mosaic-tools']),
),
).toEqual([]);
});
it('discover mode keeps a forced skill that no native root provides', () => {
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
fakeForced,
new Set(['/skills/some-other-skill']),
),
).toEqual(['--skill', '/skills/mosaic-tools']);
});
it('discover mode collapses a forced skill listed twice to a single --skill', () => {
// Mirror 'all' mode: intra-forced-set duplicates (same realpath) dedup.
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
['--skill', '/skills/mosaic-tools', '--skill', '/skills/mosaic-tools'],
new Set(),
),
).toEqual(['--skill', '/skills/mosaic-tools']);
});
});
describe('enumerateSkillDirs (real FS)', () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-skills-'));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function makeSkill(parent: string, name: string): string {
const dir = join(parent, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'SKILL.md'), `# ${name}\n`);
return dir;
}
it('accepts a symlinked skill dir (regression: synced fleet skills are symlinks)', () => {
// Real skill lives under `canonical/`; the scanned root only has a symlink to it.
const canonical = makeSkill(join(root, 'canonical'), 'mosaic-tools');
const scanned = join(root, 'scanned');
mkdirSync(scanned, { recursive: true });
symlinkSync(canonical, join(scanned, 'mosaic-tools'), 'dir');
expect(enumerateSkillDirs([scanned])).toEqual(['--skill', join(scanned, 'mosaic-tools')]);
});
it('dedups by real path when the same skill is reachable from two roots', () => {
// Root A holds the real dir; root B symlinks to it — one --skill, not two.
const rootA = join(root, 'a');
const rootB = join(root, 'b');
const real = makeSkill(rootA, 'mosaic-tools');
mkdirSync(rootB, { recursive: true });
symlinkSync(real, join(rootB, 'mosaic-tools'), 'dir');
expect(enumerateSkillDirs([rootA, rootB])).toEqual(['--skill', real]);
});
it('skips directories without a SKILL.md and missing roots', () => {
mkdirSync(join(root, 'present', 'not-a-skill'), { recursive: true });
makeSkill(join(root, 'present'), 'real-skill');
expect(enumerateSkillDirs([join(root, 'present'), join(root, 'does-not-exist')])).toEqual([
'--skill',
join(root, 'present', 'real-skill'),
]);
});
});
describe('piForceSkillNames', () => {
it('defaults to mosaic-tools when MOSAIC_PI_FORCE_SKILLS is unset', () => {
expect(piForceSkillNames({})).toEqual(['mosaic-tools']);
});
it('treats an empty string as "disable force-loading" (distinct from unset)', () => {
expect(piForceSkillNames({ MOSAIC_PI_FORCE_SKILLS: '' })).toEqual([]);
});
it('parses a colon list, trimming blanks and whitespace', () => {
expect(piForceSkillNames({ MOSAIC_PI_FORCE_SKILLS: 'mosaic-tools: mosaic-gitea ::' })).toEqual([
'mosaic-tools',
'mosaic-gitea',
]);
}); });
}); });

View File

@@ -6,7 +6,15 @@
*/ */
import { execFileSync, execSync, spawnSync } from 'node:child_process'; import { execFileSync, execSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs'; import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
readdirSync,
realpathSync,
rmSync,
} from 'node:fs';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
@@ -428,25 +436,74 @@ function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
// ─── Pi skill/extension discovery ──────────────────────────────────────────── // ─── Pi skill/extension discovery ────────────────────────────────────────────
function discoverPiSkills(): string[] { /** Resolve a skill dir to its canonical real path so symlinked duplicates
* (e.g. ~/.pi/agent/skills/X -> ~/.config/mosaic/skills/X) collapse to one key.
* Falls back to the literal path if it can't be resolved (e.g. broken link). */
function skillRealPath(dir: string): string {
try {
return realpathSync(dir);
} catch {
return dir;
}
}
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
* skills dir and the project-local one relative to the launch cwd. */
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
}
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
* counts as a skill when it (or its symlink target) contains a SKILL.md.
* Exported for tests (real-FS coverage of symlink acceptance + realpath dedup). */
export function enumerateSkillDirs(roots: string[]): string[] {
const seen = new Set<string>();
const args: string[] = []; const args: string[] = [];
for (const skillsRoot of [join(MOSAIC_HOME, 'skills'), join(MOSAIC_HOME, 'skills-local')]) { for (const skillsRoot of roots) {
if (!existsSync(skillsRoot)) continue; if (!existsSync(skillsRoot)) continue;
try { try {
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) { for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue; // Synced fleet skills land as symlinks, so accept both dirs and links.
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const skillDir = join(skillsRoot, entry.name); const skillDir = join(skillsRoot, entry.name);
if (existsSync(join(skillDir, 'SKILL.md'))) { if (!existsSync(join(skillDir, 'SKILL.md'))) continue;
args.push('--skill', skillDir); const key = skillRealPath(skillDir);
} if (seen.has(key)) continue;
seen.add(key);
args.push('--skill', skillDir);
} }
} catch { } catch {
// skip // skip unreadable roots
} }
} }
return args; return args;
} }
/** Every skill dir Pi would link under `MOSAIC_PI_SKILL_MODE=all`: the Mosaic
* global/local catalog plus Pi's own native roots. `--no-skills` suppresses
* native auto-discovery, so 'all' must re-add the native roots explicitly or
* they would be silently dropped. Deduped by real path. */
function discoverPiSkills(cwd: string = process.cwd()): string[] {
return enumerateSkillDirs([
join(MOSAIC_HOME, 'skills'),
join(MOSAIC_HOME, 'skills-local'),
...piNativeSkillRoots(cwd),
]);
}
/** Real paths of skills Pi will auto-discover from its native roots. Used to
* drop redundant force-loads in 'discover' mode (which keeps native discovery
* on) so the same skill is not registered twice. */
function piNativeSkillRealPaths(cwd: string = process.cwd()): Set<string> {
const args = enumerateSkillDirs(piNativeSkillRoots(cwd));
const set = new Set<string>();
for (let i = 1; i < args.length; i += 2) {
const dir = args[i];
if (dir !== undefined) set.add(skillRealPath(dir));
}
return set;
}
type PiSkillMode = 'none' | 'all' | 'discover'; type PiSkillMode = 'none' | 'all' | 'discover';
function normalizePiSkillMode(env: NodeJS.ProcessEnv): PiSkillMode { function normalizePiSkillMode(env: NodeJS.ProcessEnv): PiSkillMode {
@@ -455,22 +512,96 @@ function normalizePiSkillMode(env: NodeJS.ProcessEnv): PiSkillMode {
return 'none'; return 'none';
} }
/**
* Fleet-critical Pi skills that are force-loaded on every Pi launch regardless
* of MOSAIC_PI_SKILL_MODE. They cover the highest-frequency cross-agent and
* git-provider operations where Pi workers historically improvised raw CLIs
* (raw `tmux send-keys`, raw `tea`/`gh`/`glab`) instead of the maintained
* `~/.config/mosaic/tools/` wrappers.
*
* An explicit `--skill <dir>` overrides `--no-skills` for that path, so forcing
* a single targeted skill surfaces the must-use toolkit without loading the full
* ~100-skill catalog (context bloat). Missing skills are skipped silently, so
* this is a no-op until the named skill is synced into ~/.config/mosaic/skills/.
*
* Override with MOSAIC_PI_FORCE_SKILLS (colon-separated skill dir names; set to
* an empty string to disable force-loading entirely).
*/
const DEFAULT_PI_FORCE_SKILLS = ['mosaic-tools'];
export function piForceSkillNames(env: NodeJS.ProcessEnv): string[] {
const override = env['MOSAIC_PI_FORCE_SKILLS'];
if (override === undefined) return DEFAULT_PI_FORCE_SKILLS;
return override
.split(':')
.map((name) => name.trim())
.filter(Boolean);
}
function forcedPiSkillArgs(env: NodeJS.ProcessEnv = process.env): string[] {
const args: string[] = [];
for (const name of piForceSkillNames(env)) {
const skillDir = join(MOSAIC_HOME, 'skills', name);
if (existsSync(join(skillDir, 'SKILL.md'))) {
args.push('--skill', skillDir);
}
}
return args;
}
/** Concatenate `--skill <dir>` arg groups, dropping any skill already seen.
* Dedup is by real path, so a forced skill and the same skill reached via a
* different (e.g. symlinked) directory collapse to a single `--skill`. */
function mergeSkillArgs(...groups: string[][]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const group of groups) {
for (let i = 0; i < group.length; i += 2) {
const dir = group[i + 1];
if (group[i] !== '--skill' || dir === undefined) continue;
const key = skillRealPath(dir);
if (seen.has(key)) continue;
seen.add(key);
out.push('--skill', dir);
}
}
return out;
}
export function buildPiSkillArgs( export function buildPiSkillArgs(
_runtimeArgs: string[], _runtimeArgs: string[],
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
discoveredSkillArgs: string[] = discoverPiSkills(), discoveredSkillArgs: string[] = discoverPiSkills(),
forcedSkillArgs: string[] = forcedPiSkillArgs(env),
nativeSkillRealPaths: Set<string> = piNativeSkillRealPaths(),
): string[] { ): string[] {
const mode = normalizePiSkillMode(env); const mode = normalizePiSkillMode(env);
if (mode === 'discover') { if (mode === 'discover') {
return []; // Native Pi discovery stays on, so only force-load fleet skills it will NOT
// already find under its native roots — otherwise the same skill is
// registered twice (once natively, once via --skill). mergeSkillArgs first
// collapses any intra-forced-set realpath duplicates, mirroring 'all' mode.
const deduped = mergeSkillArgs(forcedSkillArgs);
const out: string[] = [];
for (let i = 0; i < deduped.length; i += 2) {
const dir = deduped[i + 1];
if (deduped[i] !== '--skill' || dir === undefined) continue;
if (nativeSkillRealPaths.has(skillRealPath(dir))) continue;
out.push('--skill', dir);
}
return out;
} }
if (mode === 'all') { if (mode === 'all') {
return ['--no-skills', ...discoveredSkillArgs]; // 'all' links the full catalog; merge in the forced set so fleet-critical
// skills are guaranteed present even if they live only under skills-local/.
// discoverPiSkills already covers Pi's native roots, which `--no-skills`
// would otherwise suppress.
return ['--no-skills', ...mergeSkillArgs(discoveredSkillArgs, forcedSkillArgs)];
} }
return ['--no-skills']; return ['--no-skills', ...forcedSkillArgs];
} }
function discoverPiExtension(): string[] { function discoverPiExtension(): string[] {

View File

@@ -0,0 +1,755 @@
# Durable tmux Fleet Installation Plan
> **For Mosaic/Hermes:** This is an implementation plan for making the tmux-backed Mosaic software-factory fleet durable on this server and reusable in generic Mosaic Stack installs. Keep local USC/Mosaic defaults in profiles; keep framework behavior customizable.
**Goal:** Add a supported Mosaic tmux-fleet installation path: holder-owned tmux server, per-agent reusable sessions, reliable send/reset/status tools, local roster customization, and a documented cutover for this server.
**Architecture:** Mosaic should ship generic tmux fleet primitives in the framework, then layer local rosters through configuration. The holder service owns the tmux socket; each agent service joins the holder-owned server and runs `mosaic yolo <runtime>`. The orchestrator addresses agents through `mosaic agent ...` abstractions so tmux can later be replaced by Matrix-backed agent comms without changing mission flow.
**Reference:** AI Guide `playbooks/tmux-fleet.md` at commit `2a0b0b5` documents the organization-neutral holder-service pattern, exact-match `=<name>` stop targets, and coupled-server cutover/verification sequence. The Stack implementation should treat that as the lifecycle model and keep concrete Mosaic unit/tooling details here.
**Tech Stack:** Bash, tmux, user systemd units, Mosaic CLI/framework installer, JSON/YAML roster config, existing `packages/mosaic/framework/tools/tmux/{agent-send.sh,send-message.sh}`.
---
## Current evidence from this server
Checked 2026-06-19:
- Host: `W-jarvis`
- User: `jarvis`
- tmux: `/usr/bin/tmux`, version `3.4`
- user systemd: active
- existing tmux sessions: `ai-bma-0`, `dyor-1`, `melaniewoltje-3`, `sage-2`
- existing Mosaic runtime: `/home/jarvis/.npm-global/bin/mosaic`, version `0.0.31`
- installed `~/.config/mosaic/tools/tmux` was not present even though the stack repo contains `packages/mosaic/framework/tools/tmux/`
Implication: do not kill the current tmux server casually. This server has active ad-hoc/service sessions. The durable fleet cutover must be planned, with either a separate socket first or a scheduled fleet recycle.
## Design decisions
### 1. Generic framework, local profile
The Mosaic framework should ship:
- systemd unit templates;
- tmux fleet CLI wrappers;
- roster schema and examples;
- install/enable/status/reset commands;
- docs and verification scripts.
Local environments should provide:
- agent names;
- runtime per slot (`claude`, `pi`, `codex`, etc.);
- default role class;
- launch directory;
- optional kickstart prompt;
- model/provider hints;
- transport selection (`tmux` now, `matrix` later).
Do not bake the USC roster into generic install code. Ship it as an example profile.
### 2. Durable sessions, disposable task context
Session names are durable operational addresses. Task persona is disposable. Reusable worker slots should be reset with `/clear` or `/new` and then receive a fresh task kickstart.
Persistent/semi-persistent personas:
- lead orchestrator;
- final/adversarial reviewer;
- architecture/enhancement lane.
Disposable slots:
- implementers;
- ordinary reviewers;
- security reviewers unless actively holding a security mission.
### 3. Transport abstraction now
Add commands around tmux instead of calling tmux directly from orchestration:
```bash
mosaic agent send <agent> --message "..."
mosaic agent status [--json]
mosaic agent reset <agent> [--clear|--new]
mosaic agent roster [--json]
mosaic fleet install|start|stop|restart|status|verify
```
Today these call tmux/systemd. Later the same command surface can target Matrix or per-agent gateways.
### 4. Avoid shared-server ownership bug
Use the AI Guide holder pattern:
```text
mosaic-tmux-holder.service owns the tmux server/socket
mosaic-agent@<name>.service joins the existing holder-owned socket
ExecStop kills only session =<name>
```
Use exact tmux targets: `=<session>`.
### 5. Prefer separate named socket for Mosaic factory
To avoid disturbing existing tmux work, the default fleet should use a named socket such as:
```text
$XDG_RUNTIME_DIR/mosaic-factory.tmux
```
or tmux socket name:
```bash
tmux -L mosaic-factory ...
```
This avoids collision with ordinary `tmux ls` sessions. The send tools need socket support.
---
## Target USC-style roster example
Ship as example only, not default:
```yaml
version: 1
transport: tmux
tmux:
socket_name: mosaic-factory
holder_session: _holder
working_directory: ~/src
agents:
- name: mos-claude
runtime: claude
class: orchestrator
model_hint: Claude Opus
persistent_persona: true
- name: coder0
runtime: claude
class: implementer
model_hint: Claude Opus
reset_between_tasks: true
- name: coder1
runtime: claude
class: implementer
model_hint: Claude Opus
reset_between_tasks: true
- name: coder2
runtime: pi
class: implementer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: coder3
runtime: pi
class: implementer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: coder4
runtime: claude
class: implementer
model_hint: Claude Opus
reset_between_tasks: true
- name: coder5
runtime: claude
class: implementer
model_hint: Claude Opus
reset_between_tasks: true
- name: enhance
runtime: claude
class: enhancer
model_hint: Claude Opus
persistent_persona: semi
- name: rev0
runtime: pi
class: reviewer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: rev1
runtime: pi
class: reviewer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: secrev0
runtime: pi
class: security_reviewer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: secrev1
runtime: pi
class: security_reviewer
model_hint: Pi GPT-5.5
reset_between_tasks: true
- name: ultron
runtime: pi
class: final_reviewer
model_hint: Pi GPT-5.5
persistent_persona: semi
```
---
## Phase 0 — Confirm install surfaces
### Task 0.1: Inspect installer copy behavior
**Objective:** Confirm how framework files under `packages/mosaic/framework/` become installed under `~/.config/mosaic/`.
**Files:**
- Read: `tools/install.sh`
- Read: `packages/mosaic/framework/install.sh`
- Read: `packages/mosaic/src/runtime/install-manifest.ts`
**Steps:**
1. Verify `packages/mosaic/framework/install.sh` rsyncs `tools/tmux`.
2. Verify whether npm-packaged installs include `framework/tools/tmux`.
3. Confirm whether installed hosts should run `mosaic update`, `bash tools/install.sh`, or `packages/mosaic/framework/install.sh` to receive new tmux tools.
4. Record exact propagation command in docs.
**Verification:**
```bash
bash packages/mosaic/framework/install.sh --help || true
npm pack --dry-run --json | jq '.[0].files[].path' | grep 'framework/tools/tmux'
```
Expected: tmux tools are included in installable package or packaging fix is identified.
### Task 0.2: Inspect current yolo launch semantics
**Objective:** Confirm `mosaic yolo claude` and `mosaic yolo pi` accept optional initial prompt text and behave well under systemd/tmux.
**Files:**
- Read: `packages/mosaic/src/**`
- Read: `packages/mosaic/framework/runtime/claude/RUNTIME.md`
- Read: `packages/mosaic/framework/runtime/pi/RUNTIME.md`
**Verification commands:**
```bash
mosaic yolo claude --help
mosaic yolo pi --help
```
Expected: a systemd `ExecStart` can launch the runtime either with no prompt or with a kickstart prompt file/string.
---
## Phase 1 — Framework tmux primitives
### Task 1.1: Add socket support to send tools
**Objective:** Allow `agent-send.sh` and `send-message.sh` to target a named Mosaic tmux socket without affecting default tmux sessions.
**Files:**
- Modify: `packages/mosaic/framework/tools/tmux/send-message.sh`
- Modify: `packages/mosaic/framework/tools/tmux/agent-send.sh`
- Modify: `packages/mosaic/framework/tools/tmux/README.md`
- Test: `packages/mosaic/framework/tools/tmux/test-send-message.sh` (new)
**Design:**
Add optional flags:
```bash
-L SOCKET_NAME # tmux -L socket name
-SOCKET PATH # optional later if needed; avoid conflict with existing -S source label in agent-send
```
Because `agent-send.sh` already uses `-S` for source label, prefer `-L` for socket name and `-T` or `--socket-path` only if long-option parsing is added.
**Implementation notes:**
- Build a tmux command array:
```bash
tmux_cmd=(tmux)
if [ -n "$SOCKET_NAME" ]; then tmux_cmd+=( -L "$SOCKET_NAME" ); fi
```
- Replace raw `tmux ...` calls with `"${tmux_cmd[@]}" ...`.
- Pass `-L` through remote ssh invocation.
- Include socket name in verbose output.
**Verification:**
```bash
tmux -L mosaic-test new-session -d -s target 'cat'
packages/mosaic/framework/tools/tmux/send-message.sh -L mosaic-test -t target -m 'hello'
tmux -L mosaic-test capture-pane -t target -p | grep hello
tmux -L mosaic-test kill-server
```
Expected: message lands in the named socket session; default `tmux ls` is untouched.
### Task 1.2: Add exact target validation helper
**Objective:** Prevent accidental prefix targeting in all tmux fleet operations.
**Files:**
- Create: `packages/mosaic/framework/tools/tmux/_lib.sh`
- Modify: `send-message.sh`
- Modify: `agent-send.sh`
**Behavior:**
- For session-only agent names, normalize target to `=<name>` before kill/status/reset operations.
- For explicit pane targets like `session:window.pane`, allow as advanced path but document the risk.
**Verification:**
Create sessions `agent` and `agent0`; verify killing/resetting `agent` does not affect `agent0`.
---
## Phase 2 — systemd unit templates
### Task 2.1: Add holder service template
**Objective:** Ship a user systemd unit template that owns the Mosaic factory tmux server.
**Files:**
- Create: `packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service`
- Create: `packages/mosaic/framework/tools/fleet/install-user-units.sh`
**Unit shape:**
```ini
[Unit]
Description=Mosaic tmux fleet holder
Documentation=https://git.mosaicstack.dev/mosaicstack/aiguide
[Service]
Type=oneshot
RemainAfterExit=yes
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory
ExecStart=/usr/bin/tmux -L ${MOSAIC_TMUX_SOCKET} new-session -d -s _holder 'while true; do sleep 3600; done'
ExecStop=-/usr/bin/tmux -L ${MOSAIC_TMUX_SOCKET} kill-server
[Install]
WantedBy=default.target
```
**Important:** systemd environment expansion in `ExecStart` is limited. Verify syntax; if `%E`/environment expansion is awkward, generate concrete units from config instead of relying on dynamic expansion.
**Verification:**
```bash
systemd-analyze --user verify ~/.config/systemd/user/mosaic-tmux-holder.service
systemctl --user daemon-reload
systemctl --user start mosaic-tmux-holder.service
tmux -L mosaic-factory ls | grep _holder
```
### Task 2.2: Add agent service template
**Objective:** Ship a user systemd template that starts one configured agent slot.
**Files:**
- Create: `packages/mosaic/framework/systemd/user/mosaic-agent@.service`
- Modify: `packages/mosaic/framework/tools/fleet/install-user-units.sh`
**Unit shape:**
```ini
[Unit]
Description=Mosaic agent session %i
Requires=mosaic-tmux-holder.service
After=mosaic-tmux-holder.service
PartOf=mosaic-tmux-holder.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=%h/src
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory
ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "%i" "mosaic yolo $(mosaic fleet runtime %i)"'
ExecStop=-/usr/bin/tmux -L mosaic-factory kill-session -t '=%i'
[Install]
WantedBy=default.target
```
**Design warning:** command substitution in unit files can become brittle. Prefer a generated per-agent EnvironmentFile:
```text
~/.config/mosaic/fleet/agents/coder0.env
```
with:
```bash
MOSAIC_AGENT_NAME=coder0
MOSAIC_AGENT_RUNTIME=claude
MOSAIC_AGENT_WORKDIR=/home/jarvis/src
MOSAIC_TMUX_SOCKET=mosaic-factory
```
Then `ExecStart` calls a wrapper:
```bash
~/.config/mosaic/tools/fleet/start-agent-session.sh
```
**Verification:**
```bash
systemd-analyze --user verify ~/.config/systemd/user/mosaic-agent@.service
systemctl --user start mosaic-agent@coder0.service
tmux -L mosaic-factory has-session -t '=coder0'
systemctl --user restart mosaic-agent@coder0.service
```
Expected: holder server PID remains unchanged; only `coder0` session recycles.
### Task 2.3: Add start-agent wrapper
**Objective:** Keep systemd units simple by moving config lookup and launch command construction into a script.
**Files:**
- Create: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
**Behavior:**
Inputs:
```bash
start-agent-session.sh <agent-name>
```
Reads:
```text
$MOSAIC_HOME/fleet/agents/<agent-name>.env
```
Starts:
```bash
tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" "mosaic yolo $MOSAIC_AGENT_RUNTIME"
```
Guardrails:
- fail if runtime is empty;
- fail if workdir does not exist;
- no duplicate sessions unless `--replace` is passed;
- exact session names only.
---
## Phase 3 — roster config and CLI wrappers
### Task 3.1: Add fleet config schema and examples
**Objective:** Define customizable install-time roster without hardcoding USC.
**Files:**
- Create: `packages/mosaic/framework/fleet/roster.schema.json`
- Create: `packages/mosaic/framework/fleet/examples/minimal.yaml`
- Create: `packages/mosaic/framework/fleet/examples/usc-software-factory.yaml`
- Create: `packages/mosaic/framework/fleet/README.md`
**Schema concepts:**
- `transport`: `tmux` now; `matrix` later.
- `tmux.socket_name`
- `tmux.holder_session`
- `defaults.working_directory`
- `agents[].name`
- `agents[].runtime`
- `agents[].class`
- `agents[].model_hint`
- `agents[].persistent_persona`
- `agents[].reset_between_tasks`
- `agents[].kickstart_template`
**Verification:**
Use `jq` for JSON examples or add a small Python/YAML validator if YAML is chosen. If no YAML parser is guaranteed, store examples as JSON or support both with Python stdlib JSON first.
### Task 3.2: Add `mosaic fleet` commands
**Objective:** Provide operator-safe commands for install/status/start/stop/restart/verify.
**Files:**
- Modify: `packages/mosaic/src/cli.ts` or the current commander entrypoint.
- Create scripts under: `packages/mosaic/framework/tools/fleet/`
**Commands:**
```bash
mosaic fleet init --profile minimal|usc --write
mosaic fleet install-systemd
mosaic fleet start [agent]
mosaic fleet stop [agent]
mosaic fleet restart [agent]
mosaic fleet status --json
mosaic fleet verify
```
**Implementation path:**
Start by wrapping framework shell scripts from the TypeScript CLI. Do not overbuild a TypeScript service manager in the first pass.
### Task 3.3: Add `mosaic agent` commands
**Objective:** Provide transport-stable per-agent operations.
**Files:**
- Modify: Mosaic CLI entrypoint.
- Create: `packages/mosaic/framework/tools/agent/` or reuse `tools/tmux` + `tools/fleet`.
**Commands:**
```bash
mosaic agent roster [--json]
mosaic agent status [agent] [--json]
mosaic agent send <agent> --message "..."
mosaic agent reset <agent> --clear|--new
mosaic agent tail <agent> [-n 80]
```
**Reset behavior:**
For tmux transport, `reset --clear` sends `/clear` then Enter through `send-message.sh`.
For Claude/Pi differences, keep reset command configurable per runtime:
```yaml
runtimes:
claude:
reset_command: /clear
pi:
reset_command: /new
```
If a runtime does not support a known reset command, restart the service and send a fresh kickstart.
---
## Phase 4 — this-server rollout strategy
### Task 4.1: Install on separate socket first
**Objective:** Prove the holder pattern without disturbing existing sessions.
**Commands after implementation lands locally:**
```bash
mosaic fleet init --profile minimal --write
mosaic fleet install-systemd
systemctl --user daemon-reload
systemctl --user start mosaic-tmux-holder.service
mosaic fleet verify
```
Expected:
- `tmux -L mosaic-factory ls` shows `_holder`.
- normal `tmux ls` still shows existing sessions unchanged.
### Task 4.2: Start one canary agent
**Objective:** Validate single-agent start/restart isolation.
Use a harmless canary first, not the full fleet.
Example roster addition:
```yaml
- name: canary-pi
runtime: pi
class: canary
working_directory: /home/jarvis/src
```
Commands:
```bash
systemctl --user start mosaic-agent@canary-pi.service
SRV=$(tmux -L mosaic-factory display-message -p '#{pid}')
systemctl --user restart mosaic-agent@canary-pi.service
test "$SRV" = "$(tmux -L mosaic-factory display-message -p '#{pid}')"
tmux -L mosaic-factory ls
```
Expected: holder PID unchanged; `_holder` remains; `canary-pi` recreated.
### Task 4.3: Configure local Mosaic factory roster
**Objective:** Create the actual local roster for this server after canary passes.
Do not assume USC exact roster is desired here. Create a local profile such as:
```text
~/.config/mosaic/fleet/roster.yaml
```
Initial local recommendation:
- `mos-claude` orchestrator
- `coder0` / `coder1` implementers
- `rev0` reviewer
- `secrev0` security reviewer
- `ultron` final/adversarial reviewer
Scale to full USC-style pool only after resource/budget behavior is understood.
### Task 4.4: Cut over existing ad-hoc tmux sessions only if desired
**Objective:** Avoid data loss.
Existing sessions on this server are not on the proposed `mosaic-factory` socket. They can remain untouched. If we later want them under Mosaic fleet control:
1. list sessions;
2. capture logs/handoffs;
3. stop old processes intentionally;
4. recreate as configured `mosaic-agent@...` services;
5. verify comms and state.
Do not run `tmux kill-server` on the default socket unless Jason explicitly approves that outage.
---
## Phase 5 — docs and AI Guide backfill
### Task 5.1: Stack docs
**Objective:** Document install and customization for Mosaic Stack users.
**Files:**
- Create: `docs/fleet/tmux-fleet.md` or `packages/mosaic/framework/tools/fleet/README.md`
- Modify: top-level `README.md` if appropriate.
Must cover:
- what problem holder service solves;
- install commands;
- customization file;
- example rosters;
- reset/reuse lifecycle;
- exact-target safety;
- separate socket default;
- Matrix migration path.
### Task 5.2: AI Guide docs
**Objective:** Keep generic guidance in AI Guide and implementation details in Stack.
**Files in `mosaicstack/aiguide`:**
- Update: `playbooks/tmux-fleet.md` with named socket, roster/profile, and resettable-slot pattern.
- Add or update: `reference/agent-role-matrix.md` if PR #5 lands.
Do not put Mosaic install commands as the only path in AI Guide. Present them as one implementation profile.
---
## Phase 6 — Matrix migration seam
### Task 6.1: Add transport enum but implement tmux only
**Objective:** Avoid hardcoding tmux into orchestration semantics.
Roster:
```yaml
transport: tmux
```
Future:
```yaml
transport: matrix
matrix:
homeserver: https://matrix.example
room_prefix: mosaic-factory
```
### Task 6.2: Define transport interface docs
**Objective:** Make Matrix plugin work a transport swap, not a rewrite.
Minimum operations:
```text
send(agent, message)
reset(agent, mode)
status(agent)
tail(agent)
listAgents()
```
Any tmux-specific concept must stay below this line.
---
## Acceptance criteria
The implementation is complete when:
- `mosaic fleet init` can write a minimal roster.
- `mosaic fleet install-systemd` installs holder and agent units without hand editing.
- `mosaic fleet start` starts the holder and configured agents on a named tmux socket.
- Restarting one `mosaic-agent@name.service` does not change holder server PID or kill sibling sessions.
- `mosaic agent send` can deliver a message to a named agent with a self-identifying preamble.
- `mosaic agent reset` can clear/new a reusable slot and send a fresh kickstart.
- `mosaic fleet verify` proves holder ownership, exact-target safety, and per-agent restart isolation.
- Existing default tmux sessions on this server are not disturbed by default install.
- Docs explain generic customization and include USC-style roster only as an example.
- AI Guide remains generic; Mosaic Stack docs carry the concrete install path.
## Risks and mitigations
| Risk | Mitigation |
| --------------------------------------------------- | --------------------------------------------------------------------------------- |
| Killing existing tmux sessions | Use named `mosaic-factory` socket; no default `tmux kill-server`. |
| systemd unit quoting/env expansion bugs | Move logic into shell wrappers; verify with `systemd-analyze --user verify`. |
| Runtime reset command mismatch | Make reset command runtime-configurable; fallback to service restart + kickstart. |
| Tool install drift | Ensure npm package includes framework tmux/fleet tools; add packaging test. |
| Mosaic-specific assumptions leak into generic guide | Keep USC roster as example profile; AI Guide documents pattern/options. |
| Matrix migration blocked by tmux coupling | Add `mosaic agent` abstraction now; keep tmux details below transport layer. |
## Suggested first PR split
1. **PR A — tmux tool hardening**
- socket support;
- exact target helpers;
- tests/docs.
2. **PR B — fleet systemd primitives**
- holder unit;
- agent unit;
- start-agent wrapper;
- install-user-units script;
- verify script.
3. **PR C — roster and CLI**
- roster schema/examples;
- `mosaic fleet ...` commands;
- `mosaic agent ...` commands.
4. **PR D — local rollout and docs**
- local roster for this server;
- run canary;
- document verification evidence;
- update AI Guide with generic lessons.
## Immediate next action
Implement PR A first. It is low-risk, improves existing tools, and is required for a safe named-socket rollout on this server.