Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5a8d2cec | ||
|
|
e14ad9ab52 | ||
|
|
9fd16b9739 | ||
|
|
9051ad179b | ||
|
|
2f649ed930 | ||
|
|
db330c12c7 | ||
|
|
0a17d29bce | ||
|
|
3458f6a7ad | ||
|
|
b033952cd5 | ||
|
|
c102980ad4 | ||
|
|
58b96cb715 | ||
|
|
dd3ff944a1 | ||
|
|
8eb81ebec1 | ||
|
|
530597cc84 | ||
|
|
9a0d44f96a | ||
|
|
121b331c6c | ||
|
|
34e06e7de7 | ||
|
|
9bd4f1c405 | ||
|
|
a7b612435b | ||
|
|
87f10772ce | ||
|
|
7db4c5c2ed | ||
|
|
0273a84549 | ||
|
|
3b674b7a66 | ||
|
|
527bc581ca | ||
|
|
1249714a9a | ||
|
|
e175616885 | ||
|
|
6955717612 | ||
|
|
88d9cf750f | ||
|
|
c038706eed |
@@ -0,0 +1,115 @@
|
||||
# AGENTS.md — Mosaic Stack rebuild (`mosaicstack/stack-v2`)
|
||||
|
||||
Operational context for any agent session working in this repository.
|
||||
Read top to bottom; it is deliberately short — depth lives in the files it
|
||||
points to, not here.
|
||||
|
||||
## What this repository is
|
||||
|
||||
A standalone rebuild of Mosaic Stack: a file-based, fail-closed
|
||||
orchestration foundation that dispatches sandboxed headless pi workers to do
|
||||
real work, with immutable run records as evidence. Thirteen-plus tagged
|
||||
milestones (`git tag -l`) from `poc-container-hello-v0` to today; suites
|
||||
green at every step. Not production software — a proven foundation.
|
||||
|
||||
## Non-negotiable invariants (the canon)
|
||||
|
||||
1. **Root is bootstrap-only.** First-class system configuration lives at the
|
||||
repository root; everything else gets a dedicated directory (`roles/`,
|
||||
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
|
||||
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
|
||||
config — created only by `scripts/bootstrap.sh`, never overwritten,
|
||||
fail-closed on any problem. Repo-scoped role authority lives in
|
||||
`roles/*.json` (versioned, reviewed commits only).
|
||||
3. **Secrets** never enter the repository or container images; auth is
|
||||
runtime-only (read-only mount or environment variable).
|
||||
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
|
||||
tasks are declarative JSON with strict schemas.
|
||||
5. **Run records** under `<dataRoot>/runs/` are write-once evidence — never
|
||||
rewritten, only pruned via `prune` with a receipt.
|
||||
6. **Fail closed**: missing or invalid config/policy refuses the operation.
|
||||
Never improvise around a refusal; diagnose it.
|
||||
7. **Policy**: missions govern tasks (least-privilege intersection — a task
|
||||
narrows, never widens). Role authority is declared in `roles/` and changes
|
||||
only via reviewed commits.
|
||||
8. **Git**: commit only after suites are green; push only `main`; never
|
||||
force-push. `scripts/conductor-apply.sh` commits locally — push stays an
|
||||
explicit act.
|
||||
9. **Append-only logs**: BUILD-LOG.md (phases), `activation-log.jsonl`,
|
||||
`.pruned.log`, docs/SESSIONS.md. Corrections are new entries, never edits.
|
||||
|
||||
## Session protocol (mandatory)
|
||||
|
||||
- **Register** your session in `docs/SESSIONS.md` — one append-only line
|
||||
(date, actor, scope, outcome). Never rewrite or remove entries.
|
||||
- **Cadence**: read `docs/plans/CURRENT.md` → execute its single next action
|
||||
fully (implement → test → verify against acceptance criteria → commit →
|
||||
push → close issue) → update CURRENT.md → register in SESSIONS.md.
|
||||
- "next" means one action. A batch mandate ("run the queue") repeats the
|
||||
loop until green or blocked. Blocked means stop and report, never improvise.
|
||||
- Substantial work gets a Gitea issue and a BUILD-LOG phase entry
|
||||
(before/after, with corrections recorded honestly).
|
||||
|
||||
## Role model
|
||||
|
||||
- **Conductor**: a system-scoped role — not an agent, not a daemon. Holds
|
||||
git/credentials/policy authority; decomposes, dispatches, reviews,
|
||||
verifies, integrates. Protocol: `docs/plans/CONDUCTOR.md`. Exists only
|
||||
when invoked; push is never automatic.
|
||||
- **Workers**: headless pi via `scripts/run-task.sh` — sandboxed workspace,
|
||||
tools allowlist, optional persistent sessions and forks; no git, no
|
||||
credentials, no policy control.
|
||||
- Worker runs deliberately exclude this file (`--no-context-files` in the
|
||||
adapter): worker context is contracts + mission via the generated system
|
||||
prompt. This file is for conductor-level sessions.
|
||||
|
||||
## Command surface
|
||||
|
||||
`scripts/bootstrap.sh` (idempotent) · `build.sh` · `hello.sh` ·
|
||||
`verify.sh` · `run-task.sh run <task.json>` · `release.sh
|
||||
package|activate|rollback|status` · `reset.sh` (**danger**: wipes the data
|
||||
root; triple-safety-checked) · `mosaic-task.mjs validate|run|show|list|retry|prune` ·
|
||||
`agent.sh <name>` (interactive TUI agent) ·
|
||||
suites: `test-config.sh`, `test-task.sh`, `test-release.sh`,
|
||||
`test-conductor.sh`.
|
||||
|
||||
Full reference — usage, fields, exit codes, safety notes:
|
||||
`docs/TOOLS.md` (read on demand; do not rely on this summary for detail).
|
||||
|
||||
## Data map (canon)
|
||||
|
||||
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
|
||||
auto-written).
|
||||
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
|
||||
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
|
||||
- `sessions/` — pi JSONL session trees, one directory per named session
|
||||
- `workspaces/` — agent file effects (persistent or `:run` ephemeral)
|
||||
- `state/` — release pointer + append-only activation/auto-apply logs
|
||||
- Ownership is per-directory; nothing shares state. Directory map and
|
||||
lifecycle rules: README.md "Data map" section.
|
||||
|
||||
## Pointers (depth lives here)
|
||||
|
||||
- `docs/plans/CURRENT.md` — THE next action (single source of "what now")
|
||||
- `docs/plans/ROADMAP.md` — agreed milestone path (M16+)
|
||||
- `docs/plans/CONDUCTOR.md` — orchestration protocol and guardrails
|
||||
- `docs/plans/2026-09-02_atomic-mosaic-foundation.md` — architecture, invariants
|
||||
- `docs/plans/2026-09-03_autonomous-run.md` — batch-run tracker
|
||||
- `BUILD-LOG.md` — append-only build/verification history with corrections
|
||||
- `LAYERS.md` — implemented vs deferred layers
|
||||
- `docs/SESSIONS.md` — session registry
|
||||
- `adapters/README.md` — the harness adapter contract
|
||||
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
|
||||
|
||||
## Recovery rule
|
||||
|
||||
Compacted, restarted, or new? Nothing that matters is lost: this file +
|
||||
`docs/plans/CURRENT.md` + `git log --oneline -10` + the suites reconstruct
|
||||
the full state. **Never guess** — verify with the suites; the run records
|
||||
and logs hold the receipts.
|
||||
|
||||
## Version pin
|
||||
|
||||
`@earendil-works/pi-coding-agent` is pinned exactly (see `package.json` /
|
||||
`RELEASE`); never install unversioned. Release identity: `RELEASE` file
|
||||
(0.0.X until declared stable); image tags derive from it.
|
||||
+134
@@ -303,4 +303,138 @@ M5 tagged `workspace-capabilities-v1`, M6 tagged `sessions-v1`, M7 tagged `opera
|
||||
|
||||
Conductor loop proven end-to-end on the stack itself. `main` merged with M8; release 0.0.6 remains active (retry is host-side only, no image change).
|
||||
|
||||
---
|
||||
|
||||
## Phase 13: Mission-level capability policy (M9)
|
||||
|
||||
### Entry 13.1 — before
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Intended action: Missions may declare capabilities.tools as governing constraints (Gitea #30); merge semantics = least-privilege intersection (task narrows, never widens; empty intersection = tool-free run). Host-side only.
|
||||
- Reason: First mechanical restriction layer — the trust model becomes enforced, not instructed.
|
||||
- Expected result: all four merge cases asserted from run evidence; suites green.
|
||||
|
||||
### Entry 13.2 — after
|
||||
|
||||
- Observed: four merge cases verified deterministically via run-record stderr (mission-only, task-only, narrowed, emptied); invalid mission capabilities exit 2; task suite 41/41.
|
||||
- Failure or correction: selftest harness could not express ABSENT vs EMPTY fields via its printf helper — fixed with an ABSENT marker; two suite config-leak defects fixed (per-command env scoping). Product unaffected.
|
||||
|
||||
## Phase 14: Session forking (M11)
|
||||
|
||||
### Entry 14.1 — before
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Intended action: sessionForkFrom task field branches the source session's newest file (pi --fork) into the target session dir; ancestor untouched; RELEASE -> 0.0.7 with health-gated activation (Gitea #33).
|
||||
- Reason: Owner flagged conversation forking from a common ancestor as a desired property; pi JSONL trees make it native.
|
||||
- Expected result: forked child recalls ancestor context; ancestor file untouched; suites green; 0.0.7 active.
|
||||
|
||||
### Entry 14.2 — after
|
||||
|
||||
- Observed: mock plumbing asserts fork source + target delivery; validation rejects fork-without-target and self-fork (exit 2); ghost source exits 4; live fork: child recalled 'mosaico' from ancestor context while the ancestor session file remained untouched (file-level assertion); suites 58/24/14 + verify green; 0.0.7 packaged and activated via health gate.
|
||||
- Failure or correction: retryRun-style self-assignment bug in validation (compared null to target) — caught by negative test, fixed.
|
||||
|
||||
## Result (M11)
|
||||
|
||||
Session forking verified. `main` merged with M11, tagged `session-fork-v1`; release 0.0.7 active.
|
||||
|
||||
---
|
||||
|
||||
## Phase 15: Interactive TUI agent + TOOLS.md (M13)
|
||||
|
||||
### Entry 15.1 — before
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Intended action: Add scripts/agent.sh — an interactive TUI launcher (contracts + optional mission + agent identity + named session + optional workspace/tools) — and the pi-adapter interactive branch; remove the fixed compose command; add docs/TOOLS.md as the on-demand reference AGENTS.md routes to; RELEASE -> 0.0.8 (Gitea #35).
|
||||
- Reason: The owner's bootstrap model is vanilla pi sessions directed by AGENTS.md, graduating to governed TUI agents — the first the system itself launches.
|
||||
- Expected result: TUI agent launches with contracts+identity context; headless paths unchanged; TOOLS.md consolidates the reference.
|
||||
|
||||
### Entry 15.2 — after
|
||||
|
||||
- Observed: mock plumbing asserts agent name/session/workspace/mission delivery; identity section asserted in generated prompt; headless hello + suites green (24/58/17/14 + verify); 0.0.8 packaged and health-gated activated.
|
||||
- Failure or correction:
|
||||
1. Regression: pi adapter rewrite made MOSAIC_AGENT_NAME unconditionally required, breaking headless paths — caught by task suite (empty-stderr exit-nonzero), fixed (optional in headless; identity section simply omitted).
|
||||
2. Regression: unquoted $REQUEST_ARG word-split the request into positional args — fixed with positional-argument building (set -- ... "$@").
|
||||
3. Mission fixture wording (objective named the agent) invited the model to append its name after the marker, tripping the strict gate — fixture tightened; strict gate kept by design.
|
||||
- Conductor session env hygiene: sandbox config exports now scoped per-command after a leak broke cross-suite runs.
|
||||
|
||||
## Result (M13)
|
||||
|
||||
Interactive TUI agent launched and verified; TOOLS.md reference shipped. `main` merged with M13, tagged `interactive-agent-v1`; release 0.0.8 active.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Phase 16: Run-record retention (M10) — recorded retroactively
|
||||
|
||||
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled entry; the original phase entry was lost to editor races - see correction note below)
|
||||
- Summary: `mosaic-task.mjs prune [--keep=N] [--yes]` - keep newest N run records, dry-run by default, append-only `.pruned.log` receipt, sessions/workspaces/state untouched. 8 suite cases.
|
||||
- Observed: dry-run deletes nothing; keep-N honored; newest kept; receipt written; isolation asserted.
|
||||
- Correction (recorded): suite-hardening edits (prune section config scoping, duplicate helper removal) were applied in the same phase.
|
||||
|
||||
## Phase 17: Conductor auto-apply policy (M12) — recorded retroactively
|
||||
|
||||
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled)
|
||||
- Summary: `conductor-policy.json` (tracked, strictly validated: enabled switch, path allowlist, gating suites) + `scripts/conductor-apply.sh <runId>` - succeeded-run check, clean target tree, allowlist, syntax gates, apply, suites, attribution commit; any failure reverts; push never automatic. 17 sandbox suite cases.
|
||||
- Observed: all gates green; suite-failure auto-revert verified; disabled policy refuses with exit 2.
|
||||
- Decision recorded: review moves to after-the-fact (history revertible) for worker patches under the policy; push remains explicit.
|
||||
|
||||
## Phase 18: Live user context (M14) — recorded retroactively
|
||||
|
||||
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled)
|
||||
- Summary: USER.md removed from immutable contracts (wrong owner - user info is user-owned live context); `<dataRoot>/user/*.md` dispatched (sorted) into every agent launch's generated prompt; bootstrap seeds `user/USER.md` once; loader layers now governance -> persona -> identity -> mission -> user.
|
||||
- Observed: user edit propagates to next launch (TUI or headless) without rebuilds; contract-only prompts unchanged when no user dir present.
|
||||
- Correction (recorded): first implementation pass did not regenerate the container - caught by owner test (edits to repo/mirror copies of USER.md did not propagate; the design, not the test, was the defect).
|
||||
|
||||
## Phase 19: Agent seats + roles/ convention (M15) — recorded retroactively
|
||||
|
||||
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled)
|
||||
- Summary: `agents/<name>/` holds `agent.json` (strict validation: version, name, role?, capabilities?, workspace?, session?) + `SOUL.md` persona; `agent.sh` validates, copies runtime SOUL to `dataRoot/agents/<name>/`, sets `MOSAIC_AGENT_SOUL_FILE`; loader fills the SOUL slot from the seat persona (contract SOUL = default); identity section gains role; per-agent default workspace; compose passthroughs.
|
||||
- Observed: launch with seat definition replaces the contract persona in the generated prompt; role in identity; seat record written once; suites green; RELEASE 0.0.10 packaged, health-gated activated.
|
||||
- Correction (recorded): RELEASE was not bumped when container content changed - tag r0.0.9 rebuilt with different content (invariant lapse; r0.0.9 was never active). Restored: RELEASE 0.0.10 packaged beside, health-gated, activated.
|
||||
- Conventions recorded per owner direction: repository root holds bootstrap-required configuration only; role contracts live in `roles/`.
|
||||
|
||||
## Backfill note
|
||||
|
||||
Phases 16-19 were recorded retroactively on 2026-09-03 after editor-session races
|
||||
left them unwritten at the time of work. Ground truth for each entry: the git
|
||||
history (commit subjects/bodies), the Gitea milestone/issue records (#32, #34,
|
||||
#35, #36), and the suite files themselves. No facts were reconstructed from
|
||||
memory alone.
|
||||
|
||||
---
|
||||
|
||||
## Phase 20: Release self-determination (M16)
|
||||
|
||||
### Entry 20.1 — before
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Intended action: The system determines what is installed and aligns itself; the user never manually runs release.sh. `release.sh ensure` (align-or-noop) invoked automatically by human-facing launchers (hello, verify, agent); run-task reports drift without auto-aligning (workers/suites must not trigger builds or model gates mid-automation).
|
||||
- Reason: Owner direction — intelligence in operation; post-reset pointer loss required a manual release command, which contradicts the self-healing design.
|
||||
- Expected result: post-reset pointer loss auto-restores via health-gated ensure; drift warns on run-task; aligned state is a no-op; M20 packages/* decision recorded.
|
||||
|
||||
### Entry 20.2 — after
|
||||
|
||||
- Timestamp: 2026-09-03
|
||||
- Commands run: ensure with pointer removed (live drift); ensure idempotence; drift-warning demo via RELEASE bump without packaging; full suites.
|
||||
- Observed result: drift detected (active: none, desired: 0.0.11) -> health-gated activate -> aligned; second ensure no-op; run-task drift warning fired on desired-version bump without packaging; all suites green (24/68/17/14 + verify).
|
||||
- Failure or correction:
|
||||
1. First run-task edit anchor missed (inline comment mismatch) — reapplied with exact text.
|
||||
2. RELEASE was temporarily bumped to 0.0.12 without packaging for the drift demo — restored to 0.0.11; state pointer remained aligned.
|
||||
- M20 decision recorded in ROADMAP.md: v2 adopts packages/* monorepo structure at usurpation (owner, continuity-first).
|
||||
- Process note (tool races): mechanism confirmed — batching a file write/edit and a dependent bash command in one parallel block runs the bash before the write flushes. Consequences: lost doc updates, stale anchors, one premature grep. Operating rule: dependent calls sequential; every write verified before claimed complete.
|
||||
|
||||
## Result (M16)
|
||||
|
||||
Release self-determination live: the system aligns itself to RELEASE without manual commands. Suites 24/68/17/14 + verify green.
|
||||
|
||||
## Phase 21: M16 hardening + M20 decision
|
||||
|
||||
- Recursion guard: release.sh's health-gate task run sets MOSAIC_ENSURE_SKIP
|
||||
so the gate's run-task cannot re-enter release self-determination.
|
||||
- run-task.sh: drift warning on pointer/RELEASE mismatch (workers and suites
|
||||
never trigger builds or model gates mid-automation).
|
||||
- ROADMAP M20 decision recorded: v2 adopts packages/* monorepo structure at
|
||||
usurpation (owner, continuity-first); restructure sequenced as M20 phase 1.
|
||||
- Live drill: drift 0.0.11 -> 0.0.12 detected, health-gated activate, no
|
||||
recursion, verify green.
|
||||
|
||||
@@ -92,10 +92,15 @@ scripts/release.sh package # build + tag the release image
|
||||
scripts/release.sh activate # health check (exact marker) -> atomic pointer swap
|
||||
scripts/release.sh activate --fault-injection # prove the refusal path (drills only)
|
||||
scripts/release.sh rollback # health-gated return to the previous release
|
||||
scripts/release.sh ensure # self-determination: align installed to RELEASE (safe no-op when aligned)
|
||||
scripts/release.sh status # release, tag, active pointer, recent log
|
||||
scripts/test-release.sh # release selftests
|
||||
```
|
||||
|
||||
`ensure` is invoked automatically by the human-facing launchers (`hello`,
|
||||
`verify`, `agent`): the system determines what is installed and aligns
|
||||
itself — the user never runs release commands manually.
|
||||
|
||||
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
|
||||
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
set -eu
|
||||
|
||||
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "mock adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
|
||||
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "mock adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
|
||||
if [ "${MOSAIC_INTERACTIVE:-}" != "1" ]; then
|
||||
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "mock adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
|
||||
fi
|
||||
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
|
||||
|
||||
echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2
|
||||
|
||||
+53
-12
@@ -2,27 +2,40 @@
|
||||
# Pi adapter: implements the Mosaic adapter contract for the pinned
|
||||
# @earendil-works/pi-coding-agent CLI.
|
||||
#
|
||||
# Contract: see /opt/mosaic/adapters/README.md. stdout = response only.
|
||||
# Contract: see /opt/mosaic/adapters/README.md.
|
||||
# Headless (default): stdout = response only; stderr = diagnostics; exit 0.
|
||||
# Interactive (MOSAIC_INTERACTIVE=1): full pi TUI on the attached terminal.
|
||||
set -eu
|
||||
|
||||
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "pi adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
|
||||
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "pi adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
|
||||
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
|
||||
# MOSAIC_AGENT_NAME is optional in headless mode (identity section is then
|
||||
# omitted); interactive launches always set it via scripts/agent.sh.
|
||||
|
||||
: "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}"
|
||||
: "${PI_MODEL:?pi adapter: PI_MODEL is required}"
|
||||
|
||||
INTERACTIVE="${MOSAIC_INTERACTIVE:-}"
|
||||
if [ "$INTERACTIVE" != "1" ]; then
|
||||
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "pi adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
|
||||
fi
|
||||
|
||||
# Workspace (M5): run inside the provided workspace when present.
|
||||
if [ -n "${MOSAIC_WORKSPACE:-}" ]; then
|
||||
mkdir -p "$MOSAIC_WORKSPACE"
|
||||
cd "$MOSAIC_WORKSPACE"
|
||||
fi
|
||||
|
||||
# Session (M6): persistent named session directory; resume the most recent
|
||||
# session in that directory when one exists (pi documented flags).
|
||||
# Default remains ephemeral (--no-session) when no session is declared.
|
||||
# Session (M6/M11): default ephemeral (--no-session). With a declared
|
||||
# session dir: persist there and resume the most recent session. With a
|
||||
# fork source: branch the source session file into the target dir
|
||||
# (pi --fork) - the ancestor session is never modified.
|
||||
SESSION_FLAGS="--no-session"
|
||||
if [ -n "${MOSAIC_SESSION_DIR:-}" ]; then
|
||||
if [ -n "${MOSAIC_SESSION_FORK:-}" ]; then
|
||||
[ -n "${MOSAIC_SESSION_DIR:-}" ] || { echo "pi adapter: session fork requires MOSAIC_SESSION_DIR" >&2; exit 2; }
|
||||
mkdir -p "$MOSAIC_SESSION_DIR"
|
||||
SESSION_FLAGS="--fork $MOSAIC_SESSION_FORK --session-dir $MOSAIC_SESSION_DIR"
|
||||
elif [ -n "${MOSAIC_SESSION_DIR:-}" ]; then
|
||||
mkdir -p "$MOSAIC_SESSION_DIR"
|
||||
SESSION_FLAGS="--session-dir $MOSAIC_SESSION_DIR"
|
||||
if [ -n "$(ls -A "$MOSAIC_SESSION_DIR" 2>/dev/null)" ]; then
|
||||
@@ -34,16 +47,41 @@ fi
|
||||
TOOLS_FLAG="--no-tools"
|
||||
[ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS"
|
||||
|
||||
# Skills (M17): explicitly provided skill dirs replace discovery. When none
|
||||
# are provided the agent runs with --no-skills (nothing ambient to find).
|
||||
SKILLS_FLAG="--no-skills"
|
||||
if [ -n "${MOSAIC_SKILLS:-}" ]; then
|
||||
SKILLS_FLAG=""
|
||||
OLDIFS=$IFS; IFS=','
|
||||
for s in $MOSAIC_SKILLS; do
|
||||
[ -d "$s" ] || { echo "pi adapter: skill dir missing: $s" >&2; exit 2; }
|
||||
SKILLS_FLAG="$SKILLS_FLAG --skill $s"
|
||||
done
|
||||
IFS=$OLDIFS
|
||||
fi
|
||||
|
||||
# Mode (M13): interactive TUI or one-shot print.
|
||||
PRINT_MODE="-p"
|
||||
REQUEST_ARG=""
|
||||
if [ "$INTERACTIVE" = "1" ]; then
|
||||
PRINT_MODE=""
|
||||
else
|
||||
REQUEST_ARG="$MOSAIC_REQUEST"
|
||||
fi
|
||||
|
||||
# All flags documented in the pi package README (CLI Reference):
|
||||
# -p/--print noninteractive: print the response and exit
|
||||
# -p/--print one-shot mode: print the response and exit (omitted in
|
||||
# interactive TUI mode)
|
||||
# --system-prompt replace the default prompt with the generated one
|
||||
# --no-* no ambient context/skills/extensions/templates/themes
|
||||
# --no-session ephemeral; TOOLS_FLAG per capabilities
|
||||
# SESSION_FLAGS ephemeral | persistent | forked (per env)
|
||||
# TOOLS_FLAG per capabilities
|
||||
# --offline no startup network operations (update checks/telemetry)
|
||||
exec pi \
|
||||
PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
|
||||
set -- \
|
||||
--offline \
|
||||
--no-extensions \
|
||||
--no-skills \
|
||||
$SKILLS_FLAG \
|
||||
--no-prompt-templates \
|
||||
--no-themes \
|
||||
--no-context-files \
|
||||
@@ -51,5 +89,8 @@ exec pi \
|
||||
$SESSION_FLAGS \
|
||||
--provider "$PI_PROVIDER" \
|
||||
--model "$PI_MODEL" \
|
||||
--system-prompt "$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")" \
|
||||
-p "$MOSAIC_REQUEST"
|
||||
--system-prompt "$PROMPT_CONTENT"
|
||||
# One-shot mode appends -p and the request (both safely quoted);
|
||||
# interactive mode appends nothing - clean TUI.
|
||||
[ "$INTERACTIVE" = "1" ] || set -- "$@" -p "$MOSAIC_REQUEST"
|
||||
exec pi "$@"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# SOUL - researcher
|
||||
|
||||
You are the researcher seat of the Mosaic fleet. You are curious, methodical,
|
||||
and precise. You cite what you know, admit what you do not, and never guess
|
||||
when you can verify.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"agentVersion": 1,
|
||||
"name": "researcher",
|
||||
"role": "researcher",
|
||||
"capabilities": { "tools": ["read", "bash"] }
|
||||
}
|
||||
+12
-4
@@ -18,8 +18,16 @@ services:
|
||||
# Workspace + capabilities (set by the task runner; M5)
|
||||
MOSAIC_WORKSPACE: ${MOSAIC_WORKSPACE:-}
|
||||
MOSAIC_TOOLS: ${MOSAIC_TOOLS:-}
|
||||
# Persistent named session dir (set by the task runner; M6)
|
||||
# Persistent named session dir + optional fork source (M6/M11)
|
||||
MOSAIC_SESSION_DIR: ${MOSAIC_SESSION_DIR:-}
|
||||
MOSAIC_SESSION_FORK: ${MOSAIC_SESSION_FORK:-}
|
||||
# Interactive TUI mode + agent identity (M13, set by scripts/agent.sh)
|
||||
MOSAIC_INTERACTIVE: ${MOSAIC_INTERACTIVE:-}
|
||||
MOSAIC_AGENT_NAME: ${MOSAIC_AGENT_NAME:-}
|
||||
MOSAIC_AGENT_ROLE: ${MOSAIC_AGENT_ROLE:-}
|
||||
MOSAIC_AGENT_SOUL_FILE: ${MOSAIC_AGENT_SOUL_FILE:-}
|
||||
# Skill dirs explicitly provided to the seat (M17)
|
||||
MOSAIC_SKILLS: ${MOSAIC_SKILLS:-}
|
||||
# mock adapter only: verbatim response for deterministic seam tests
|
||||
MOSAIC_MOCK_RESPONSE: ${MOSAIC_MOCK_RESPONSE:-}
|
||||
# Documented container auth alternative: provider API key via
|
||||
@@ -33,6 +41,6 @@ services:
|
||||
# Runtime credential only: pi auth file mounted READ-ONLY.
|
||||
# Never copied into the image.
|
||||
- ${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}:/home/node/.pi/agent/auth.json:ro
|
||||
# One-shot: the exact startup verification request. It deliberately
|
||||
# does NOT contain the expected marker MOSAIC_HELLO_OK.
|
||||
command: ["Return your startup marker and nothing else."]
|
||||
# Headless runs: the request is passed as command args by the launchers
|
||||
# (run-task.sh) or defaults inside run-agent.sh (hello/verify). Never a
|
||||
# fixed command here - interactive runs (scripts/agent.sh) need no args.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Session registry — append-only
|
||||
|
||||
Every agent session (assistant, worker-cycle conductor, or owner-directed
|
||||
automation) that works in this repository registers one line here. Entries
|
||||
are never rewritten or removed; corrections are new entries.
|
||||
|
||||
| Date (UTC) | Actor | Scope | Outcome / artifacts |
|
||||
|---|---|---|---|
|
||||
| 2026-09-03 | assistant (conductor + worker) | POC through M12: containerized pi proof, config layer, missions/tasks, release model, adapter seam, workspaces/capabilities, named sessions, retention, session forking, conductor auto-apply, roles/ convention | 13 tags; suites 24/58/14 + 17 conductor + verify green; releases 0.0.1–0.0.7; issues #1–#34 closed |
|
||||
| 2026-09-03 | assistant (conductor) | User layer: profile updates (pets, family), ms-user skill review/revision (confirmation rules merged, propose-not-apply, missing-file flow, privacy scope, dispatch = all of user/, rule 9 scratch-file constraint), USER.md.bak removed | skills/ms-user/SKILL.md rewritten; ~/.mosaic-dev/user/USER.md updated (Family, Pets); USER.md.bak deleted |
|
||||
@@ -0,0 +1,85 @@
|
||||
# TOOLS.md — command and tool reference
|
||||
|
||||
On-demand reference for agent sessions (conductors, bootstrapping agents,
|
||||
reviewers). `AGENTS.md` routes here; this file carries the depth: usage,
|
||||
inputs/outputs, exit codes, and safety notes for every entry point.
|
||||
|
||||
Reading guide: all entry points are `scripts/*.sh` (bash) or invoked via
|
||||
`node scripts/mosaic-task.mjs` (node). Every script fails closed — missing
|
||||
or invalid configuration/policy refuses the operation with a nonzero exit
|
||||
and changes nothing.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Command | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `scripts/bootstrap.sh` | Create `~/.config/mosaic-dev/config.json` if absent | Idempotent; existing config validated, never rewritten |
|
||||
| `scripts/build.sh` | Build the release image | Tag derived from `RELEASE` + pinned pi version |
|
||||
| `scripts/hello.sh` | One-shot startup request | Prints model response on stdout |
|
||||
| `scripts/verify.sh` | Full gated test | Exit 0 only on exact `MOSAIC_HELLO_OK`; `EXPECTED_MARKER` overrides for negative drills |
|
||||
|
||||
## Tasks (missions, runs, evidence)
|
||||
|
||||
| Command | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `scripts/run-task.sh run <task.json>` | Execute a task | Immutable run record under `<dataRoot>/runs/` |
|
||||
| `scripts/run-task.sh validate <task.json>` | Strict validation | Writes nothing |
|
||||
| `node scripts/mosaic-task.mjs show <runId>` | Inspect a run | Full record + snapshots + artifacts |
|
||||
| `node scripts/mosaic-task.mjs list` | List runs | task/workspace/session columns |
|
||||
| `node scripts/mosaic-task.mjs retry <runId>` | Re-execute a run's snapshot | New run dir; `retriedFrom` lineage recorded |
|
||||
| `node scripts/mosaic-task.mjs prune [--keep=N] [--yes]` | Retention | Dry-run default; receipt in `runs/.pruned.log` |
|
||||
|
||||
Task fields: `prompt` (required), `mission` (path), `expectExact`,
|
||||
`timeoutSeconds` (5–600), `workspace` (`:run` or named), `capabilities.tools`
|
||||
(allowlist: read write edit bash grep find ls), `session`,
|
||||
`sessionForkFrom` (requires `session`). Mission fields: `objective`,
|
||||
`directives[]`, optional governing `capabilities.tools`. Policy: a task may
|
||||
narrow a mission's tools, never widen; empty intersection = tool-free run.
|
||||
|
||||
## Agent (interactive TUI)
|
||||
|
||||
```bash
|
||||
scripts/agent.sh <name> [--mission <file>] [--workspace <ws>] [--session <s>] [--tools <list>]
|
||||
```
|
||||
|
||||
Launches an interactive pi TUI inside the container with the four immutable
|
||||
contracts + optional mission + agent identity as its system prompt,
|
||||
persistent named session, optional workspace. Exit with `/quit`.
|
||||
|
||||
## Release
|
||||
|
||||
| Command | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `scripts/release.sh package` | Build + tag the release image | Tag: `mosaic-poc-agent:<pi>-r<release>` |
|
||||
| `scripts/release.sh activate` | Health gate → atomic pointer swap | `--fault-injection` proves the refusal path |
|
||||
| `scripts/release.sh rollback` | Health-gated return to previous | Refuses if image missing |
|
||||
| `scripts/release.sh status` | Release, tag, active pointer, log | Safe on empty state |
|
||||
|
||||
## Conductor (worker patches)
|
||||
|
||||
```bash
|
||||
scripts/conductor-apply.sh <runId> [--dry-run]
|
||||
```
|
||||
|
||||
Auto-applies a worker's patch under `roles/conductor-policy.json`:
|
||||
succeeded run → clean target tree → path allowlist → syntax gates →
|
||||
apply → policy suites → attribution commit. Any failure reverts.
|
||||
Push is never automatic.
|
||||
|
||||
## Maintenance
|
||||
|
||||
| Command | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `scripts/reset.sh` | Delete the data root | Triple-safety-checked (path, symlink, ownership marker) |
|
||||
| `scripts/test-config.sh` | Config selftests (no Docker) | 24 cases |
|
||||
| `scripts/test-task.sh` | Task selftests + live cases | 58 cases |
|
||||
| `scripts/test-release.sh` | Release selftests | 14 cases |
|
||||
| `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases |
|
||||
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper | Token never on argv/stdout |
|
||||
|
||||
## Exit-code convention
|
||||
|
||||
`0` success · `1` operation failed · `2` invalid data/configuration ·
|
||||
`3` configuration missing for a read operation · `4` usage/file/environment
|
||||
problem. Scripts print diagnostics on stderr; model responses (and only
|
||||
model responses) on stdout.
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
How the stack orchestrates headless pi workers to do work on itself.
|
||||
|
||||
## Role contracts
|
||||
|
||||
Role authority is declared in role contracts, one file per role, under
|
||||
`roles/` (e.g. `roles/conductor-policy.json`). The repository root holds
|
||||
only first-class, bootstrap-required configuration; role contracts are
|
||||
tracked, versioned files whose changes arrive as reviewed commits.
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Runs where | Powers | Never has |
|
||||
|
||||
+27
-15
@@ -7,20 +7,21 @@ update this file to the next action). No ambiguity, no re-planning.
|
||||
|
||||
## Next action
|
||||
|
||||
Owner review of M9 (mission capability policy) — then name the next target.
|
||||
M16 — release self-determination (`release.sh ensure` invoked at launch; post-reset auto-restore; no manual release commands). Owner approved 2026-09-03 — say "next" to execute.
|
||||
|
||||
## Queue (ordered, not started)
|
||||
## Queue (ordered per docs/plans/ROADMAP.md)
|
||||
|
||||
1. Run-record retention/pruning policy
|
||||
2. Second real adapter (parked — owner focused on Pi)
|
||||
3. Auto-apply policy for worker patches (capability policy now exists as its substrate)
|
||||
4. Session forking from a common ancestor (pi JSONL trees make this native)
|
||||
1. M17 — ms-tools skill (central tooling documentation; AGENTS.md references it; workers excluded via --no-skills)
|
||||
2. M18 — seat-role progressive capability restriction (role ceilings ∩ mission ∩ task)
|
||||
3. M19 — harness auth tooling (pi checkpoint: auth status, provider stacking, multi-account investigation)
|
||||
4. Deferred by owner: CI runners (Gitea hardware slow); second real adapter; push automation
|
||||
|
||||
## Rules
|
||||
|
||||
- One action in flight. Update this file at the END of every action.
|
||||
- Blocked? Move the item to "Blocked" below with the reason and stop.
|
||||
- Completed actions move to the log at the bottom (date + issue + result).
|
||||
- Corrected entries are marked, never silently rewritten (see 2026-09-03 dedup note).
|
||||
|
||||
## Blocked
|
||||
|
||||
@@ -28,12 +29,23 @@ Owner review of M9 (mission capability policy) — then name the next target.
|
||||
|
||||
## Completed log
|
||||
|
||||
- 2026-09-03 — M5 workspaces + capability envelope (#20, #21) — merged, tests green
|
||||
- 2026-09-03 — M6 named sessions + resume (#22, #23) — merged, teach/recall verified
|
||||
- 2026-09-03 — M7 run inspection + release 0.0.6 (#24) — merged, activated
|
||||
- 2026-09-03 — M8 conductor loop + worker-built retry (#25, #26, #27) — merged; worker authored retry in 2 refinement rounds, conductor fixed a 3-line interpolation rename; live retry verified
|
||||
- 2026-09-03 — retry lineage + relative mission resolution (#28) — merged, 36/24/14 suites + verify green
|
||||
- 2026-09-03 — M9 mission capability policy (#30) — merged, least-privilege intersection, 41/36/14 + verify green
|
||||
- 2026-09-03 — M8 conductor loop + worker-built retry (#25, #26, #27) — merged; worker authored retry in 2 refinement rounds, conductor fixed a 3-line interpolation rename; live retry verified
|
||||
- 2026-09-03 — retry lineage + relative mission resolution (#28) — merged, 36/24/14 suites + verify green
|
||||
- 2026-09-03 — M8 conductor loop + worker-built retry (#25, #26, #27) — merged; worker authored retry in 2 refinement rounds, conductor fixed a 3-line interpolation rename; live retry verified
|
||||
Note (2026-09-03): this log was deduplicated after editor-session races
|
||||
appended duplicate blocks. The dedup removed repeated lines only; every
|
||||
distinct action appears exactly once, in completion order. Ground truth:
|
||||
git history + Gitea issues.
|
||||
|
||||
- 2026-09-03 — POC: containerized pi hello-world (poc-container-hello-v0)
|
||||
- 2026-09-03 — M1 configuration-driven hello world (#1–#4; config-hello-v1); hotfix #5 stdin detach
|
||||
- 2026-09-03 — M2 mission/task abstraction (#6–#9; mission-task-v1); hotfix #14 release identity in task path
|
||||
- 2026-09-03 — M3 release model + safe updates (#10–#13; release-model-v1); drills: update/refusal/rollback
|
||||
- 2026-09-03 — M14 live user context layer (user/ dispatched to all launches; 0.0.9 built)
|
||||
- 2026-09-03 — M15 agent seats: per-agent SOUL + role contracts (#36; agent-seats-v1); roles/ convention (root = bootstrap-only)
|
||||
- 2026-09-03 — M13 interactive TUI agent + TOOLS.md (#35; interactive-agent-v1); release 0.0.8 activated
|
||||
- 2026-09-03 — M12 conductor auto-apply policy (#34; auto-apply-v1); 17 conductor selftests
|
||||
- 2026-09-03 — M11 session forking (#33; session-fork-v1); child recalls ancestor, base untouched
|
||||
- 2026-09-03 — M10 run-record retention (#32; retention-v1); prune keep-N, dry-run default, receipt
|
||||
- 2026-09-03 — M9 mission capability policy (#30; mission-policy-v1); least-privilege intersection
|
||||
- 2026-09-03 — test UX: green OK/red FAIL status colors; NO_COLOR-aware
|
||||
- 2026-09-03 — M10-era hotfix: retry lineage (#28) + AGENTS.md/SESSIONS.md recovery shim
|
||||
- 2026-09-03 — release 0.0.10 packaged and health-gated activated (user context + agent seats live)
|
||||
- 2026-09-03 — release 0.0.11 shipped (onboarding + live user context); ROADMAP.md agreed (M16–M19); CI deferred by owner
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# HARVEST — patterns from mosaicstack/stack + the live fleet runtime
|
||||
|
||||
Sources surveyed 2026-09-03 (owner-authorized):
|
||||
- `~/src/mosaic-stack` (drifted) and fresh clone at
|
||||
`~/src/mosaic-stack-worktrees/v2-harvest` (branch `next` @ 0db2d19a,
|
||||
"mosaic doctor structure-anchor provisioning check (T51 WP0b)")
|
||||
- `~/.mosaic/fleet/` — live fleet runtime: `bin/`, `roles/` (+`-ng`),
|
||||
`agents/<seat>/`, `auth/`, `roster.yaml`, `SEAT-CUTOVER.md`
|
||||
|
||||
Rule honored: nothing pulled whole-cloth. This file is the pattern ledger;
|
||||
each adoption lands through its own milestone with tests.
|
||||
|
||||
## Pattern ledger
|
||||
|
||||
| # | Pattern (source) | Verdict | Destination |
|
||||
|---|---|---|---|
|
||||
| 1 | **Channel dispatcher** — `fleet/bin/mosaic`: shipped npm CLI is default; dev worktree opt-in via `MOSAIC_CLI_WORKTREE` health-checked with `--version`; `MOSAIC_FLEET_CLI_OFF` forces pass-through; launcher never writes to user dirs | **steal** | M20 unified `mosaic` CLI |
|
||||
| 2 | **Single canonical launcher + per-seat env** — `bin/launch-seat.sh` is the only launcher; per-seat variation lives in `launch.env` ("per-seat copies drift") | **steal** | M20 CLI + agent.sh stays thin |
|
||||
| 3 | **Refuse-rather-than-guess provisioning** — `bin/new-seat.sh`: seat classes, `--adopt` never overwrites real identity files, `--mint` fails closed (unminted = git writes fail closed), `--dry-run` | **steal** | seat instantiation (M15 evolution) |
|
||||
| 4 | **Role manifest schema v3** — `roles/code-ng/mosaic-core.manifest.json`: capability bindings (id/effect/binding/status), `tools[]`, `forbiddenTools[]`, `shell.mode: denied`, `credentials` policy, `evidence` journaling, `digestRules` postcondition (`active-set-exact`, sha256 over manifest) | **adapt progressively** | M18 seat-role restriction |
|
||||
| 5 | **Per-seat git identity** — `MOSAIC_GIT_IDENTITY=<seat>` + `GIT_AUTHOR_NAME/EMAIL`; incident 2026-08-19: seven seats committed as the wrong identity via directory git config | **steal** | M18/M20 (we have no per-seat git writes yet — adopt before we do) |
|
||||
| 6 | **Auth slots + symlink shares** — `fleet/auth/<provider>/<account>/` credential dirs; seat `.pi/agent/` symlinks `auth.json settings.json models-store.json bin skills` | **adapt** | M19 auth tooling |
|
||||
| 7 | **Launch-time pre-flight probes** — every consumed path checked before start; pi's `existsSync` silently appends literal path strings as prompt text (documented trap) | **steal** | M19 launch check + our existing refusal style |
|
||||
| 8 | **Prompt ordering doctrine** — estate context first, seat SOUL/AGENTS last ("closest to the task wins") | **matches ours** | document in adapters/README |
|
||||
| 9 | **`--check` mode** on launchers (verify provisioning without launching) | **steal** | agent.sh / release ensure |
|
||||
| 10 | **ENTITY.md per seat** — identity record incl. git identity, explicitly secret-free | **steal** | seat records (extend seat.json or add ENTITY.md) |
|
||||
| 11 | **`mosaic doctor`** — structure-anchor provisioning check (T51) | **adapt** | M20 `mosaic --check` |
|
||||
| 12 | packages/ anatomy on `next`: `mosaic` (CLI), `auth`, `agent`, `config`, `forge`, `quality-rails`, `comms`, `macp` | reference map | M18/M19/M20 deep reads |
|
||||
|
||||
## Skips (for now)
|
||||
|
||||
- tmux comms watchers, lanes/, board/, memory/, journal/ — L5/L6 fleet
|
||||
territory; our conductor loop is deliberately not a resident service.
|
||||
- openbao/swarm/coolify compose stacks — deployment, far future.
|
||||
- roster.yaml + roster.schema.json — superseded by our agents/ definitions
|
||||
for now; revisit when fleet registration returns.
|
||||
|
||||
## Owner-corrected design points (2026-09-03)
|
||||
|
||||
- M17 revised: skills must reach **non-conductor seats as role-scoped
|
||||
subsets** — agents invent tooling when the right tool isn't supplied.
|
||||
Role contracts will declare skill sets; the runner loads them explicitly
|
||||
(mock-verified `--skill` + `--no-skills` coexistence pending).
|
||||
- M19 revised (owner's own diagnosis): auth selection was symlinks from
|
||||
`agents/<seat>/.pi` into `fleet/auth`, and a missing symlink silently
|
||||
fell back to defaults. Our design: the launcher reads the seat's declared
|
||||
account, **ensures** the symlink/auth selection before TUI launch, and
|
||||
refuses loudly on mismatch — check + fix, never silent default.
|
||||
- Unified `mosaic` CLI is directional (`mosaic tui`, `mosaic yolo
|
||||
<harness>`, `mosaic --check`): tracked as M20, harvested from pattern #1
|
||||
and stack `packages/mosaic`.
|
||||
@@ -0,0 +1,185 @@
|
||||
# ROADMAP — agreed path forward
|
||||
|
||||
Status: planning aligned with owner 2026-09-03. Feature work follows this
|
||||
order unless the owner re-orders. Design notes below are commitments about
|
||||
intent, not implementation — each milestone still gets its own issue, tests,
|
||||
and BUILD-LOG phase.
|
||||
|
||||
## Standing decisions (owner, 2026-09-03)
|
||||
|
||||
- **Pi is the main TUI harness** and the reference checkpoint. Multi-harness
|
||||
(Codex, Claude, OpenCode) comes after pi is stable as the primary seat.
|
||||
- **CI is deferred.** Gitea runs on slow hardware; local suites + verify are
|
||||
the quality gate. Revisit when code volume justifies runners.
|
||||
- **The user never manually runs release.sh.** The system determines what is
|
||||
installed and self-aligns (M16).
|
||||
- **Tooling documentation is agent-accessible** via the ms-tools skill (M17),
|
||||
referenced from AGENTS.md.
|
||||
- **Capability restriction becomes progressive and role-based** (M18),
|
||||
porting prior Mosaic Stack concepts where they fit the file-based runtime.
|
||||
|
||||
## M16 — Release self-determination
|
||||
|
||||
Problem: after `reset.sh` the activation pointer is gone and the owner had
|
||||
to know about `scripts/release.sh activate`. The system must determine what
|
||||
is actually installed.
|
||||
|
||||
Design:
|
||||
- New `release.sh ensure` (also invoked automatically by the launchers via
|
||||
`common.sh`): compares desired (`RELEASE`) vs packaged image vs active
|
||||
pointer.
|
||||
- Fast path (drift = pointer missing/mismatched, image already packaged):
|
||||
restore the pointer without a health gate — the release was previously
|
||||
health-gated; reset is data loss, not a new release.
|
||||
- Slow path (new version in RELEASE, or image missing): package beside →
|
||||
full health gate (exact-marker model run) → atomic pointer swap. This is
|
||||
the M3 gate-then-flip, now automatic.
|
||||
- Acceptance: post-`reset.sh`, the next `hello.sh`/`agent.sh` self-restores
|
||||
activation with zero manual release commands; a bumped `RELEASE` auto-
|
||||
activates after its health gate; a failing health gate leaves the previous
|
||||
release active and records the refusal.
|
||||
|
||||
## M17 — ms-tools skill + role-scoped skill lifecycle (DESIGN REVISED per owner)
|
||||
|
||||
Problem: agents without supplied tooling invent their own, even when a
|
||||
functional method exists. Skills must reach non-conductor seats as
|
||||
role-scoped subsets, with an explicit lifecycle.
|
||||
|
||||
Design (owner-specified):
|
||||
- Two machine-scoped directories under the data root:
|
||||
- `<dataRoot>/skills-enabled/` — loadable by agents
|
||||
- `<dataRoot>/skills-available/` — installed but disabled
|
||||
- Lifecycle: **install** → available · **activate** → enabled ·
|
||||
**deactivate** → back to available · **uninstall** → removed.
|
||||
A skill not in skills-enabled is not enabled or available for use.
|
||||
- Launch resolution: role contracts declare skill NAMES; the runner resolves
|
||||
each against skills-enabled and passes `--skill <path>` explicitly.
|
||||
`--skill` necessarily negates `--no-skills` (explicit provision wins).
|
||||
Contracted-but-not-enabled → **refuse the launch loudly** (a silently
|
||||
under-equipped seat is the exact failure mode this prevents).
|
||||
- Containerization removes ambiguity: agents get only what is explicitly
|
||||
provided; no discovery, no ambient skill leakage.
|
||||
- Interfaces: CLI first (`mosaic skill install|activate|deactivate|uninstall|list`),
|
||||
TUI and WebUI frontends call the same tooling later.
|
||||
- The ms-tools skill is the first shipped skill (tooling reference for
|
||||
conductor/vanilla sessions); role contracts then grant subsets per seat.
|
||||
|
||||
## M18 — Seat-role progressive capability restriction
|
||||
|
||||
Problem: capability restriction exists but is per-task/per-mission only.
|
||||
Seats (agent identities) need governing ceilings that tasks cannot escalate
|
||||
past — the M9 intersection chain extended: **role ceiling ∩ mission grant ∩
|
||||
task grant = effective**.
|
||||
|
||||
Design:
|
||||
- `roles/<role>.json` (role contracts, tracked): `roleVersion`, `name`,
|
||||
`tools` ceiling (subset of pi built-ins), `network: "none"|"api-only"|`
|
||||
`"open"` (declared now, enforced when network policy lands), `paths`
|
||||
(future).
|
||||
- Resolution chain in the runner: seat role contract ∩ mission capabilities
|
||||
∩ task capabilities → effective tools (existing intersection logic, one
|
||||
more link). Empty at any stage = tool-free run, loudly recorded.
|
||||
- agent.json keeps `role` (already shipped in M15) — it now binds to
|
||||
`roles/<role>.json`.
|
||||
- Porting note: prior Mosaic Stack role/policy concepts inform this design,
|
||||
but implementation is fresh on the file-based runtime (the standing
|
||||
repository exclusion applies until the owner points at specific prior
|
||||
files to port).
|
||||
- Adapters unchanged: `MOSAIC_TOOLS` already carries the effective set.
|
||||
|
||||
## M19 — Harness auth tooling (pi checkpoint)
|
||||
|
||||
Problem: auth is manual (`/login`, `auth.json`); multi-account and
|
||||
multi-harness stacking have no tooling.
|
||||
|
||||
Current facts (observed): one `auth.json` holds multiple providers
|
||||
(anthropic, openai-codex, zai observed on the host) — provider stacking
|
||||
already works. Multiple accounts per provider is the open question (the
|
||||
owner runs two Claude accounts; openai-codex multi-account desired).
|
||||
|
||||
Design direction (pi-first checkpoint):
|
||||
- `scripts/auth.sh status` — per-provider auth report (which providers have
|
||||
credentials, source: auth.json vs env), no secrets printed.
|
||||
- Multi-account: investigate pi's auth schema for per-provider account
|
||||
entries; if unsupported natively, model accounts as named auth files
|
||||
(`auth.<account>.json`) with a conductor-level selector — runtime-injected
|
||||
per launch, same pattern as the read-only credential mount.
|
||||
- Claude/Codex/OpenCode adapters stay parked; the auth tooling is
|
||||
harness-neutral so it serves them when they arrive.
|
||||
|
||||
## M20+ — Unified `mosaic` CLI and the stack succession path
|
||||
|
||||
Direction (owner): a unified `mosaic` CLI (`mosaic --check`, `mosaic tui`,
|
||||
`mosaic yolo <harness>`, `mosaic skill ...`, `mosaic release ensure`, ...).
|
||||
Much larger task; channel-dispatcher doctrine harvested from
|
||||
`fleet/bin/mosaic` (HARVEST.md #1).
|
||||
|
||||
Succession trajectory (owner): the v2 line is actively building the
|
||||
REPLACEMENT for the mosaicstack/stack repo. At the appropriate level of
|
||||
usurpation, v2 content becomes THE next-branch content on
|
||||
mosaicstack/stack — a full refactor-and-replace with a functional system.
|
||||
Not ready yet.
|
||||
|
||||
DECIDED (owner, 2026-09-03): v2 reorganizes to the `packages/*` monorepo
|
||||
structure for continuity with the existing stack tooling and expectations.
|
||||
Reference: harvest worktree `~/src/mosaic-stack-worktrees/v2-harvest`
|
||||
(branch `next` @ 0db2d19a) and `packages/mosaic` (CLI), `packages/auth`.
|
||||
Usurpation criteria to be defined before any replacement (proposed:
|
||||
functional parity of P0 operations on the v2 runtime + owner sign-off).
|
||||
|
||||
### Restructure sequencing (decided 2026-09-03, owner question)
|
||||
|
||||
The monorepo restructure is NOT done first. Reason: the bash entry points'
|
||||
final home is *replacement by* `packages/mosaic` commands, not containment -
|
||||
moving them into packages/ now, then replacing them at M20, migrates
|
||||
everything twice and churns the suites (the safety net) during peak feature
|
||||
work. Instead:
|
||||
|
||||
1. **Layout doctrine frozen now** (this file + AGENTS.md): nothing new lands
|
||||
at root; features build in their durable homes (`skills/`, `roles/`,
|
||||
`docs/`, `adapters/`, `src/`).
|
||||
2. **M20 phase 1 = restructure**: `packages/` monorepo skeleton absorbs the
|
||||
script entry points as CLI commands (`packages/mosaic`), with
|
||||
`packages/agent`, `packages/auth` following the harvested anatomy. The
|
||||
bash scripts retire as they are absorbed.
|
||||
3. **Succession**: at usurpation, v2 content (already in `packages/*` shape)
|
||||
becomes the next-branch content of mosaicstack/stack.
|
||||
|
||||
### Target tree (post-M20-phase-1)
|
||||
|
||||
/ bootstrap + entry docs (unchanged)
|
||||
+-- packages/
|
||||
| +-- mosaic/ the CLI: tui, yolo, skill, release, onboard, check
|
||||
| +-- agent/ seat/session runtime (absorbs src/ + adapters/)
|
||||
| +-- auth/ provider slots, account ensure (absorbs M19)
|
||||
| +-- config/ schema + policy validation (absorbs config tooling)
|
||||
+-- contracts/ roles/ missions/ tasks/ templates/ declarative inputs
|
||||
+-- skills/ shipped skills (installed to dataRoot on activate)
|
||||
+-- docs/ plans, harvest, SESSIONS, TOOLS
|
||||
+-- src/ container runtime (loader, dispatcher)
|
||||
|
||||
### Skills as discipline encoding (owner insight, 2026-09-03)
|
||||
|
||||
Skills are not tool menus - they encode operational discipline: order of
|
||||
operations, gotchas, and failure history. The tool-race lesson and every
|
||||
catalogued defect become skill content per role (e.g. a conductor skill
|
||||
carries: sequential dependent calls; verify writes before claiming;
|
||||
symlink-ensure before TUI; path pre-checks before pi flags). Role
|
||||
contracts (M17) declare which skill subsets each seat receives.
|
||||
|
||||
## Explicitly deferred
|
||||
|
||||
- CI runners (owner: Gitea hardware is slow; local suites are the gate) —
|
||||
revisit as code volume grows.
|
||||
- Network egress policy enforcement (declared in role contracts at M18;
|
||||
enforcement is its own milestone).
|
||||
- Push automation (push stays an explicit act).
|
||||
- Second real adapter; fleet/multi-agent; knowledge storage; portal.
|
||||
|
||||
## Completed reference (see docs/plans/CURRENT.md log)
|
||||
|
||||
M1 config-driven hello · M2 missions/tasks · M3 release model · M4 adapter
|
||||
seam · M5 workspaces/capabilities · M6 sessions · M7 ergonomics · M8
|
||||
conductor loop · M9 mission capability policy · M10 retention · M11 session
|
||||
forking · M12 auto-apply policy · M13 interactive TUI agent · M14 live user
|
||||
context · M15 agent seats + roles/ convention.
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"missionVersion": 1,
|
||||
"id": "m-hello",
|
||||
"objective": "Prove the startup marker path of the mosaic-poc-agent.",
|
||||
"objective": "Verify the startup marker path.",
|
||||
"directives": [
|
||||
"Startup verification requests are answered with the marker only.",
|
||||
"No explanation, no formatting."
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"policyVersion": 1,
|
||||
"autoApply": {
|
||||
"enabled": true,
|
||||
"allowedPaths": [
|
||||
"scripts/**",
|
||||
"docs/**",
|
||||
"tasks/**",
|
||||
"missions/**",
|
||||
"adapters/**",
|
||||
"README.md"
|
||||
],
|
||||
"suites": [
|
||||
"test-config",
|
||||
"test-task",
|
||||
"test-release"
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch an interactive (TUI) Mosaic agent in its container.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/agent.sh <name> [--mission <file>] [--workspace <ws>]
|
||||
# [--session <name>] [--tools <comma,list>]
|
||||
#
|
||||
# The agent receives the four immutable contracts (constitution, standards,
|
||||
# SOUL, USER) plus its own identity and optional mission directives as its
|
||||
# system prompt, a persistent named session, and - if declared - a
|
||||
# workspace and tool capabilities. The TUI opens clean; you drive.
|
||||
#
|
||||
# This is the Mosaic alternative to launching vanilla pi: same engine,
|
||||
# governed context.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
# shellcheck source=common.sh
|
||||
source scripts/common.sh
|
||||
|
||||
NAME=""
|
||||
MISSION=""
|
||||
WORKSPACE=""
|
||||
SESSION=""
|
||||
TOOLS=""
|
||||
SKILLS=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--mission) MISSION="${2:?}"; shift 2 ;;
|
||||
--workspace) WORKSPACE="${2:?}"; shift 2 ;;
|
||||
--session) SESSION="${2:?}"; shift 2 ;;
|
||||
--tools) TOOLS="${2:?}"; shift 2 ;;
|
||||
--skills) SKILLS="${2:?}"; shift 2 ;;
|
||||
--help|-h) sed -n '2,12p' "$0"; exit 0 ;;
|
||||
*) NAME="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$NAME" ] || { echo "agent: usage: scripts/agent.sh <name> [--mission f] [--workspace ws] [--session s] [--tools list]" >&2; exit 4; }
|
||||
case "$NAME" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid agent name" >&2; exit 4;; esac
|
||||
|
||||
load_config
|
||||
load_release
|
||||
bootstrap_runtime_dir
|
||||
ensure_release_aligned
|
||||
|
||||
# Onboarding gate (M16): a TUI agent cannot launch without a user profile.
|
||||
# The onboarding wizard runs automatically here - the TTY is already yours.
|
||||
if [ ! -f "$MOSAIC_DEV_DIR/user/USER.md" ]; then
|
||||
echo "agent: no user profile found - starting onboarding"
|
||||
scripts/onboard.sh
|
||||
[ -f "$MOSAIC_DEV_DIR/user/USER.md" ] || { echo "agent: onboarding did not complete; aborting launch" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
# Agent seat definition (M15): when agents/<name>/agent.json exists it is
|
||||
# strictly validated and its values become defaults (CLI flags override).
|
||||
# The seat's SOUL.md overrides the contract persona; governance contracts
|
||||
# are never overridden.
|
||||
AGENTS_DIR="${MOSAIC_AGENTS_DIR:-agents}"
|
||||
ROLE=""
|
||||
DEFCAPS=""
|
||||
AGENT_DEF_SKILLS=""
|
||||
AGENT_DEF_SKILLS=""
|
||||
if [ -f "$AGENTS_DIR/$NAME/agent.json" ]; then
|
||||
DEFAULTS_FILE="$(mktemp)"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
if (p.agentVersion !== 1) process.exit(2);
|
||||
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
if (typeof p.name !== "string" || !ID.test(p.name)) process.exit(2);
|
||||
if (p.role !== undefined && (typeof p.role !== "string" || !ID.test(p.role))) process.exit(2);
|
||||
let tools = "";
|
||||
if (p.capabilities !== undefined) {
|
||||
if (typeof p.capabilities !== "object" || p.capabilities === null || Array.isArray(p.capabilities)) process.exit(2);
|
||||
for (const k of Object.keys(p.capabilities)) if (k !== "tools") process.exit(2);
|
||||
if (!Array.isArray(p.capabilities.tools) || p.capabilities.tools.some(t => !/^[a-z]+$/.test(t))) process.exit(2);
|
||||
tools = p.capabilities.tools.join(",");
|
||||
}
|
||||
fs.writeFileSync(process.argv[2], "AGENT_DEF_ROLE=" + (p.role || "") + "\nAGENT_DEF_CAPS=" + tools + "\nAGENT_DEF_SKILLS=" + ((p.skills && Array.isArray(p.skills)) ? p.skills.join(",") : "") + "\n");
|
||||
' "$AGENTS_DIR/$NAME/agent.json" "$DEFAULTS_FILE" || { rm -f "$DEFAULTS_FILE"; echo "agent: invalid agent definition" >&2; exit 2; }
|
||||
AGENT_DEF_ROLE=""; AGENT_DEF_CAPS=""; AGENT_DEF_SKILLS=""
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
AGENT_DEF_ROLE=*) AGENT_DEF_ROLE="${line#AGENT_DEF_ROLE=}" ;;
|
||||
AGENT_DEF_CAPS=*) AGENT_DEF_CAPS="${line#AGENT_DEF_CAPS=}" ;;
|
||||
AGENT_DEF_SKILLS=*) AGENT_DEF_SKILLS="${line#AGENT_DEF_SKILLS=}" ;;
|
||||
esac
|
||||
done < "$DEFAULTS_FILE"
|
||||
rm -f "$DEFAULTS_FILE"
|
||||
ROLE="$AGENT_DEF_ROLE"
|
||||
DEFCAPS="$AGENT_DEF_CAPS"
|
||||
[ -r "$AGENTS_DIR/$NAME/SOUL.md" ] || { echo "agent: definition dir missing SOUL.md: $AGENTS_DIR/$NAME" >&2; exit 4; }
|
||||
mkdir -p "$MOSAIC_DEV_DIR/agents/$NAME"
|
||||
cp "$AGENTS_DIR/$NAME/SOUL.md" "$MOSAIC_DEV_DIR/agents/$NAME/SOUL.md"
|
||||
export MOSAIC_AGENT_SOUL_FILE="/var/lib/mosaic/agents/$NAME/SOUL.md"
|
||||
# Seat record: written once at instantiation.
|
||||
SEAT="$MOSAIC_DEV_DIR/agents/$NAME/seat.json"
|
||||
if [ ! -f "$SEAT" ]; then
|
||||
printf '{"seatVersion":1,"name":"%s","role":"%s","instantiatedAt":"%s"}\n' \
|
||||
"$NAME" "$ROLE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SEAT"
|
||||
fi
|
||||
fi
|
||||
|
||||
SESSION="${SESSION:-agent-$NAME}"
|
||||
mkdir -p "$MOSAIC_DEV_DIR/sessions/$SESSION"
|
||||
export MOSAIC_SESSION_DIR="/var/lib/mosaic/sessions/$SESSION"
|
||||
export MOSAIC_AGENT_NAME="$NAME"
|
||||
[ -n "$ROLE" ] && export MOSAIC_AGENT_ROLE="$ROLE"
|
||||
export MOSAIC_INTERACTIVE=1
|
||||
if [ -z "$TOOLS" ] && [ -n "$DEFCAPS" ]; then TOOLS="$DEFCAPS"; fi
|
||||
export MOSAIC_TOOLS="${TOOLS:+$TOOLS}"
|
||||
|
||||
# Skills (M17): seat definition may declare skill names; each must be
|
||||
# enabled in <dataRoot>/skills-enabled or the launch refuses - a silently
|
||||
# under-equipped seat is the failure mode this prevents.
|
||||
SKILLS_LIST="${SKILLS:-$AGENT_DEF_SKILLS}"
|
||||
if [ -n "$SKILLS_LIST" ]; then
|
||||
mkdir -p "$MOSAIC_DEV_DIR/skills-enabled"
|
||||
RESOLVED=""
|
||||
OLDIFS=$IFS; IFS=','
|
||||
for s in $SKILLS_LIST; do
|
||||
case "$s" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid skill name: '$s'" >&2; exit 2;; esac
|
||||
[ -d "$MOSAIC_DEV_DIR/skills-enabled/$s" ] || { echo "agent: skill '$s' is declared but not enabled (scripts/skill.sh activate $s)" >&2; exit 1; }
|
||||
RESOLVED="${RESOLVED:+$RESOLVED,}/var/lib/mosaic/skills-enabled/$s"
|
||||
done
|
||||
IFS=$OLDIFS
|
||||
export MOSAIC_SKILLS="$RESOLVED"
|
||||
fi
|
||||
|
||||
if [ -n "$MISSION" ]; then
|
||||
[ -r "$MISSION" ] || { echo "agent: mission file not readable: $MISSION" >&2; exit 4; }
|
||||
mkdir -p "$MOSAIC_DEV_DIR/agent-missions"
|
||||
cp "$MISSION" "$MOSAIC_DEV_DIR/agent-missions/$NAME.json"
|
||||
export MOSAIC_MISSION_FILE="/var/lib/mosaic/agent-missions/$NAME.json"
|
||||
fi
|
||||
|
||||
# Workspace (M13): defaults to a persistent per-agent workspace
|
||||
# (workspaces/<agent>) so the agent has a real, host-visible home instead
|
||||
# of the container's neutral /workspace. Override with --workspace <ws>.
|
||||
[ -n "$WORKSPACE" ] || WORKSPACE="$NAME"
|
||||
case "$WORKSPACE" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid workspace name" >&2; exit 4;; esac
|
||||
mkdir -p "$MOSAIC_DEV_DIR/workspaces/$WORKSPACE"
|
||||
export MOSAIC_WORKSPACE="/var/lib/mosaic/workspaces/$WORKSPACE"
|
||||
|
||||
echo "agent: launching TUI agent '$NAME' (session: $SESSION, adapter: $MOSAIC_ADAPTER, model: $MOSAIC_MODEL)"
|
||||
echo "agent: contracts + $([ -n "$MISSION" ] && echo 'mission' || echo 'no mission') loaded; exit the TUI with /quit"
|
||||
# No -T: the TTY is the point. Ctrl+C twice or /quit exits.
|
||||
exec docker compose run --rm mosaic-agent
|
||||
@@ -50,4 +50,24 @@ bootstrap_runtime_dir() {
|
||||
echo "bootstrap: created $MOSAIC_DEV_DIR"
|
||||
fi
|
||||
touch "$MOSAIC_DEV_DIR/$POC_ROOT_MARKER"
|
||||
# Live user context layer (M14/M16): the directory is system-managed,
|
||||
# but USER.md is NEVER auto-created. Onboarding (scripts/onboard.sh,
|
||||
# auto-invoked by agent.sh) creates it from the template.
|
||||
mkdir -p "$MOSAIC_DEV_DIR/user"
|
||||
}
|
||||
|
||||
# M16 release self-determination: launchers call this after load_release.
|
||||
# Cheap no-op when the active pointer matches the desired RELEASE; on drift
|
||||
# it delegates to `release.sh ensure` (package if needed, health-gated
|
||||
# activate). The health gate's own task run sets MOSAIC_ENSURE_SKIP so the
|
||||
# ensure never re-enters itself.
|
||||
ensure_release_aligned() {
|
||||
[ -n "${MOSAIC_ENSURE_SKIP:-}" ] && return 0
|
||||
local pointer="$MOSAIC_DEV_DIR/state/active.json"
|
||||
local active=""
|
||||
if [ -f "$pointer" ]; then
|
||||
active="$(node -e 'try{const p=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(p.release||"")}catch{}' "$pointer" 2>/dev/null)"
|
||||
fi
|
||||
[ "$active" = "$MOSAIC_RELEASE" ] && return 0
|
||||
MOSAIC_ENSURE_SKIP=1 scripts/release.sh ensure
|
||||
}
|
||||
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# Conductor auto-apply: integrate a worker's patch under the declared policy.
|
||||
#
|
||||
# Usage: scripts/conductor-apply.sh <runId> [--dry-run]
|
||||
#
|
||||
# Policy (roles/conductor-policy.json in the target repo, strictly validated):
|
||||
# autoApply.enabled master switch
|
||||
# autoApply.allowedPaths glob allowlist ('dir/**' = everything under dir)
|
||||
# autoApply.suites suite scripts that must pass AFTER applying
|
||||
#
|
||||
# Gate sequence: succeeded run record -> clean target tree -> diff extracted
|
||||
# from the worker workspace -> allowlist -> syntax gates (node/bash/json) ->
|
||||
# apply -> policy suites -> commit with attribution. ANY failure reverts the
|
||||
# working tree and exits nonzero. Push is never automatic.
|
||||
#
|
||||
# Environment:
|
||||
# MOSAIC_APPLY_TARGET repo root to apply into (default: this project)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_ROOT="${MOSAIC_APPLY_TARGET:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
||||
RUN_ID="${1:?usage: conductor-apply.sh <runId> [--dry-run]}"
|
||||
DRY_RUN="no"
|
||||
[ "${2:-}" = "--dry-run" ] && DRY_RUN="yes"
|
||||
|
||||
cd "$TARGET_ROOT"
|
||||
|
||||
fail() { echo "conductor-apply: $*" >&2; exit "${2:-1}"; }
|
||||
[ -d .git ] || fail "target is not a git repository: $TARGET_ROOT" 4
|
||||
[ -f roles/conductor-policy.json ] || fail "no roles/conductor-policy.json in target" 2
|
||||
|
||||
# ---- policy (strict) ----
|
||||
POLICY_JSON="$(node -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync("roles/conductor-policy.json", "utf8"));
|
||||
if (p.policyVersion !== 1) process.exit(3);
|
||||
if (!p.autoApply || typeof p.autoApply.enabled !== "boolean" || !Array.isArray(p.autoApply.allowedPaths) || !Array.isArray(p.autoApply.suites)) process.exit(3);
|
||||
for (const g of p.autoApply.allowedPaths) {
|
||||
if (typeof g !== "string" || !/^[A-Za-z0-9_.*/-]+$/.test(g) || g.startsWith("/") || g.includes("..")) process.exit(3);
|
||||
}
|
||||
console.log(JSON.stringify(p.autoApply));
|
||||
')" || fail "invalid roles/conductor-policy.json" 2
|
||||
|
||||
ENABLED="$(node -e 'console.log(JSON.parse(process.argv[1]).enabled)' "$POLICY_JSON")"
|
||||
[ "$ENABLED" = "true" ] || fail "auto-apply is disabled by policy" 2
|
||||
|
||||
# ---- run record ----
|
||||
DATA_ROOT="$(node scripts/mosaic-config.mjs validate | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.parse(d).dataRoot))')"
|
||||
RESULT_FILE="$DATA_ROOT/runs/$RUN_ID/result.json"
|
||||
[ -f "$RESULT_FILE" ] || fail "run not found: $RUN_ID" 4
|
||||
|
||||
node -e '
|
||||
const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
|
||||
process.exit(r.status === "succeeded" ? 0 : 1);
|
||||
' "$RESULT_FILE" || fail "run $RUN_ID did not succeed; refusing to auto-apply"
|
||||
|
||||
WORKSPACE_NAME="$(node -e 'const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.workspace||"")' "$RESULT_FILE")"
|
||||
[ -n "$WORKSPACE_NAME" ] || fail "run has no workspace; nothing to integrate" 4
|
||||
WORKSPACE="$DATA_ROOT/workspaces/$WORKSPACE_NAME"
|
||||
[ -d "$WORKSPACE/.git" ] || fail "workspace is not a git clone: $WORKSPACE" 4
|
||||
|
||||
# ---- extract diff (tracked + intent-to-add) ----
|
||||
git -C "$WORKSPACE" add -N . >/dev/null 2>&1 || true
|
||||
DIFF_FILE="$(mktemp)"
|
||||
trap 'rm -f "$DIFF_FILE"' EXIT
|
||||
git -C "$WORKSPACE" diff > "$DIFF_FILE"
|
||||
if [ ! -s "$DIFF_FILE" ]; then
|
||||
fail "workspace has no changes to apply"
|
||||
fi
|
||||
|
||||
# ---- allowlist ----
|
||||
mapfile -t CHANGED < <(git -C "$WORKSPACE" diff --name-only)
|
||||
GLOBS="$(node -e 'const a=JSON.parse(process.argv[1]).allowedPaths;console.log(a.join("\n"))' "$POLICY_JSON")"
|
||||
REFUSED=""
|
||||
for f in "${CHANGED[@]}"; do
|
||||
ok="no"
|
||||
while IFS= read -r g; do
|
||||
[ -z "$g" ] && continue
|
||||
case "$f" in
|
||||
$g) ok="yes"; break ;;
|
||||
esac
|
||||
done <<< "$GLOBS"
|
||||
[ "$ok" = "yes" ] || REFUSED="$REFUSED $f"
|
||||
done
|
||||
if [ -n "$REFUSED" ]; then
|
||||
echo "conductor-apply: refusing - files outside policy allowlist:$REFUSED" >&2
|
||||
echo "conductor-apply: patch preserved at $DIFF_FILE for manual review" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- syntax gates (on workspace files, pre-apply) ----
|
||||
for f in "${CHANGED[@]}"; do
|
||||
case "$f" in
|
||||
*.mjs) node --check "$WORKSPACE/$f" || fail "syntax gate failed (node): $f" 1 ;;
|
||||
*.sh) bash -n "$WORKSPACE/$f" || fail "syntax gate failed (bash): $f" 1 ;;
|
||||
*.json) node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$WORKSPACE/$f" || fail "syntax gate failed (json): $f" 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$DRY_RUN" = "yes" ]; then
|
||||
echo "conductor-apply (dry-run): would apply $(echo "${#CHANGED[@]}") file(s) from $RUN_ID:"
|
||||
printf ' %s\n' "${CHANGED[@]}"
|
||||
echo "conductor-apply (dry-run): suites would run: $(node -e 'console.log(JSON.parse(process.argv[1]).suites.join(", "))' "$POLICY_JSON")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- apply ----
|
||||
[ -z "$(git status --porcelain)" ] || fail "target tree is not clean; refusing to mix states"
|
||||
git apply "$DIFF_FILE" || fail "git apply failed"
|
||||
|
||||
# ---- policy suites ----
|
||||
SUITES="$(node -e 'console.log(JSON.parse(process.argv[1]).suites.join(" "))' "$POLICY_JSON")"
|
||||
SUITES_OK="yes"
|
||||
for s in $SUITES; do
|
||||
case "$s" in
|
||||
test-[a-z]*) : ;; # shape guard; existence checked next
|
||||
*) echo "conductor-apply: refusing suspicious suite name: $s" >&2; SUITES_OK="no"; break ;;
|
||||
esac
|
||||
[ -x "scripts/$s.sh" ] || { echo "conductor-apply: suite script missing: scripts/$s.sh" >&2; SUITES_OK="no"; break; }
|
||||
if ! bash "scripts/$s.sh" >/dev/null 2>&1; then
|
||||
echo "conductor-apply: suite failed: $s" >&2
|
||||
SUITES_OK="no"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$SUITES_OK" != "yes" ]; then
|
||||
git apply -R "$DIFF_FILE" && echo "conductor-apply: changes REVERTED (suites failed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- commit with attribution ----
|
||||
git add -A
|
||||
git commit -q -m "feat(worker): auto-applied patch from run $RUN_ID
|
||||
|
||||
Authored-by: pi worker (run $RUN_ID, workspace $WORKSPACE_NAME)
|
||||
Applied-under: conductor-policy v1 (allowlist + syntax gates + suites)"
|
||||
echo "conductor-apply: applied and committed run $RUN_ID ($(echo "${#CHANGED[@]}") file(s)); suites: $SUITES"
|
||||
echo "conductor-apply: NOT pushed - push remains an explicit act."
|
||||
@@ -14,6 +14,7 @@ load_config
|
||||
load_release
|
||||
|
||||
bootstrap_runtime_dir
|
||||
ensure_release_aligned
|
||||
|
||||
# -T: no pseudo-TTY, so stdout is clean model output.
|
||||
# </dev/null: detach stdin. Pi's print mode reads piped stdin until EOF;
|
||||
|
||||
+40
-1
@@ -129,7 +129,7 @@ function validateMission(document, file) {
|
||||
|
||||
function validateTask(document, file) {
|
||||
if (!isPlainObject(document)) fail(2, "task must be a JSON object");
|
||||
rejectUnknownKeys(document, ["taskVersion", "id", "prompt", "mission", "expectExact", "timeoutSeconds", "workspace", "capabilities", "session"], "task");
|
||||
rejectUnknownKeys(document, ["taskVersion", "id", "prompt", "mission", "expectExact", "timeoutSeconds", "workspace", "capabilities", "session", "sessionForkFrom"], "task");
|
||||
if (document.taskVersion !== 1) {
|
||||
fail(2, `unsupported taskVersion: ${JSON.stringify(document.taskVersion)} (supported: 1)`);
|
||||
}
|
||||
@@ -192,6 +192,23 @@ function validateTask(document, file) {
|
||||
session = document.session;
|
||||
}
|
||||
|
||||
// Session fork (M11): optional source session whose newest session file
|
||||
// is branched (pi --fork) into the target session dir. Requires session.
|
||||
let sessionForkFrom = null;
|
||||
if (document.sessionForkFrom !== undefined && document.sessionForkFrom !== null) {
|
||||
if (typeof document.sessionForkFrom !== "string" || document.sessionForkFrom.length === 0) {
|
||||
fail(2, 'task "sessionForkFrom" must be a non-empty string when present');
|
||||
}
|
||||
validateId(document.sessionForkFrom, "task sessionForkFrom");
|
||||
if (!session) {
|
||||
fail(2, 'task "sessionForkFrom" requires "session" (the fork target) to be set');
|
||||
}
|
||||
if (document.sessionForkFrom === session) {
|
||||
fail(2, 'task "sessionForkFrom" must differ from "session" (cannot fork onto itself)');
|
||||
}
|
||||
sessionForkFrom = document.sessionForkFrom;
|
||||
}
|
||||
|
||||
// Capabilities (M5): optional tools allowlist mapped by adapters to their
|
||||
// native permission flags. Absent = no tools.
|
||||
let tools = null;
|
||||
@@ -224,6 +241,7 @@ function validateTask(document, file) {
|
||||
workspace,
|
||||
tools,
|
||||
session,
|
||||
sessionForkFrom,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -338,6 +356,26 @@ function runTask(taskFile, options = {}) {
|
||||
spawnEnv.MOSAIC_SESSION_DIR = `/var/lib/mosaic/sessions/${task.session}`;
|
||||
}
|
||||
|
||||
// Session fork (M11): resolve the source session's newest file; pi --fork
|
||||
// branches it into the target dir without modifying the ancestor.
|
||||
if (task.sessionForkFrom) {
|
||||
const sourceDir = path.join(resolved.dataRoot, "sessions", task.sessionForkFrom);
|
||||
let sources = [];
|
||||
try {
|
||||
sources = fs.readdirSync(sourceDir).filter((f) => f.endsWith(".jsonl")).sort();
|
||||
} catch {
|
||||
fail(4, `cannot fork: source session dir not found: ${sourceDir}`);
|
||||
}
|
||||
if (sources.length === 0) {
|
||||
fail(4, `cannot fork: source session '${task.sessionForkFrom}' has no session files`);
|
||||
}
|
||||
const relative = path.relative(resolved.dataRoot, path.join(sourceDir, sources[sources.length - 1]));
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
fail(4, `source session is outside the configured dataRoot: ${sourceDir}`);
|
||||
}
|
||||
spawnEnv.MOSAIC_SESSION_FORK = `/var/lib/mosaic/${relative.split(path.sep).join("/")}`;
|
||||
}
|
||||
|
||||
const proc = spawnSync(
|
||||
"docker",
|
||||
["compose", "run", "--rm", "-T", "mosaic-agent", task.prompt],
|
||||
@@ -388,6 +426,7 @@ function runTask(taskFile, options = {}) {
|
||||
workspace: task.workspace,
|
||||
tools: effectiveTools,
|
||||
session: task.session,
|
||||
sessionForkFrom: task.sessionForkFrom,
|
||||
exitCode: proc.status,
|
||||
signal: proc.signal ?? null,
|
||||
provider: resolved.execution.provider,
|
||||
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# User onboarding: renders the user profile (user/USER.md) from the canon
|
||||
# template. User name is REQUIRED; everything else is optional at this time
|
||||
# (skillset is intended to be filled ongoing by agents in later layers).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/onboard.sh # interactive prompts (TTY)
|
||||
# scripts/onboard.sh --name "Jason" [--timezone "Europe/Berlin"] [...]
|
||||
#
|
||||
# Fields: name (required), profession, marital-status, age, gender,
|
||||
# education, location, timezone, skillset, interests, hobbies, pets
|
||||
#
|
||||
# The rendered profile is dispatched to every agent launch. Re-running
|
||||
# onboarding replaces the profile (the previous file is kept as .bak).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
# shellcheck source=common.sh
|
||||
source scripts/common.sh
|
||||
|
||||
load_config
|
||||
load_release
|
||||
bootstrap_runtime_dir
|
||||
|
||||
TEMPLATE="templates/USER.md"
|
||||
OUT_DIR="$MOSAIC_DEV_DIR/user"
|
||||
OUT="$OUT_DIR/USER.md"
|
||||
[ -f "$TEMPLATE" ] || { echo "onboard: template missing: $TEMPLATE" >&2; exit 4; }
|
||||
|
||||
NAME="" PROFESSION="" MARITAL_STATUS="" AGE="" GENDER="" EDUCATION=""
|
||||
LOCATION="" TIMEZONE="" SKILLSET="" INTERESTS="" HOBBIES="" PETS=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--name) NAME="${2:-}"; shift 2 ;;
|
||||
--profession) PROFESSION="${2:-}"; shift 2 ;;
|
||||
--marital-status) MARITAL_STATUS="${2:-}"; shift 2 ;;
|
||||
--age) AGE="${2:-}"; shift 2 ;;
|
||||
--gender) GENDER="${2:-}"; shift 2 ;;
|
||||
--education) EDUCATION="${2:-}"; shift 2 ;;
|
||||
--location) LOCATION="${2:-}"; shift 2 ;;
|
||||
--timezone) TIMEZONE="${2:-}"; shift 2 ;;
|
||||
--skillset) SKILLSET="${2:-}"; shift 2 ;;
|
||||
--interests) INTERESTS="${2:-}"; shift 2 ;;
|
||||
--hobbies) HOBBIES="${2:-}"; shift 2 ;;
|
||||
--pets) PETS="${2:-}"; shift 2 ;;
|
||||
*) echo "onboard: unknown option: $1" >&2; exit 4 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
INTERACTIVE="no"
|
||||
if [ -t 0 ] && [ -t 1 ]; then INTERACTIVE="yes"; fi
|
||||
|
||||
trim() { local v="$1"; v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}"; printf '%s' "$v"; }
|
||||
valid_value() { local v="$1"; [ -n "$v" ] && [ "${#v}" -le 500 ] && [ "${v//$'\n'/}" = "$v" ] && [ "${v//$'\r'/}" = "$v" ]; }
|
||||
|
||||
# Required: name
|
||||
if [ -z "$(trim "$NAME")" ]; then
|
||||
if [ "$INTERACTIVE" = "yes" ]; then
|
||||
while [ -z "$(trim "$NAME")" ]; do
|
||||
read -r -p "Your name (required): " NAME || NAME=""
|
||||
NAME="$(trim "$NAME")"
|
||||
done
|
||||
else
|
||||
echo "onboard: user name is required (use --name \"...\", or run from a terminal)" >&2
|
||||
exit 4
|
||||
fi
|
||||
fi
|
||||
NAME="$(trim "$NAME")"
|
||||
valid_value "$NAME" || { echo "onboard: invalid name" >&2; exit 2; }
|
||||
|
||||
# Optional fields: prompt interactively when not supplied by flag
|
||||
ask_optional() { # varname label current
|
||||
local __label="$2" __val=""
|
||||
if [ -z "$(trim "$3")" ] && [ "$INTERACTIVE" = "yes" ]; then
|
||||
read -r -p "$__label (optional, Enter to skip): " __val || __val=""
|
||||
__val="$(trim "$__val")"
|
||||
if valid_value "$__val"; then printf '%s' "$__val"; return; fi
|
||||
printf '%s' "$3"
|
||||
else
|
||||
printf '%s' "$3"
|
||||
fi
|
||||
}
|
||||
PROFESSION="$(ask_optional PROFESSION "Profession" "$PROFESSION")"
|
||||
MARITAL_STATUS="$(ask_optional MARITAL_STATUS "Marital status" "$MARITAL_STATUS")"
|
||||
AGE="$(ask_optional AGE "Age" "$AGE")"
|
||||
GENDER="$(ask_optional GENDER "Gender" "$GENDER")"
|
||||
EDUCATION="$(ask_optional EDUCATION "Education level" "$EDUCATION")"
|
||||
LOCATION="$(ask_optional LOCATION "Location" "$LOCATION")"
|
||||
TIMEZONE="$(ask_optional TIMEZONE "Timezone" "$TIMEZONE")"
|
||||
SKILLSET="$(ask_optional SKILLSET "Skillset" "$SKILLSET")"
|
||||
INTERESTS="$(ask_optional INTERESTS "Interests" "$INTERESTS")"
|
||||
HOBBIES="$(ask_optional HOBBIES "Hobbies" "$HOBBIES")"
|
||||
PETS="$(ask_optional PETS "Pets" "$PETS")"
|
||||
|
||||
for pair in "profession:$PROFESSION" "marital:$MARITAL_STATUS" "age:$AGE" "gender:$GENDER" "education:$EDUCATION" "location:$LOCATION" "timezone:$TIMEZONE" "skillset:$SKILLSET" "interests:$INTERESTS" "hobbies:$HOBBIES" "pets:$PETS"; do
|
||||
val="${pair#*:}"
|
||||
[ -n "$(trim "$val")" ] || continue # empty optional fields are valid
|
||||
valid_value "$(trim "$val")" || { echo "onboard: invalid value for ${pair%%:*}" >&2; exit 2; }
|
||||
done
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
# Preserve the previous profile before replacing it (onboard re-runs are
|
||||
# deliberate, but the old copy costs nothing to keep).
|
||||
[ -f "$OUT" ] && cp -f "$OUT" "$OUT_DIR/USER.md.bak"
|
||||
|
||||
# Render: template placeholders -> values; unfilled -> "(not provided)".
|
||||
# Node keeps the rendering exact regardless of slashes/& in values.
|
||||
NAME="$NAME" PROFESSION="$PROFESSION" MARITAL_STATUS="$MARITAL_STATUS" AGE="$AGE" \
|
||||
GENDER="$GENDER" EDUCATION="$EDUCATION" LOCATION="$LOCATION" TIMEZONE="$TIMEZONE" \
|
||||
SKILLSET="$SKILLSET" INTERESTS="$INTERESTS" HOBBIES="$HOBBIES" PETS="$PETS" \
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const t = fs.readFileSync(process.argv[1], "utf8");
|
||||
const v = process.env;
|
||||
const out = t.replace(/\{\{(\w+)\}\}/g, (m, k) => {
|
||||
const val = v[k];
|
||||
return val === undefined || val === "" ? "(not provided)" : val;
|
||||
});
|
||||
fs.writeFileSync(process.argv[2], out);
|
||||
' "$TEMPLATE" "$OUT.new"
|
||||
|
||||
mv -f "$OUT.new" "$OUT"
|
||||
echo "onboard: user profile written: $OUT"
|
||||
echo "onboard: dispatched to every agent launch; edit freely or re-run onboard"
|
||||
+23
-2
@@ -49,7 +49,9 @@ health_check() { # returns 0 only when the marker path passes; $1 = fault inject
|
||||
task="$tmp/fault-task.json"
|
||||
fi
|
||||
local rc=0
|
||||
scripts/run-task.sh run "$task" >/dev/null 2>&1 || rc=$?
|
||||
# MOSAIC_ENSURE_SKIP: the gate's run must not re-enter release
|
||||
# self-determination (recursion guard).
|
||||
MOSAIC_ENSURE_SKIP=1 scripts/run-task.sh run "$task" >/dev/null 2>&1 || rc=$?
|
||||
[ -n "$tmp" ] && rm -rf "$tmp"
|
||||
return "$rc"
|
||||
}
|
||||
@@ -140,10 +142,29 @@ cmd_rollback() {
|
||||
activate "$prev_release" "$prev" "rollback"
|
||||
}
|
||||
|
||||
cmd_ensure() {
|
||||
# M16 release self-determination: the system determines what is installed
|
||||
# and aligns itself. Aligned (pointer == RELEASE, image present) -> no-op.
|
||||
# Drifted -> package if needed, then health-gated activate.
|
||||
local pointer="$MOSAIC_DEV_DIR/state/active.json"
|
||||
local active=""
|
||||
if [ -f "$pointer" ]; then
|
||||
active="$(node -e 'try{const p=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(p.release||"")}catch{}' "$pointer")"
|
||||
fi
|
||||
if [ "$active" = "$MOSAIC_RELEASE" ] && image_exists "$MOSAIC_IMAGE_TAG"; then
|
||||
echo "release: aligned at $MOSAIC_RELEASE ($MOSAIC_IMAGE_TAG)"
|
||||
return 0
|
||||
fi
|
||||
echo "release: drift (active: ${active:-none}, desired: $MOSAIC_RELEASE) - aligning"
|
||||
image_exists "$MOSAIC_IMAGE_TAG" || cmd_package
|
||||
activate "$MOSAIC_RELEASE" "$MOSAIC_IMAGE_TAG" "activate"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
package) cmd_package ;;
|
||||
activate) shift; cmd_activate "$@" ;;
|
||||
rollback) cmd_rollback ;;
|
||||
status) cmd_status ;;
|
||||
*) echo "usage: scripts/release.sh package | activate [--fault-injection] | rollback | status" >&2; exit 4 ;;
|
||||
ensure) shift; cmd_ensure "$@" ;;
|
||||
*) echo "usage: scripts/release.sh package | activate [--fault-injection] | rollback | status | ensure" >&2; exit 4 ;;
|
||||
esac
|
||||
|
||||
@@ -14,4 +14,15 @@ load_config
|
||||
load_release # compose requires MOSAIC_IMAGE_TAG; task runs are release-scoped too
|
||||
bootstrap_runtime_dir
|
||||
|
||||
# Workers never auto-align releases (no builds or model gates mid-automation);
|
||||
# drift is reported so the operator or a human-facing launcher can align.
|
||||
if [ -f "$MOSAIC_DEV_DIR/state/active.json" ]; then
|
||||
ACTIVE_REL="$(node -e 'try{const p=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(p.release||"")}catch{}' "$MOSAIC_DEV_DIR/state/active.json")"
|
||||
[ "$ACTIVE_REL" = "$MOSAIC_RELEASE" ] || echo "run-task: note: release drift (active: ${ACTIVE_REL:-none}, desired: $MOSAIC_RELEASE) - run scripts/release.sh ensure to align" >&2
|
||||
fi
|
||||
|
||||
if [ ! -f "$MOSAIC_DEV_DIR/user/USER.md" ]; then
|
||||
echo "run-task: note: user profile not onboarded - continuing without user context (scripts/onboard.sh)" >&2
|
||||
fi
|
||||
|
||||
exec node scripts/mosaic-task.mjs "$@"
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# Skill lifecycle: install / activate / deactivate / uninstall / list.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/skill.sh install <source-dir | bundled-name>
|
||||
# scripts/skill.sh activate <name>
|
||||
# scripts/skill.sh deactivate <name>
|
||||
# scripts/skill.sh uninstall <name>
|
||||
# scripts/skill.sh list
|
||||
#
|
||||
# Layout (machine-scoped, under the data root):
|
||||
# <dataRoot>/skills-available/<name> installed, not loadable
|
||||
# <dataRoot>/skills-enabled/<name> loadable by agent launches
|
||||
#
|
||||
# A skill not in skills-enabled is not enabled or available for use.
|
||||
# Lifecycle: install -> available; activate -> enabled; deactivate ->
|
||||
# available; uninstall -> removed (only from available).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
# shellcheck source=common.sh
|
||||
source scripts/common.sh
|
||||
|
||||
load_config
|
||||
load_release
|
||||
bootstrap_runtime_dir
|
||||
|
||||
ENABLED="$MOSAIC_DEV_DIR/skills-enabled"
|
||||
AVAILABLE="$MOSAIC_DEV_DIR/skills-available"
|
||||
mkdir -p "$ENABLED" "$AVAILABLE"
|
||||
|
||||
die() { local code="$1"; shift; echo "skill: $*" >&2; exit "$code"; }
|
||||
|
||||
is_skill_dir() { [ -f "$1/SKILL.md" ]; }
|
||||
|
||||
resolve_source() { # bundled name (skills/<name> in repo) or explicit path
|
||||
if [ -d "skills/$1" ]; then printf 'skills/%s' "$1"; return; fi
|
||||
if [ -d "$1" ]; then printf '%s' "$1"; return; fi
|
||||
die 4 "source skill dir not found: $1"
|
||||
}
|
||||
|
||||
frontmatter_name() { # extract name: from SKILL.md frontmatter
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const t = fs.readFileSync(process.argv[1], "utf8");
|
||||
const m = t.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!m) process.exit(1);
|
||||
const name = (m[1].match(/^name:\s*(.+)$/m) || [])[1];
|
||||
if (!name) process.exit(1);
|
||||
process.stdout.write(name.trim());
|
||||
' "$1/SKILL.md"
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
install)
|
||||
src="${2:?usage: skill.sh install <source-dir|bundled-name>}"
|
||||
resolve_source "$src" >/dev/null || die 4 "source skill dir not found: $src"
|
||||
SRC_DIR="$(resolve_source "$src")"
|
||||
is_skill_dir "$SRC_DIR" || die 4 "not a skill (missing SKILL.md): $SRC_DIR"
|
||||
NAME="$(basename "$SRC_DIR")"
|
||||
[ -d "$AVAILABLE/$NAME" ] && die 1 "already installed: $NAME"
|
||||
mkdir -p "$AVAILABLE"
|
||||
cp -r "$SRC_DIR" "$AVAILABLE/$NAME"
|
||||
echo "skill: installed $NAME -> skills-available"
|
||||
echo "skill: activate with scripts/skill.sh activate $NAME"
|
||||
;;
|
||||
activate)
|
||||
name="${2:?usage: skill.sh activate <name>}"
|
||||
[ -d "$AVAILABLE/$name" ] || die 4 "not installed: $name"
|
||||
[ -d "$ENABLED/$name" ] && die 1 "already enabled: $name"
|
||||
mv "$AVAILABLE/$name" "$ENABLED/$name"
|
||||
echo "skill: enabled $name"
|
||||
;;
|
||||
deactivate)
|
||||
name="${2:?usage: skill.sh deactivate <name>}"
|
||||
[ -d "$ENABLED/$name" ] || die 4 "not enabled: $name"
|
||||
mv "$ENABLED/$name" "$AVAILABLE/$name"
|
||||
echo "skill: deactivated $name -> skills-available"
|
||||
;;
|
||||
uninstall)
|
||||
name="${2:?usage: skill.sh uninstall <name>}"
|
||||
if [ -d "$ENABLED/$name" ]; then
|
||||
die 1 "refusing: $name is enabled - deactivate first"
|
||||
fi
|
||||
[ -d "$AVAILABLE/$name" ] || die 4 "not installed: $name"
|
||||
rm -rf "$AVAILABLE/$name"
|
||||
echo "skill: uninstalled $name"
|
||||
;;
|
||||
list)
|
||||
echo "enabled:"
|
||||
for d in "$ENABLED"/*/; do
|
||||
[ -d "$d" ] && echo " $(basename "$d")"
|
||||
done
|
||||
echo "available:"
|
||||
for d in "$AVAILABLE"/*/; do
|
||||
[ -d "$d" ] && echo " $(basename "$d")"
|
||||
done
|
||||
;;
|
||||
*)
|
||||
echo "usage: scripts/skill.sh install <src> | activate <name> | deactivate <name> | uninstall <name> | list" >&2
|
||||
exit 4
|
||||
;;
|
||||
esac
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sandboxed selftests for the conductor auto-apply policy gate.
|
||||
#
|
||||
# Builds a throwaway target repo + worker workspace + fake run records, then
|
||||
# exercises every gate: policy validation, allowlist, syntax gates, suite
|
||||
# failure revert, disabled policy, missing/failed runs. No real model calls.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SANDBOX="$(mktemp -d)"
|
||||
trap 'rm -rf "$SANDBOX"' EXIT
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m'
|
||||
else
|
||||
C_OK=""; C_FAIL=""; C_RESET=""
|
||||
fi
|
||||
|
||||
check_rc() { # name expectedRc command...
|
||||
local name="$1" expected="$2"
|
||||
shift 2
|
||||
local rc
|
||||
"$@" >/dev/null 2>&1
|
||||
rc=$?
|
||||
if [ "$rc" -eq "$expected" ]; then
|
||||
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $name (exit $rc)"
|
||||
else
|
||||
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $name (exit $rc, expected $expected)"
|
||||
fi
|
||||
}
|
||||
|
||||
check() {
|
||||
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi
|
||||
}
|
||||
|
||||
# ---- infrastructure: target repo + worker workspace + fake run ----
|
||||
git clone -q . "$SANDBOX/repo"
|
||||
# The clone carries committed state only - give the target its policy and
|
||||
# commit it so the tree starts clean (untracked policy would fail target_clean).
|
||||
mkdir -p "$SANDBOX/repo/roles"
|
||||
cp roles/conductor-policy.json "$SANDBOX/repo/roles/conductor-policy.json"
|
||||
git -C "$SANDBOX/repo" add roles/conductor-policy.json
|
||||
git -C "$SANDBOX/repo" -c user.name=suite -c user.email=suite@local commit -q -m policy
|
||||
mkdir -p "$SANDBOX/data/workspaces" "$SANDBOX/data/runs"
|
||||
git clone -q "$SANDBOX/repo" "$SANDBOX/data/workspaces/stack-repo"
|
||||
|
||||
TARGET="$SANDBOX/repo"
|
||||
WS="$SANDBOX/data/workspaces/stack-repo"
|
||||
cat > "$SANDBOX/config.json" <<EOF
|
||||
{"configVersion":1,"environment":"development","dataRoot":"$SANDBOX/data","execution":{"backend":"docker","provider":"zai","model":"m","adapter":"mock"}}
|
||||
EOF
|
||||
export MOSAIC_APPLY_TARGET="$TARGET"
|
||||
export MOSAIC_CONFIG="$SANDBOX/config.json"
|
||||
|
||||
RUN_OK="r-20260903T000000000Z-ok0000001"
|
||||
mkdir -p "$SANDBOX/data/runs/$RUN_OK"
|
||||
printf '{"runVersion":1,"runId":"%s","taskId":"t-fake","status":"succeeded","workspace":"stack-repo"}' "$RUN_OK" \
|
||||
> "$SANDBOX/data/runs/$RUN_OK/result.json"
|
||||
|
||||
ws_edit() { printf '\n%s\n' "$2" >> "$WS/$1"; }
|
||||
ws_reset() { git -C "$WS" checkout -q -- . 2>/dev/null; git -C "$WS" clean -qfd; }
|
||||
target_clean() { [ -z "$(git -C "$TARGET" status --porcelain)" ]; }
|
||||
|
||||
set_policy() { # enabled suites (commits: the target tree must stay clean)
|
||||
local suites="[\"$2\"]"
|
||||
printf '{"policyVersion":1,"autoApply":{"enabled":%s,"allowedPaths":["scripts/**","docs/**","README.md"],"suites":%s}}' "$1" "$suites" \
|
||||
> "$TARGET/roles/conductor-policy.json"
|
||||
git -C "$TARGET" add roles/conductor-policy.json
|
||||
git -C "$TARGET" -c user.name=suite -c user.email=suite@local commit -q -m "policy update"
|
||||
}
|
||||
set_policy true "test-config"
|
||||
|
||||
# T1: dry run - allowed change, nothing applied
|
||||
ws_edit "README.md" "worker dry-run line"
|
||||
check_rc "dry-run: allowed change, exit 0, nothing committed" 0 \
|
||||
scripts/conductor-apply.sh "$RUN_OK" --dry-run
|
||||
if git -C "$TARGET" log --format=%s | grep -q "auto-applied"; then
|
||||
check "dry-run committed nothing" 1
|
||||
else
|
||||
check "dry-run committed nothing" 0
|
||||
fi
|
||||
ws_reset
|
||||
|
||||
# T2: apply - allowed change, suites pass, commit created
|
||||
ws_edit "README.md" "worker applied line"
|
||||
check_rc "apply: allowed change exits 0" 0 scripts/conductor-apply.sh "$RUN_OK"
|
||||
git -C "$TARGET" log -1 --format=%s | grep -q "auto-applied patch from run $RUN_OK" \
|
||||
&& check "apply: attribution in commit subject" 0 || check "apply: attribution in commit subject" 1
|
||||
target_clean() { [ -z "$(git -C "$TARGET" status --porcelain)" ]; }
|
||||
target_clean && check "apply: target tree clean after commit" 0 || check "apply: target tree clean after commit" 1
|
||||
git -C "$TARGET" reset -q --hard HEAD~1
|
||||
|
||||
# T3: disallowed path refused
|
||||
ws_edit "Containerfile" "# worker touch"
|
||||
check_rc "disallowed path refused" 1 scripts/conductor-apply.sh "$RUN_OK"
|
||||
target_clean && check "disallowed path: target untouched" 0 || check "disallowed path: target untouched" 1
|
||||
ws_reset
|
||||
|
||||
# T4: syntax gate - broken .mjs on an allowed path
|
||||
printf 'this is not (valid js\n' > "$WS/scripts/broken-worker.mjs"
|
||||
check_rc "syntax gate refused broken .mjs" 1 scripts/conductor-apply.sh "$RUN_OK"
|
||||
target_clean && check "syntax gate: target untouched" 0 || check "syntax gate: target untouched" 1
|
||||
ws_reset
|
||||
|
||||
# T5: suite failure - allowed change breaks a policy suite -> auto-revert
|
||||
printf '\nexit 7\n' >> "$TARGET/scripts/test-config.sh"
|
||||
ws_edit "README.md" "worker change that will fail suites"
|
||||
check_rc "suite failure refused" 1 scripts/conductor-apply.sh "$RUN_OK"
|
||||
git -C "$TARGET" checkout -q -- scripts/test-config.sh
|
||||
target_clean && check "suite failure: target reverted to clean" 0 || check "suite failure: target reverted to clean" 1
|
||||
|
||||
# T6: disabled policy
|
||||
set_policy false "test-config"
|
||||
ws_edit "README.md" "worker line while disabled"
|
||||
check_rc "disabled policy refused" 2 scripts/conductor-apply.sh "$RUN_OK"
|
||||
target_clean && check "disabled policy: target untouched" 0 || check "disabled policy: target untouched" 1
|
||||
ws_reset
|
||||
set_policy true "test-config"
|
||||
|
||||
# T7: failed run refused
|
||||
RUN_FAIL="r-20260903T000000000Z-fail00001"
|
||||
mkdir -p "$SANDBOX/data/runs/$RUN_FAIL"
|
||||
printf '{"runVersion":1,"runId":"%s","taskId":"t","status":"failed","workspace":"stack-repo"}' "$RUN_FAIL" \
|
||||
> "$SANDBOX/data/runs/$RUN_FAIL/result.json"
|
||||
ws_edit "README.md" "worker line from failed run"
|
||||
check_rc "failed run refused" 1 scripts/conductor-apply.sh "$RUN_FAIL"
|
||||
target_clean && check "failed run: target untouched" 0 || check "failed run: target untouched" 1
|
||||
ws_reset
|
||||
|
||||
# T8/T9: missing run + invalid policy
|
||||
check_rc "missing run exits 4" 4 scripts/conductor-apply.sh r-missing
|
||||
printf '{"policyVersion":9}' > "$TARGET/roles/conductor-policy.json"
|
||||
check_rc "invalid policy exits 2" 2 scripts/conductor-apply.sh "$RUN_OK"
|
||||
git -C "$TARGET" checkout -q -- roles/conductor-policy.json
|
||||
|
||||
echo
|
||||
echo "selftest: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -191,6 +191,51 @@ EOF
|
||||
expect_exit "retry of missing run exits 4" 4 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" node scripts/mosaic-task.mjs retry r-missing
|
||||
|
||||
# skills lifecycle (M17)
|
||||
expect_exit "skill install bundled ms-tools" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh install ms-tools
|
||||
[ -d "$SANDBOX/data/skills-available/ms-tools" ] \
|
||||
&& check "installed to skills-available" 0 || check "installed to skills-available" 1
|
||||
expect_exit "double install refuses" 1 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh install ms-tools
|
||||
expect_exit "activate enables skill" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh activate ms-tools
|
||||
[ -d "$SANDBOX/data/skills-enabled/ms-tools" ] \
|
||||
&& check "enabled dir populated" 0 || check "enabled dir populated" 1
|
||||
expect_exit "uninstall while enabled refuses" 1 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh uninstall ms-tools
|
||||
expect_exit "deactivate moves back to available" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh deactivate ms-tools
|
||||
expect_exit "uninstall removes from available" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" scripts/skill.sh uninstall ms-tools
|
||||
|
||||
# seat skills dispatch (mock evidence)
|
||||
mkdir -p "$SANDBOX/agents/skillseat" "$SANDBOX/data/user"
|
||||
printf '# User\n\nname: Jason\n' > "$SANDBOX/data/user/USER.md"
|
||||
printf '# SOUL - skillseat\n\nMethodical. Verifies before claiming.\n' > "$SANDBOX/agents/skillseat/SOUL.md"
|
||||
printf '{"agentVersion":1,"name":"skillseat","skills":["ms-tools"]}' > "$SANDBOX/agents/skillseat/agent.json"
|
||||
printf '# SOUL - skillseat\n\nMethodical. Verifies before claiming.\n' > "$SANDBOX/agents/skillseat/SOUL.md"
|
||||
cat > "$SANDBOX/mock-config.json" <<EOF
|
||||
{"configVersion":1,"environment":"development","dataRoot":"$SANDBOX/data","execution":{"backend":"docker","provider":"zai","model":"m","adapter":"mock"}}
|
||||
EOF
|
||||
REL="$(tr -d '[:space:]' < RELEASE)"
|
||||
mkdir -p "$SANDBOX/data/state"
|
||||
printf '{"pointerVersion":1,"release":"%s","imageTag":"mosaic-poc-agent:0.84.4-r%s","activatedAt":"2026-01-01T00:00:00Z"}\n' "$REL" "$REL" > "$SANDBOX/data/state/active.json"
|
||||
scripts/skill.sh install ms-tools >/dev/null 2>&1
|
||||
scripts/skill.sh activate ms-tools >/dev/null 2>&1
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-config.json" MOSAIC_MOCK_RESPONSE=MOCKED \
|
||||
env MOSAIC_AGENTS_DIR="$SANDBOX/agents" \
|
||||
scripts/agent.sh skillseat </dev/null >"$SANDBOX/seat-stdout.txt" 2>"$SANDBOX/seat-stderr.txt"
|
||||
RC=$?
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} seat with enabled skill launches"
|
||||
else
|
||||
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} seat with enabled skill launches (exit $RC)" >&2
|
||||
echo "SEATCASE stderr:" >&2; cat "$SANDBOX/seat-stderr.txt" >&2
|
||||
fi
|
||||
grep -q '^MOSAIC_SKILLS=/var/lib/mosaic/skills-enabled/ms-tools$' "$SANDBOX/seat-stderr.txt" 2>/dev/null \
|
||||
&& check "skill path delivered to adapter" 0 || check "skill path delivered to adapter" 1
|
||||
|
||||
# capability policy (M9): least-privilege intersection
|
||||
POL="$SANDBOX/data/workspaces"; mkdir -p "$POL"
|
||||
pol_run() { # missionTools(ABSENT|json) taskTools(ABSENT|json) -> stderr MOSAIC_TOOLS value
|
||||
@@ -219,6 +264,18 @@ EOF
|
||||
printf '{"missionVersion":1,"id":"m-pol","objective":"o","capabilities":{"tools":["sudo"]}}' > "$SANDBOX/pol-m.json"
|
||||
expect_exit "invalid mission capabilities rejected" 2 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" $TASK validate "$SANDBOX/pol-t.json"
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" $TASK validate "$SANDBOX/pol-t.json"
|
||||
|
||||
# live user context (M14): dispatched to every launch without rebuild
|
||||
mkdir -p "$SANDBOX/data/user"
|
||||
printf '\nUSER-CANON-MARKER\n' >> "$SANDBOX/data/user/USER.md"
|
||||
expect_exit "task run with user layer present" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOSAIC_HELLO_OK \
|
||||
scripts/run-task.sh run "$SANDBOX/ok.json"
|
||||
grep -q 'USER CONTEXT: USER.md' "$SANDBOX/data/system-prompt.md" \
|
||||
&& grep -q 'USER-CANON-MARKER' "$SANDBOX/data/system-prompt.md" \
|
||||
&& check "user context dispatched into generated prompt" 0 \
|
||||
|| check "user context dispatched into generated prompt" 1
|
||||
else
|
||||
echo "skip adapter seam cases (docker daemon unavailable)"
|
||||
fi
|
||||
@@ -305,10 +362,60 @@ process.exit(r.status === "succeeded" && r.response === "MOSAIC_HELLO_OK" && r.e
|
||||
|
||||
COUNT=$($TASK list | wc -l)
|
||||
[ "$COUNT" -ge 2 ] && check "list shows both runs" 0 || check "list shows both runs" 1
|
||||
|
||||
# session fork (M11): teach in base, fork into child, child recalls;
|
||||
# ancestor file count must be unchanged by the fork
|
||||
printf '{"taskVersion":1,"id":"t-fork-base","prompt":"Remember this code word for later: mosaico. Reply with exactly: REMEMBERED","session":"suite-base","expectExact":"REMEMBERED","timeoutSeconds":180}' > "$SANDBOX/fb.json"
|
||||
expect_exit "fork base: teach succeeds" 0 -- scripts/run-task.sh run "$SANDBOX/fb.json"
|
||||
BASECOUNT=$(ls "$SANDBOX/data/sessions/suite-base" | wc -l)
|
||||
printf '{"taskVersion":1,"id":"t-fork-child","prompt":"What code word did I ask you to remember? Reply with only the code word.","session":"suite-child","sessionForkFrom":"suite-base","timeoutSeconds":180}' > "$SANDBOX/fc.json"
|
||||
expect_exit "fork child recalls ancestor context" 0 -- scripts/run-task.sh run "$SANDBOX/fc.json"
|
||||
FR="$(ls -dt "$DATA_ROOT/runs"/r-* | head -1)"
|
||||
node -e 'const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.exit(String(r.response).toLowerCase().includes("mosaico")?0:1)' "$FR/result.json" \
|
||||
&& check "forked child recalled ancestor code word" 0 || check "forked child recalled ancestor code word" 1
|
||||
[ "$(ls "$SANDBOX/data/sessions/suite-base" | wc -l)" -eq "$BASECOUNT" ] \
|
||||
&& check "ancestor session untouched by fork" 0 || check "ancestor session untouched by fork" 1
|
||||
[ "$(ls "$SANDBOX/data/sessions/suite-child" | wc -l)" -ge 1 ] \
|
||||
&& check "child session dir has its own branch file" 0 || check "child session dir has its own branch file" 1
|
||||
else
|
||||
echo "skip live task cases (docker unavailable)"
|
||||
fi
|
||||
|
||||
# ---------- onboarding (M16): deterministic, no Docker ----------
|
||||
ONB="$SANDBOX/data"
|
||||
cat > "$SANDBOX/onb-config.json" <<EOF
|
||||
{"configVersion":1,"environment":"development","dataRoot":"$ONB","execution":{"backend":"docker","provider":"zai","model":"m"}}
|
||||
EOF
|
||||
|
||||
expect_exit "onboard without name exits 4 (non-interactive)" 4 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/onb-config.json" scripts/onboard.sh --profession x
|
||||
expect_exit "onboard --name renders profile" 0 -- \
|
||||
env MOSAIC_CONFIG="$SANDBOX/onb-config.json" scripts/onboard.sh --name "Jason" --timezone "Europe/Berlin"
|
||||
[ -f "$ONB/user/USER.md" ] \
|
||||
&& check "profile written" 0 || check "profile written" 1
|
||||
grep -q "name: Jason" "$ONB/user/USER.md" \
|
||||
&& grep -q "(not provided)" "$ONB/user/USER.md" \
|
||||
&& check "canon structure: required filled, optional placeholdered" 0 \
|
||||
|| check "canon structure: required filled, optional placeholdered" 1
|
||||
grep -q "## Skillset" "$ONB/user/USER.md" && grep -q "## Pets" "$ONB/user/USER.md" \
|
||||
&& check "canon sections present" 0 || check "canon sections present" 1
|
||||
|
||||
# Live recall: real pi, real phrasing - assert containment, not exactness
|
||||
printf '{"taskVersion":1,"id":"t-user","prompt":"What is the user name? Reply with only the name.","session":"onb-check","timeoutSeconds":180}' > "$SANDBOX/recall-task.json"
|
||||
RC=0
|
||||
scripts/run-task.sh run "$SANDBOX/recall-task.json" >/dev/null 2>&1 || RC=$?
|
||||
[ "$RC" -eq 0 ] && check "user recall run succeeds" 0 || check "user recall run succeeds (exit $RC)" 1
|
||||
FR="$(ls -dt "$SANDBOX/data/runs"/r-* | head -1)"
|
||||
RESP="$(node -e 'try{const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.response||"")}catch{console.log("")}' "$FR/result.json" 2>/dev/null)"
|
||||
echo "$RESP" | grep -qi "jason" \
|
||||
&& check "recalled user name (response: $RESP)" 0 || check "recalled user name (response: $RESP)" 1
|
||||
# headless run must NOT carry an agent identity section
|
||||
! grep -q 'AGENT IDENTITY' "$SANDBOX/data/system-prompt.md" 2>/dev/null \
|
||||
&& check "no agent identity on headless run" 0 || check "no agent identity on headless run" 1
|
||||
|
||||
git -C "$SANDBOX/repo" rev-parse >/dev/null 2>&1 || true
|
||||
unset MOSAIC_CONFIG
|
||||
|
||||
echo
|
||||
echo "selftest: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
|
||||
@@ -30,6 +30,7 @@ IMAGE="$MOSAIC_IMAGE_TAG"
|
||||
# Ensure the configured data root exists (host-owned) before the mount,
|
||||
# otherwise Docker would auto-create a root-owned directory.
|
||||
bootstrap_runtime_dir
|
||||
ensure_release_aligned
|
||||
|
||||
# 1. Build the image only if it is not already present.
|
||||
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: ms-agent-watch
|
||||
description: Use for all instances where a watch, wait, or agentic status check is needed. This skill avoid the need for wait cycles and other methods used to wait for an action outcome.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-agent-watch — self-armed condition watches
|
||||
|
||||
One CLI call arms an isolated `systemd --user` transient timer per watch. Each
|
||||
tick is a fresh, cgroup-isolated process; no loop, no script, no orphan. You
|
||||
never `sleep`, never write a watch script, never background anything.
|
||||
|
||||
## Tool
|
||||
|
||||
```bash
|
||||
tools/agent-watch/agent-watch.sh start \
|
||||
--name <lowercase-id> --session <your-tmux-session> \
|
||||
[--socket <tmux-socket>] # REQUIRED for mosaic-fleet seats using tmux
|
||||
--when '<shell command>' # exit 0 = met; quote it
|
||||
--message "text delivered to you" \
|
||||
[--class actionable|reaction|human|digest|terminal-log] \
|
||||
[--interval 30] [--timeout 3600] [--repeat] [--quiet-timeout]
|
||||
|
||||
agent-watch.sh list # your host's watches
|
||||
agent-watch.sh log <name> # what it did and why
|
||||
agent-watch.sh stop <name> # retire + clean state
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Name it for the thing watched** (`ci-pr1350-green`, `peer-orch-01-reply`),
|
||||
not for yourself — names appear in `[watch:<name>]` message prefixes and
|
||||
in `systemctl list-timers 'agent-watch-*'`.
|
||||
2. **Interval floor is 10s.** A watcher is a fallback cadence. If you find
|
||||
yourself wanting 1s polls, the real fix is an event, not a faster watch.
|
||||
3. **Timeout is real** (default 1h): on expiry you get one terminal-log note
|
||||
(unless `--quiet-timeout`) and the watch retires. A watch is never forever;
|
||||
re-arm deliberately if the condition is still pending.
|
||||
4. **Conditions are cron-style**: clean-ish environment, `cwd=$HOME`. Use
|
||||
absolute paths. Do NOT rely on ambient credentials — resolve tokens through
|
||||
the git credential helper or absolute service-credential paths.
|
||||
5. **rc=2 delivery is DELIVERED** (text reached your pane as a draft — FLEET-COMMS
|
||||
E7); the watcher never retries it. Real failures retry twice then retire loudly.
|
||||
6. **A broken condition (exit ≠ 0/1) retires the watch** with a terminal-log
|
||||
note. Check `log <name>` before re-arming — re-arming a broken condition
|
||||
without fixing it just burns another timeout.
|
||||
7. **`--repeat` re-arms after every delivery.** Default is one-shot on purpose:
|
||||
each watch should correspond to one pending fact.
|
||||
8. **Retire your watches** when the mission closes (`stop`). `list` shows
|
||||
stale-state entries whose timer is gone; stop removes those too.
|
||||
9. Fleet seats MUST pass `--socket mosaic-fleet` — the default is the default
|
||||
socket and the delivery will not reach you.
|
||||
|
||||
## When NOT to watch
|
||||
|
||||
- Waiting on another SEAT: send them a message (agent-send / comms) instead —
|
||||
a watch polling their output is a loop wearing a hat.
|
||||
- Waiting on yourself: just do the next thing.
|
||||
- Something that already has a wake path (fleet watcher injects comms
|
||||
automatically): those arrive without any watch.
|
||||
|
||||
## Failure model (what you'll see)
|
||||
|
||||
| Symptom | Meaning |
|
||||
|---|---|
|
||||
| `started watcher ...` then nothing, timer inactive | condition broke (exit ≠ 0/1) or timeout hit — `log <name>` says which |
|
||||
| `[watch:x] ... timeout after Ns` | retired; re-arm only if still relevant |
|
||||
| delivered (rc=2) in log | delivered as draft into your pane — go read the pane |
|
||||
| delivery failed rc=1 after 3 attempts | your session was gone; restart it, then re-arm |
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: ms-agent
|
||||
description: Use for ALL agent operation cycles.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-agent
|
||||
|
||||
Agent operation cycles: launch, work, verify, persist, hand off.
|
||||
|
||||
## Cycle
|
||||
|
||||
1. Launch: `scripts/agent.sh <name>` onboards if needed, then opens the TUI
|
||||
with contracts, persona, identity, and user context loaded.
|
||||
2. Work inside your workspace. Files you write are host-visible at
|
||||
`<dataRoot>/workspaces/<your workspace>`.
|
||||
3. Your session persists in `sessions/<name>`. Relaunching the same seat
|
||||
resumes where you left off.
|
||||
4. Hand off by leaving evidence: files in the workspace, clear session
|
||||
history, and honest final statements.
|
||||
|
||||
## Rules
|
||||
|
||||
- One seat, one identity. Never share sessions or workspaces across seats.
|
||||
- Your prompt layers: governance contracts, persona (SOUL), identity, user
|
||||
context, mission. Later layers refine earlier ones. Governance is never
|
||||
overridden.
|
||||
- If a capability you need is missing, say so. Do not improvise a capability
|
||||
you were not granted.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: ms-conductor
|
||||
description: Use this skill when performing Conducting tasks.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-conductor
|
||||
|
||||
Conducting discipline: direct workers without being one.
|
||||
|
||||
## Order of operations
|
||||
|
||||
1. Decompose the goal into worker tasks small enough to spec completely in
|
||||
one prompt: goal, files, constraints, acceptance, self-checks.
|
||||
2. Dispatch through the task runner. Never raw pi; never a shell one-liner.
|
||||
3. Extract the worker's diff. Review it line by line before integration.
|
||||
4. Verify with the suites. A failure reverts; the refusal is recorded.
|
||||
5. Integrate with attribution. Update the plan and registry.
|
||||
|
||||
## Gotcha ledger
|
||||
|
||||
- Sequential dependent calls. Verify a write before claiming it done.
|
||||
- Pre-check every path before passing it to a tool. Missing paths fail
|
||||
silently in some consumers.
|
||||
- Auth and symlink ensure before TUI launch. Missing auth falls back to
|
||||
defaults silently.
|
||||
- Post-reset: the release pointer is gone and onboarding reruns. Both are
|
||||
expected; align releases with `release.sh ensure`.
|
||||
- A worker that passes for the wrong reason is a masking failure. Assert
|
||||
reasons, not just exit codes.
|
||||
|
||||
## Refusals
|
||||
|
||||
Refuse rather than guess. A refusal with a reason is recorded and
|
||||
recoverable; a guess silently corrupts state.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: ms-file-read
|
||||
description: Use this skill when reading any file.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-file-read
|
||||
|
||||
Read files before you act on them. Never act on a filename alone.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Read the file before editing, summarizing, or deciding anything about it.
|
||||
2. Large files: read in chunks with offset and limit instead of dumping.
|
||||
3. Verify what you read matches what you expected before building on it.
|
||||
4. Never read credential material (auth files, tokens, keys). The name is
|
||||
warning enough.
|
||||
|
||||
## When
|
||||
|
||||
- Before any edit: the edit must match what is actually on disk.
|
||||
- Before answering questions about file contents.
|
||||
- When a run record, log, or receipt is cited as evidence.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: ms-file-write
|
||||
description: Use this skill when writing any file.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-file-write
|
||||
|
||||
Write files so the write is provable and reversible.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Write to a temp file and rename for atomic replacement of an existing file.
|
||||
2. Verify the write: re-read or checksum before claiming success.
|
||||
3. Never overwrite a file that carries identity or history (seat records,
|
||||
run records, logs). They are append-only or write-once for a reason.
|
||||
4. No secrets in written files. Ever.
|
||||
5. Match the file's existing style. Do not reformat regions you did not touch.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: ms-tools
|
||||
description: Reference for all available tools in Mosaic Stack.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-tools
|
||||
|
||||
You are a Mosaic seat agent. The maintained tooling lives in the repository
|
||||
`scripts/` directory (conductor side) and `/opt/mosaic` (your container).
|
||||
Use it FIRST for the tasks below. Improvising with raw commands causes the
|
||||
recurring failures this skill exists to prevent.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- scripts/bootstrap.sh creates the system config. Idempotent, never overwrites.
|
||||
- scripts/build.sh builds the release image. Tag comes from RELEASE.
|
||||
- scripts/verify.sh runs the gated startup check. Exit 0 means MOSAIC_HELLO_OK.
|
||||
- scripts/release.sh ensure aligns what is installed with RELEASE. No manual
|
||||
release commands; drift is detected and corrected at launch.
|
||||
|
||||
## Tasks and runs
|
||||
|
||||
- scripts/run-task.sh run <task.json> executes a governed task. Every run
|
||||
leaves a write-once record under <dataRoot>/runs/.
|
||||
- node scripts/mosaic-task.mjs list shows run history.
|
||||
- node scripts/mosaic-task.mjs show <runId> inspects one run: receipts,
|
||||
snapshots, stderr.
|
||||
- node scripts/mosaic-task.mjs retry <runId> re-executes a recorded task as
|
||||
a new run. The old record stays.
|
||||
|
||||
## Inter-agent communications
|
||||
|
||||
- tools/tmux/agent-send.sh # Send a tmux message to an agent launched in tmux
|
||||
|
||||
## Seats
|
||||
|
||||
- scripts/agent.sh <name> launches an interactive seat. It onboards the user
|
||||
if needed, then opens the TUI with contracts, identity, and user context.
|
||||
- agents/<name>/ holds a seat definition: agent.json plus SOUL.md.
|
||||
|
||||
## Rules
|
||||
|
||||
- Exit codes: 0 ok, 1 failed, 2 invalid input, 3 config missing, 4 usage.
|
||||
- A refusal is evidence. Diagnose it; do not route around it.
|
||||
- Full reference: docs/TOOLS.md.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: ms-unslop
|
||||
description: Cut AI tells from any writing. Must always apply.
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# Unslop
|
||||
|
||||
Edit text to remove AI patterns and add human voice.
|
||||
|
||||
## Process
|
||||
|
||||
1. Scan for the patterns below.
|
||||
2. Rewrite. Preserve meaning, match intended tone.
|
||||
3. Add soul (see next section).
|
||||
4. Self-audit: "What makes this obviously AI generated?" Fix remaining tells.
|
||||
|
||||
## Adding soul
|
||||
|
||||
Removing patterns is half the job. Sterile, voiceless writing is just as obvious.
|
||||
|
||||
- **Have opinions.** React to facts instead of neutrally listing pros and cons.
|
||||
- **Vary rhythm.** Short sentences. Then longer ones that take their time. Mix it up.
|
||||
- **Acknowledge complexity.** "Impressive but also kind of unsettling" beats "impressive."
|
||||
- **Use "I" when it fits.** First person isn't unprofessional.
|
||||
- **Let some mess in.** Perfect structure looks machine-made.
|
||||
- **Be specific.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am."
|
||||
|
||||
## Patterns to detect and fix
|
||||
|
||||
### Content
|
||||
|
||||
1. **Puffery.** `pivotal moment`, `testament to`, "evolving landscape", "setting the stage for", "indelible mark", "deeply rooted". Cut puffery, state what happened.
|
||||
2. **Name-dropping.** Listing media outlets without context. Pick one, say what was said.
|
||||
3. **Superficial -ing phrases.** "highlighting...", "ensuring...", "reflecting...", "showcasing...", "fostering...". Delete or expand with real sources.
|
||||
4. **Promotional language.** "nestled", `vibrant`, "breathtaking", "groundbreaking", "renowned", "stunning", "must-visit". Use neutral descriptions.
|
||||
5. **Vague attributions.** "Experts believe", "Industry reports suggest", "Some critics argue". Name the source or delete.
|
||||
6. **Formulaic challenges.** "Despite challenges... continues to thrive." Replace with specific facts.
|
||||
|
||||
### Language
|
||||
|
||||
7. **AI vocabulary.** `Additionally`, `crucial`, `delve`, `enduring`, `enhance`, `fostering`, `garner`, `interplay`, `intricate`, `landscape` (abstract), `pivotal`, `showcase`, `tapestry` (abstract), `testament`, `underscore`, `vibrant`. Replace with plain words.
|
||||
8. **Fancy ways to say "is".** "serves as", "stands as", "boasts", "features". Just say "is" or "has".
|
||||
9. **`Not just X, but Y`.** State the point directly instead.
|
||||
10. **Rule of three.** Forcing ideas into groups of three. Use the natural number.
|
||||
11. **Synonym cycling.** Protagonist, main character, central figure, hero all in one paragraph. Pick one, repeat it.
|
||||
12. **False ranges.** "from X to Y" where X and Y aren't on a meaningful scale. List topics directly.
|
||||
|
||||
### Style
|
||||
|
||||
13. **Em dash overuse.** Avoid em dashes entirely. Use periods or commas only (no parentheses, no en dashes, no hyphen-as-dash substitutes). Em dashes are an AI tell, and reaching for parentheses instead just trades one tell for another. If a thought needs separation, end the sentence or use a comma.
|
||||
14. **Colon overuse.** Colons are fine before a list or example. Not as mid-sentence connectors. "If you're coming from traditional automation: instead of registering event handlers, you describe conditions" adds nothing with the colon. Rewrite to let the point stand on its own without comparison framing. "Describing when the scheduler should fire works best as plain English." Same meaning, no crutch punctuation.
|
||||
15. **Boldface overuse.** Don't bold every proper noun or acronym.
|
||||
16. **Inline-header lists.** The tell is a bold label and colon that restates the line: "**Performance:** Performance improved...". Convert those to prose. A bold lead-in that ends in a period, names the item, and is followed by genuinely new detail ("**Schema in TypeScript.** Tables live in one file.") is fine, not a tell.
|
||||
17. **Title case headings.** Use sentence case.
|
||||
18. **Decorative emojis.** Remove from headings and bullets.
|
||||
19. **Curly quotes.** Replace with straight quotes.
|
||||
|
||||
### Communication artifacts
|
||||
|
||||
20. **Chatbot phrases.** `I hope this helps!`, `Let me know if...`, `Of course!`, `Certainly!`, `Found the smoking gun!` Remove.
|
||||
21. **Cutoff disclaimers.** "While specific details are limited..." Find sources or remove.
|
||||
22. **Sycophantic tone.** `Great question!` `You're absolutely right!` Respond directly.
|
||||
|
||||
### Filler
|
||||
|
||||
23. **Filler phrases.** `In order to` becomes "To". `Due to the fact that` becomes "Because". `It is important to note that` gets deleted.
|
||||
24. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may".
|
||||
25. **Generic conclusions.** "The future looks bright." State specific plans or facts.
|
||||
|
||||
### Jargon
|
||||
|
||||
26. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word.
|
||||
|
||||
### Plain speech
|
||||
|
||||
27. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write that. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it.
|
||||
28. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence.
|
||||
29. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter.
|
||||
30. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong.
|
||||
31. **Prefer the plain word.** `utilize` becomes "use", `leverage` becomes "use", `facilitate` becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer.
|
||||
|
||||
## Mention convention
|
||||
|
||||
A document that MENTIONS a banned word or phrase quotes it as inline code. The checker (`tools/unslop-hook/unslop-check.js`, machine source `tools/unslop-hook/lists.json`) strips code spans before matching, so a backticked mention is invisible to the gate while a bare one flags. This file follows that convention and doubles as a regression fixture: if `unslop-check.js` ever flags this file, either an edit broke the mention convention or code stripping regressed. Documents that deliberately CONTAIN slop to test detection (fixture files) are uses, not mentions; they are expected to flag.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: ms-user
|
||||
description: Always use to update recorded information about the user
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# ms-user
|
||||
|
||||
The user context layer holds user-owned information dispatched to every
|
||||
agent launch: everything under `<dataRoot>/user/`, with `USER.md` as the
|
||||
profile.
|
||||
|
||||
## Rules
|
||||
|
||||
1. The user owns the content. It is injected into your system prompt when
|
||||
present; verify it on disk before editing.
|
||||
2. Always check for the `<dataRoot>/user/USER.md` file before attempting
|
||||
to update info.
|
||||
3. If the USER.md file is missing, guide the user through onboarding
|
||||
(`scripts/onboard.sh`) or, with their consent, create a minimal
|
||||
template (name only) and let them fill in the rest.
|
||||
4. Initial configuration uses `scripts/onboard.sh`
|
||||
(guided, name required) or the user's own edits.
|
||||
Agents propose; the user decides and authorizes; once authorized,
|
||||
the agent performs the edit.
|
||||
5. NEVER place secrets in the user layer. Everything under
|
||||
`<dataRoot>/user/` is dispatched to every agent and worker, so keep
|
||||
near-secrets out too: home address, finances, anything you would not
|
||||
hand a stranger with shell access.
|
||||
6. Propose every change to the user layer, new content or new section
|
||||
alike, and apply it only after an explicit yes.
|
||||
7. Propose updates proactively as information about the user is learned.
|
||||
8. The USER.md file is not limited to the provided template fields.
|
||||
Add sections as needed.
|
||||
9. Never block work on proposed updates to the user information files.
|
||||
Note the proposed additions in a scratch file outside
|
||||
`<dataRoot>/user/` (unconfirmed content must not be dispatched),
|
||||
then confirm with the user at a better time.
|
||||
+47
-4
@@ -1,14 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Load the four immutable contract files in fixed order and write the
|
||||
# generated system prompt to /var/lib/mosaic/system-prompt.md.
|
||||
# Load agent context and write the generated system prompt to
|
||||
# /var/lib/mosaic/system-prompt.md.
|
||||
#
|
||||
# Order is normative: CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md.
|
||||
# Layers, in normative order:
|
||||
# 1. Immutable contracts (image): CONSTITUTION, STANDARDS, SOUL
|
||||
# 2. Agent identity (when the launcher names the agent)
|
||||
# 3. Mission (when the task/launcher provides one)
|
||||
# 4. Live user context (M14): <dataRoot>/user/*.md - user-owned,
|
||||
# dispatched to every launch without rebuilds
|
||||
set -eu
|
||||
|
||||
CONTRACT_DIR="${1:-/opt/mosaic/contracts}"
|
||||
OUT="${2:-/var/lib/mosaic/system-prompt.md}"
|
||||
|
||||
FILES="CONSTITUTION.md STANDARDS.md SOUL.md USER.md"
|
||||
FILES="CONSTITUTION.md STANDARDS.md"
|
||||
|
||||
# SOUL slot (M15): the contract SOUL.md is the DEFAULT persona; a launched
|
||||
# agent seat overrides it with its own runtime SOUL (governance contracts
|
||||
# are never overridden).
|
||||
SOUL_SRC="$CONTRACT_DIR/SOUL.md"
|
||||
SOUL_HEADER="SOUL.md"
|
||||
if [ -n "${MOSAIC_AGENT_SOUL_FILE:-}" ]; then
|
||||
if [ ! -r "$MOSAIC_AGENT_SOUL_FILE" ]; then
|
||||
echo "load-contracts: agent SOUL not readable: $MOSAIC_AGENT_SOUL_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
SOUL_SRC="$MOSAIC_AGENT_SOUL_FILE"
|
||||
SOUL_HEADER="SOUL.md (agent seat override)"
|
||||
fi
|
||||
|
||||
if [ ! -d "$CONTRACT_DIR" ]; then
|
||||
echo "load-contracts: contract directory not found: $CONTRACT_DIR" >&2
|
||||
@@ -33,6 +52,30 @@ for f in $FILES; do
|
||||
printf '\n' >> "$TEMP"
|
||||
done
|
||||
|
||||
printf '===== CONTRACT: %s =====\n' "$SOUL_HEADER" >> "$TEMP"
|
||||
cat "$SOUL_SRC" >> "$TEMP"
|
||||
printf '\n' >> "$TEMP"
|
||||
|
||||
# Agent identity (M13): when the launcher names the agent, the generated
|
||||
# prompt states it - SOUL.md provides the persona, this provides the name.
|
||||
if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then
|
||||
printf '===== AGENT IDENTITY =====\n' >> "$TEMP"
|
||||
printf 'agent name: %s\n' "$MOSAIC_AGENT_NAME" >> "$TEMP"
|
||||
[ -n "${MOSAIC_AGENT_ROLE:-}" ] && printf 'agent role: %s\n' "$MOSAIC_AGENT_ROLE" >> "$TEMP"
|
||||
printf '\n' >> "$TEMP"
|
||||
fi
|
||||
|
||||
# Live user context (M14): every *.md in /var/lib/mosaic/user (sorted) is
|
||||
# appended - the user owns this layer and edits it without rebuilds.
|
||||
USER_DIR="/var/lib/mosaic/user"
|
||||
if [ -d "$USER_DIR" ]; then
|
||||
for f in $(ls "$USER_DIR"/*.md 2>/dev/null | sort); do
|
||||
printf '===== USER CONTEXT: %s =====\n' "$(basename "$f")" >> "$TEMP"
|
||||
cat "$f" >> "$TEMP"
|
||||
printf '\n' >> "$TEMP"
|
||||
done
|
||||
fi
|
||||
|
||||
# Sanctioned mission injection point (M4): when the task runner provides a
|
||||
# mission snapshot, its objective and directives are appended AFTER the
|
||||
# immutable contracts. Runtime data; never part of the contract fixtures.
|
||||
|
||||
+15
-7
@@ -1,13 +1,22 @@
|
||||
#!/bin/sh
|
||||
# One-shot agent dispatcher inside the container.
|
||||
# Agent dispatcher inside the container.
|
||||
#
|
||||
# 1. Loads the contract-generated system prompt (contracts + optional
|
||||
# mission section from MOSAIC_MISSION_FILE).
|
||||
# 2. Dispatches to /opt/mosaic/adapters/<MOSAIC_ADAPTER>/adapter.sh per
|
||||
# the contract in /opt/mosaic/adapters/README.md.
|
||||
# Headless (default): loads the contract-generated system prompt, then
|
||||
# dispatches one request to /opt/mosaic/adapters/<MOSAIC_ADAPTER>/adapter.sh
|
||||
# (contract: /opt/mosaic/adapters/README.md).
|
||||
#
|
||||
# Interactive (MOSAIC_INTERACTIVE=1, from scripts/agent.sh): same prompt,
|
||||
# but the adapter opens the full pi TUI with no initial prompt - the human
|
||||
# drives from there.
|
||||
set -eu
|
||||
|
||||
REQUEST="${*:-Return your startup marker and nothing else.}"
|
||||
REQUEST=""
|
||||
if [ "${MOSAIC_INTERACTIVE:-}" != "1" ]; then
|
||||
# Headless: args are the request; default is the startup verification
|
||||
# request used by hello/verify.
|
||||
REQUEST="${*:-Return your startup marker and nothing else.}"
|
||||
export MOSAIC_REQUEST="$REQUEST"
|
||||
fi
|
||||
|
||||
ADAPTER="${MOSAIC_ADAPTER:-pi}"
|
||||
case "$ADAPTER" in
|
||||
@@ -28,6 +37,5 @@ fi
|
||||
/opt/mosaic/src/load-contracts.sh /opt/mosaic/contracts /var/lib/mosaic/system-prompt.md
|
||||
|
||||
export MOSAIC_SYSTEM_PROMPT_FILE="/var/lib/mosaic/system-prompt.md"
|
||||
export MOSAIC_REQUEST="$REQUEST"
|
||||
|
||||
exec "$ADAPTER_SCRIPT"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# User
|
||||
|
||||
name: {{NAME}}
|
||||
profession: {{PROFESSION}}
|
||||
location: {{LOCATION}}
|
||||
timezone: {{TIMEZONE}}
|
||||
|
||||
## Demographics
|
||||
|
||||
marital status: {{MARITAL_STATUS}}
|
||||
age: {{AGE}}
|
||||
gender: {{GENDER}}
|
||||
education: {{EDUCATION}}
|
||||
|
||||
## Skillset
|
||||
|
||||
{{SKILLSET}}
|
||||
|
||||
## Interests
|
||||
|
||||
{{INTERESTS}}
|
||||
|
||||
## Hobbies
|
||||
|
||||
{{HOBBIES}}
|
||||
|
||||
## Pets
|
||||
|
||||
{{PETS}}
|
||||
Executable
+730
@@ -0,0 +1,730 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-watch.sh — isolated condition watcher for fleet agents (v2: systemd timers).
|
||||
#
|
||||
# Each watch is its own transient systemd --user timer + service (cron-style):
|
||||
# no loop process, no orphan risk, no host-reboot fragility (v1's nohup model
|
||||
# died at reboot), cgroup-isolated, journald-logged. Agents never hand-write
|
||||
# watch scripts or sleep loops — a watch is one CLI call.
|
||||
#
|
||||
# Usage:
|
||||
# agent-watch.sh start --name <id> --session <tmux-session> \
|
||||
# --when '<shell command; true = met>' --message "text to send" \
|
||||
# [--class actionable|reaction|human|digest|terminal-log] \
|
||||
# [--interval 30] [--timeout 3600] [--repeat] [--quiet-timeout] \
|
||||
# [--socket <tmux-socket>] # e.g. mosaic-fleet for fleet seats
|
||||
# agent-watch.sh list
|
||||
# agent-watch.sh status [--json] # exit 0 = clean, 3 = any stale watch or dead meta-watch, 6 = user bus unreachable
|
||||
# agent-watch.sh stop <name>
|
||||
# agent-watch.sh log <name>
|
||||
# agent-watch.sh meta-install [--interval 300] [--unit-name agent-watch-meta]
|
||||
# agent-watch.sh meta-remove [--unit-name agent-watch-meta]
|
||||
#
|
||||
# Rules encoded (guides/FLEET-COMMS.md, WAKE-DOCTRINE.md):
|
||||
# - Interval floor 10s: a watcher is a fallback cadence, never a tight poll.
|
||||
# - Delivery via agent-send.sh only; rc=2 = reached the pane as draft =
|
||||
# DELIVERED, never retried. Real failures retry twice, then give up loudly.
|
||||
# - Conditions run CRON-STYLE: clean-ish env (HOME/PATH/MOSAIC_* pass
|
||||
# through), cwd=$HOME. Do not rely on ambient credentials; use absolute
|
||||
# paths and the credential helper.
|
||||
# - Messages carry [watch:<name>] so the recipient can trace or stop them.
|
||||
# - One-shot by default; --repeat re-arms after each delivery.
|
||||
# - Timeout (default 3600s): terminal-log note unless --quiet-timeout,
|
||||
# then the timer is cancelled. A watch is never forever; re-arm deliberately.
|
||||
# - State: $STATE_ROOT/<name>/ (config, log). Units: agent-watch-<name>.{timer,service}
|
||||
# - Expected deaths are marked: completion, timeout, and broken-condition
|
||||
# paths write a `terminated` marker BEFORE stopping the timer, so those
|
||||
# watches show as retired (owed nothing), never as stale.
|
||||
# - Stale = config present, timer gone, no terminated marker: unexpected
|
||||
# loss. list/status deliver AT MOST ONE notice per staleness episode: a
|
||||
# noclobber claim on stale-noticed admits exactly one of N concurrent
|
||||
# callers; a crash between claim and send can drop that episode's notice
|
||||
# (staleness stays visible in status output / exit 3 regardless). The
|
||||
# stale claim clears on re-arm (start) and observed recovery; the
|
||||
# terminated marker clears on re-arm only — activity during a stop
|
||||
# window must not erase it (T16W2 race). D62 CLOSED by the meta-watch
|
||||
# (T19): a persistent, ENABLED systemd user timer runs the hidden
|
||||
# `_scan` path on a fixed cadence — the SAME classification and
|
||||
# claim-first notice logic list/status use — so a lost watch is noticed
|
||||
# without anyone querying. Each scan stamps a heartbeat file; list and
|
||||
# status report meta health (timer active + heartbeat age vs cadence),
|
||||
# so a dead meta-watch is visible on the existing query surface instead
|
||||
# of silently recreating D62 one level up. status is the machine-readable
|
||||
# liveness answer (JSON or human; retired watches are listed separately,
|
||||
# exit 3 when any watch is stale OR an installed meta-watch is dead).
|
||||
# - Ambiguous unpinned socket resolution refuses with rc 4; pass --socket
|
||||
# or set MOSAIC_TMUX_SOCKET to choose deliberately.
|
||||
# - No user bus (XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS unset or bus
|
||||
# unreachable): systemctl --user fails with EMPTY output, which a naive
|
||||
# classifier reads as "timer gone". Every classifying query (list /
|
||||
# status / _scan / start / meta-install) refuses loudly instead — rc 6,
|
||||
# diagnostic naming the bus, zero claims, zero notices (T24).
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="2.1.2"
|
||||
SEND="${MOSAIC_AGENT_SEND:-$HOME/.config/mosaic/tools/tmux/agent-send.sh}"
|
||||
STATE_ROOT="${MOSAIC_WATCH_STATE:-$HOME/.cache/mosaic-agent-watch}"
|
||||
FLOOR_INTERVAL=10
|
||||
CLASSES="actionable|reaction|human|digest|terminal-log"
|
||||
UNIT_PREFIX="agent-watch"
|
||||
META_UNIT_DEFAULT="agent-watch-meta"
|
||||
META_FLOOR_INTERVAL=60 # meta cadence floor: a detection net, never a poll
|
||||
RC_NO_BUS=6 # T24: user bus unreachable — classify nothing, notify nothing
|
||||
|
||||
die() { echo "agent-watch: $*" >&2; exit 2; }
|
||||
usage() { sed -n '2,/^set -euo pipefail/{/^set -euo pipefail/d;p}' "$0" | sed 's/^# \{0,1\}//'; }
|
||||
|
||||
# T24 (measured live 2026-08-23 03:33): a caller without the user bus gets a
|
||||
# FAILING systemctl --user whose empty output the classifier read as "timer
|
||||
# gone" — every live watch classified LOST, false claims written, false
|
||||
# notices delivered (self-healed only via T16W2 alive-release, masking real
|
||||
# losses in the window). An unreachable bus is a broken instrument, not a
|
||||
# fleet of dead watches: systemctl's own failure (rc!=0, EMPTY stdout) is
|
||||
# distinguishable from an empty-but-successful query (rc!=0 with TEXT like
|
||||
# "inactive" on stdout). Every path that classifies probes first and
|
||||
# refuses to classify at all on failure.
|
||||
require_user_bus() {
|
||||
local err rc=0
|
||||
err="$(systemctl --user show-environment 2>&1 1>/dev/null)" || rc=$?
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
{
|
||||
echo "agent-watch: systemd user bus UNREACHABLE — refusing to classify watch liveness (rc $RC_NO_BUS)"
|
||||
echo "agent-watch: systemctl --user show-environment failed rc=$rc: ${err}"
|
||||
echo "agent-watch: no claims written, no notices sent; a failed query is NOT 'no timers'."
|
||||
echo "agent-watch: fix the caller: export XDG_RUNTIME_DIR=/run/user/$(id -u) DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus"
|
||||
} >&2
|
||||
exit "$RC_NO_BUS"
|
||||
fi
|
||||
}
|
||||
state_dir() { echo "$STATE_ROOT/$1"; }
|
||||
unit_for() { echo "$UNIT_PREFIX-$1"; }
|
||||
|
||||
say_log() { # per-watch durable log (journal also carries it, but `log` reads this)
|
||||
local dir; dir="$(state_dir "$1")"; mkdir -p "$dir"
|
||||
echo "[$(date -u +%FT%TZ)] ${2:-}" >> "$dir/watch.log"
|
||||
}
|
||||
|
||||
# Terminal marker (review B1): every intentional stop path records WHY the
|
||||
# timer is about to vanish, BEFORE the stop. Stale detection (config present +
|
||||
# timer gone) ignores markered watches: expected deaths are owed nothing.
|
||||
# Writing pre-stop closes the race: a detector can never observe timer-gone
|
||||
# with the marker still absent.
|
||||
mark_terminal() { # $1=name $2=reason
|
||||
printf '%s\n' "$2" > "$(state_dir "$1")/terminated"
|
||||
say_log "$1" "terminal: $2"
|
||||
}
|
||||
|
||||
# ── start ─────────────────────────────────────────────────────────────────────
|
||||
cmd_start() {
|
||||
local name="" session="" when="" message="" cls="actionable" socket=""
|
||||
local interval=30 timeout=3600 repeat=0 quiet=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--session) session="$2"; shift 2 ;;
|
||||
--socket) socket="$2"; shift 2 ;;
|
||||
--when) when="$2"; shift 2 ;;
|
||||
--message) message="$2"; shift 2 ;;
|
||||
--class) cls="$2"; shift 2 ;;
|
||||
--interval) interval="$2"; shift 2 ;;
|
||||
--timeout) timeout="$2"; shift 2 ;;
|
||||
--repeat) repeat=1; shift ;;
|
||||
--quiet-timeout) quiet=1; shift ;;
|
||||
*) die "start: unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$name" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "start: --name must be lowercase-hyphens (got: '$name')"
|
||||
[[ "$name" != *.* ]] || die "start: --name must not contain dots (systemd unit naming)"
|
||||
[[ -n "$session" ]] || die "start: --session is required"
|
||||
|
||||
# Pin the target to an EXACT session name. Without the leading `=`, tmux
|
||||
# PREFIX-matches and returns rc=0 on the wrong session. Measured 2026-08-24
|
||||
# on this host: `jarvis` and `jarvis-enhance` are a prefix pair split across
|
||||
# two servers, so on the default server `-t jarvis` resolves to
|
||||
# `jarvis-enhance` at rc=0 today -- no dead seat required. Normalising here
|
||||
# rather than at each use covers the guard below AND all four fire-time
|
||||
# `$SEND -s "$SESSION"` sites, because send-message.sh already expands
|
||||
# `=sess` to a pane-qualified target. Preserve an already-pinned session,
|
||||
# and pin only its session component for compound `session:window.pane`
|
||||
# targets. This keeps discovery from constructing `==session:window.pane`.
|
||||
case "$session" in
|
||||
=*) ;;
|
||||
*:*) session="=${session%%:*}:${session#*:}" ;;
|
||||
*) session="=$session" ;;
|
||||
esac
|
||||
[[ -n "$when" ]] || die "start: --when is required (quoted shell command; exit 0 = met)"
|
||||
[[ -n "$message" ]] || die "start: --message is required"
|
||||
[[ "$cls" =~ ^($CLASSES)$ ]] || die "start: --class must be one of: $CLASSES"
|
||||
[[ "$interval" =~ ^[0-9]+$ ]] || die "start: --interval must be a number"
|
||||
[[ "$interval" -ge "$FLOOR_INTERVAL" ]] || die "start: --interval floor is ${FLOOR_INTERVAL}s (got ${interval}s) — no tight polls"
|
||||
[[ "$timeout" =~ ^[0-9]+$ ]] || die "start: --timeout must be a number (seconds)"
|
||||
[[ -x "$SEND" ]] || die "sender not found/executable: $SEND"
|
||||
command -v systemctl >/dev/null 2>&1 || die "systemctl not on PATH (v2 requires systemd --user)"
|
||||
require_user_bus # T24: fail naming the bus, not as a cryptic systemd-run error
|
||||
# B1 (2026-08-29): socket default resolution. Precedence: explicit
|
||||
# --socket > MOSAIC_TMUX_SOCKET (launcher-exported) > unique socket hit
|
||||
# > refusal on ambiguity. Socket discovery scans tmux's own socket dir,
|
||||
# ${TMUX_TMPDIR:-/tmp}/tmux-UID (codex PR #1466: TMPDIR is wrong here).
|
||||
# Validate an environment-selected socket just like an explicit socket so a
|
||||
# stale launcher pin cannot create a watcher that can never deliver.
|
||||
if [[ -z "$socket" && -n "${MOSAIC_TMUX_SOCKET:-}" ]]; then
|
||||
socket="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
if [[ -n "$socket" ]]; then
|
||||
tmux -L "$socket" has-session -t "$session" 2>/dev/null || die "no tmux session '$session' on socket '$socket'"
|
||||
else
|
||||
local hits="" sname sf hit_count
|
||||
local socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
|
||||
for sf in "$socket_dir"/*; do
|
||||
[ -S "$sf" ] || continue
|
||||
sname="${sf##*/}"
|
||||
tmux -L "$sname" has-session -t "$session" 2>/dev/null && hits="$hits $sname"
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | wc -w)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
echo "agent-watch: session '$session' exists on multiple sockets:$hits — pass --socket explicitly" >&2
|
||||
exit 4
|
||||
elif [ "$hit_count" -eq 1 ]; then
|
||||
socket="${hits# }"
|
||||
else
|
||||
die "no tmux session '$session' (default socket)"
|
||||
fi
|
||||
fi
|
||||
|
||||
local unit; unit="$(unit_for "$name")"
|
||||
if systemctl --user is-active "$unit.timer" >/dev/null 2>&1; then
|
||||
die "a watcher named '$name' is already running (unit $unit.timer); stop it first or pick another name"
|
||||
fi
|
||||
|
||||
local dir; dir="$(state_dir "$name")"
|
||||
mkdir -p "$dir"
|
||||
# printf %q, not bare quotes (D43): the config is SOURCED by each tick; a
|
||||
# single quote in message/condition used to close the string early and kill
|
||||
# the watcher silently after start reported success. Measured 2026-08-21.
|
||||
{
|
||||
printf 'NAME=%q\n' "$name"
|
||||
printf 'SESSION=%q\n' "$session"
|
||||
printf 'SOCKET=%q\n' "$socket"
|
||||
printf 'CLASS=%q\n' "$cls"
|
||||
printf 'MESSAGE=%q\n' "$message"
|
||||
printf 'CONDITION=%q\n' "$when"
|
||||
printf 'INTERVAL=%s\n' "$interval"
|
||||
printf 'TIMEOUT=%s\n' "$timeout"
|
||||
printf 'STARTED=%s\n' "$(date +%s)"
|
||||
printf 'REPEAT=%s\n' "$repeat"
|
||||
printf 'QUIET_TIMEOUT=%s\n' "$quiet"
|
||||
} > "$dir/config"
|
||||
# Fail loudly here, not in a detached tick nobody is reading.
|
||||
if ! ( set -e; source "$dir/config" ) 2>/dev/null; then
|
||||
rm -f "$dir/config"
|
||||
die "could not write a sourceable config for '$name'; watcher NOT started"
|
||||
fi
|
||||
# Re-arm clears episode/terminal markers: a fresh staleness episode must be
|
||||
# notifiable again, and a re-armed watch is no longer retired.
|
||||
rm -f "$dir/stale-noticed" "$dir/terminated" 2>/dev/null || true
|
||||
|
||||
# One transient timer per watch = the isolation. Each tick is a fresh
|
||||
# process in its own cgroup: a crash kills that tick only; the journal
|
||||
# carries the history; reboot cancels cleanly (a watch is re-armed by
|
||||
# whoever still wants it — that is deliberate, WAKE-DOCTRINE's "a watcher
|
||||
# is a fallback cadence, not a steady-state mechanism").
|
||||
# --on-active fires the first tick in ~1s; --on-unit-active-sec re-arms
|
||||
# after every tick. systemd composes the two as OR.
|
||||
: > "$dir/watch.log"
|
||||
if ! systemctl --user start "$unit.timer" 2>/dev/null; then
|
||||
# transient timer does not exist yet — create it
|
||||
# ENV PASS-THROUGH (measured 2026-08-23, D62): a tick runs in the unit's
|
||||
# environment, NOT the arming shell's. MOSAIC_WATCH_STATE or
|
||||
# MOSAIC_AGENT_SEND set at arm time but not passed here made _tick resolve
|
||||
# a DIFFERENT state root, miss its config, and fire the poison pill two
|
||||
# seconds after start — the watch silently died while `start` had already
|
||||
# reported success. Any override the arming shell used must travel with
|
||||
# the unit, or start and tick disagree about which watch they serve.
|
||||
local extra_env=()
|
||||
[[ -n "${MOSAIC_WATCH_STATE:-}" ]] && extra_env+=(--setenv=MOSAIC_WATCH_STATE="$MOSAIC_WATCH_STATE")
|
||||
[[ -n "${MOSAIC_AGENT_SEND:-}" ]] && extra_env+=(--setenv=MOSAIC_AGENT_SEND="$MOSAIC_AGENT_SEND")
|
||||
if ! systemd-run --user --collect \
|
||||
--unit="$unit" \
|
||||
--description="agent-watch: $name (-> $session${socket:+ on $socket})" \
|
||||
--on-active=1s \
|
||||
--on-unit-active="${interval}s" \
|
||||
--setenv=HOME="$HOME" \
|
||||
--setenv=PATH="$PATH" \
|
||||
--setenv=MOSAIC_BRAIN_HOME="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}" \
|
||||
${extra_env[@]+"${extra_env[@]}"} \
|
||||
bash "$(readlink -f "$0")" _tick "$name" >> "$dir/watch.log" 2>&1; then
|
||||
rm -f "$dir/config"
|
||||
die "systemd-run failed for $unit (see $dir/watch.log); watcher NOT started"
|
||||
fi
|
||||
fi
|
||||
say_log "$name" "watcher started: every ${interval}s, timeout ${timeout}s, -> $session${socket:+ on $socket} ($cls)"
|
||||
echo "started watcher '$name' (unit $unit.timer): every ${interval}s, timeout ${timeout}s, -> ${socket:+$socket/}$session ($cls)"
|
||||
echo "condition: $when"
|
||||
}
|
||||
|
||||
# ── _tick (hidden; run BY the transient service each interval) ────────────────
|
||||
cmd_tick() {
|
||||
local name="$1"
|
||||
local dir; dir="$(state_dir "$name")"
|
||||
local unit; unit="$(unit_for "$name")"
|
||||
# Poison pill (v1.1.0 semantics): no config = stopped/cleaned; cancel timer.
|
||||
if [[ ! -r "$dir/config" ]]; then
|
||||
systemctl --user stop "$unit.timer" "$unit.service" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$dir/config"
|
||||
|
||||
# Timeout: a watch is never forever.
|
||||
if (( $(date +%s) - STARTED >= TIMEOUT )); then
|
||||
if [[ "$QUIET_TIMEOUT" -eq 0 ]]; then
|
||||
"$SEND" ${SOCKET:+-L "$SOCKET"} -s "$SESSION" -C terminal-log \
|
||||
-m "[watch:$NAME] timeout after ${TIMEOUT}s — watcher retired" >/dev/null 2>&1 || true
|
||||
fi
|
||||
say_log "$NAME" "timeout after ${TIMEOUT}s"
|
||||
mark_terminal "$NAME" "timeout after ${TIMEOUT}s"
|
||||
systemctl --user stop "$unit.timer" >/dev/null 2>&1 || true
|
||||
rm -f "$dir/pid" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set +e
|
||||
( cd "$HOME" && bash -c "$CONDITION" ) >/dev/null 2>&1
|
||||
local rc=$?
|
||||
set -e
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
say_log "$NAME" "condition met (rc=0)"
|
||||
local tries=0 drc
|
||||
while :; do
|
||||
set +e
|
||||
"$SEND" ${SOCKET:+-L "$SOCKET"} -s "$SESSION" -C "$CLASS" -m "[watch:$NAME] $MESSAGE"
|
||||
drc=$?
|
||||
set -e
|
||||
case "$drc" in
|
||||
0) say_log "$NAME" "delivered (rc=0)"; break ;;
|
||||
2) say_log "$NAME" "rc=2: reached pane as draft — delivered, NOT retried"; break ;;
|
||||
*) tries=$((tries + 1))
|
||||
if [[ "$tries" -ge 3 ]]; then
|
||||
say_log "$NAME" "delivery failed rc=$drc after 3 attempts — giving up"
|
||||
# NO terminal marker here, deliberately: the recipient got
|
||||
# nothing, so the watch is still owed. It stays stale-detectable
|
||||
# and the LOST notice is truthful (B1 covers only paths that
|
||||
# already told the recipient something).
|
||||
systemctl --user stop "$unit.timer" >/dev/null 2>&1 || true
|
||||
exit 4
|
||||
fi
|
||||
sleep 5 ;;
|
||||
esac
|
||||
done
|
||||
if [[ "$REPEAT" -eq 1 ]]; then
|
||||
say_log "$NAME" "--repeat: re-arming"
|
||||
return 0
|
||||
fi
|
||||
say_log "$NAME" "watcher complete"
|
||||
mark_terminal "$NAME" "complete: condition met, notice delivered"
|
||||
systemctl --user stop "$unit.timer" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
elif [[ "$rc" -ne 1 ]]; then
|
||||
# 1 = ordinary false; anything else = broken condition. Say so, retire.
|
||||
say_log "$NAME" "condition exited rc=$rc (not 0/1) — broken; stopping"
|
||||
"$SEND" ${SOCKET:+-L "$SOCKET"} -s "$SESSION" -C terminal-log \
|
||||
-m "[watch:$NAME] condition broken (rc=$rc), watcher stopped: $CONDITION" >/dev/null 2>&1 || true
|
||||
mark_terminal "$NAME" "condition broken (rc=$rc)"
|
||||
systemctl --user stop "$unit.timer" >/dev/null 2>&1 || true
|
||||
exit 5
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── stale detection (D62) ─────────────────────────────────────────────────
|
||||
# Config present + timer gone + no terminal marker = the watch will never
|
||||
# fire and nobody was told. Claim-first (review B2): the stale-noticed marker
|
||||
# is created atomically (noclobber) BEFORE any send, so exactly one of N
|
||||
# concurrent callers sends and the others return immediately. Crash semantics
|
||||
# are AT-MOST-ONCE per episode: a crash between claim and successful send
|
||||
# drops that episode's notice (the claim survives, preventing a later
|
||||
# duplicate); staleness itself stays visible in list/status output and exit
|
||||
# code 3 regardless. Delivery follows fired-watch semantics: rc=0/2 counts as
|
||||
# delivered and the claim is held; exhausted retries release the claim so the
|
||||
# next detection tries again.
|
||||
notice_stale_once() {
|
||||
local name="$1"
|
||||
local dir; dir="$(state_dir "$name")"
|
||||
[[ -r "$dir/config" ]] || return 0
|
||||
[[ -e "$dir/terminated" ]] && return 0 # expected death: owed nothing
|
||||
if ! ( set -o noclobber; printf '%s\n' "$(date -u +%FT%TZ)" > "$dir/stale-noticed" ) 2>/dev/null; then
|
||||
return 0 # another caller holds the claim for this episode
|
||||
fi
|
||||
say_log "$name" "stale claim acquired (config present, timer gone, not terminal)"
|
||||
# shellcheck disable=SC1090
|
||||
source "$dir/config"
|
||||
# Post-claim re-check: if the timer recovered while we raced for the claim,
|
||||
# the episode ended. Release the claim without sending.
|
||||
# T24: the re-check itself can fail (bus dropped since entry) — an EMPTY
|
||||
# answer from systemctl must not read as "still gone": release the claim
|
||||
# (nothing was verified, nothing sent) and fail loud rather than fabricate
|
||||
# a LOST notice on an unreadable system.
|
||||
local rerc=0 reout
|
||||
reout="$(systemctl --user is-active "$(unit_for "$name").timer" 2>/dev/null)" || rerc=$?
|
||||
if [[ "$rerc" -ne 0 && -z "$reout" ]]; then
|
||||
rm -f "$dir/stale-noticed"
|
||||
say_log "$name" "user bus unreachable at post-claim re-check; claim released, NO notice sent"
|
||||
require_user_bus # prints the diagnostic, exits rc $RC_NO_BUS
|
||||
fi
|
||||
if [[ "$rerc" -eq 0 ]]; then
|
||||
rm -f "$dir/stale-noticed"
|
||||
say_log "$name" "timer recovered after claim; notice suppressed, claim released"
|
||||
return 0
|
||||
fi
|
||||
local tries=0 drc=1
|
||||
while :; do
|
||||
set +e
|
||||
"$SEND" ${SOCKET:+-L "$SOCKET"} -s "$SESSION" -C actionable \
|
||||
-m "[watch:$NAME] LOST: its timer is gone but the watch state remains; it will never fire like this. Re-arm (agent-watch.sh start) or stop it (agent-watch.sh stop $NAME)."
|
||||
drc=$?
|
||||
set -e
|
||||
[[ "$drc" -eq 0 || "$drc" -eq 2 ]] && break
|
||||
tries=$((tries + 1)); [[ "$tries" -ge 3 ]] && break
|
||||
sleep 5
|
||||
done
|
||||
if [[ "$drc" -eq 0 || "$drc" -eq 2 ]]; then
|
||||
say_log "$NAME" "stale notice delivered (rc=$drc); claim held"
|
||||
else
|
||||
rm -f "$dir/stale-noticed" # claim released: nothing was delivered
|
||||
say_log "$NAME" "stale notice delivery FAILED rc=$drc after 3 attempts — claim released, will retry on next detection"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── shared classification (T19) ──────────────────────────────────────────────
|
||||
# One classification source for list/status/_scan. T16W2 semantics preserved
|
||||
# exactly: an observed-alive timer releases the episode claim ONLY — a timer
|
||||
# mid-stop can still report active while mark_terminal's stop is in flight, so
|
||||
# activity must never clear `terminated` (only start/re-arm does).
|
||||
classify_watch() { # $1=name -> echoes alive|retired|stale
|
||||
local dir; dir="$(state_dir "$1")"
|
||||
if systemctl --user is-active "$(unit_for "$1").timer" >/dev/null 2>&1; then
|
||||
rm -f "$dir/stale-noticed" 2>/dev/null || true
|
||||
echo alive
|
||||
elif [[ -e "$dir/terminated" ]]; then
|
||||
echo retired
|
||||
else
|
||||
echo stale
|
||||
fi
|
||||
}
|
||||
|
||||
# ── meta-watch (T19/D62): detection without a query ──────────────────────────
|
||||
# The scan path is nothing new: it is the SAME classification + claim-first
|
||||
# notices list/status run, invoked on a cadence by a persistent, ENABLED
|
||||
# systemd user timer instead of a human/orchestrator query. A tick is just
|
||||
# another concurrent caller of the T16 semantics, so tick-vs-query races still
|
||||
# yield exactly one LOST per episode.
|
||||
#
|
||||
# Why this does not recreate D62 one level up:
|
||||
# - watches are TRANSIENT units: reboot wipes them while their state dirs
|
||||
# still promise delivery (the measured loss mode). The meta-watch is a
|
||||
# persistent, enabled unit — reboot cannot strand it; it returns with
|
||||
# timers.target.
|
||||
# - it holds no per-obligation state. Its only artifact is a heartbeat that
|
||||
# AGES: a dead meta-watch leaves an absence signal, not a silent promise.
|
||||
# - list/status surface meta health (timer active + heartbeat age vs
|
||||
# cadence) on the query surface that already exists, with a JSON field
|
||||
# for machine consumption. Pre-T19 a dead watch was invisible even when
|
||||
# queried; post-T19 a dead meta-watch is visible whenever queried.
|
||||
# - a same-host meta-meta-watch would share fate with the meta (one systemd
|
||||
# user instance) and add nothing. The honest terminal for liveness is
|
||||
# off-host dead-man supervision (WAKE-DOCTRINE); out of scope here.
|
||||
meta_conf() { echo "$STATE_ROOT/meta-watch.conf"; } # flat files on purpose:
|
||||
meta_heartbeat() { echo "$STATE_ROOT/meta-watch.heartbeat"; } # scan loop reads dirs only
|
||||
|
||||
meta_state() { # rc 0 = installed (globals below set) · rc 2 = not installed
|
||||
MU_UNIT=""; MU_INTERVAL=0; MU_TIMER="inactive"; MU_AGE="none"; MU_HEALTHY=0
|
||||
local conf; conf="$(meta_conf)"
|
||||
[[ -r "$conf" ]] || return 2
|
||||
# shellcheck disable=SC1090
|
||||
source "$conf"
|
||||
MU_UNIT="${META_UNIT:-$META_UNIT_DEFAULT}"
|
||||
MU_INTERVAL="${META_INTERVAL:-300}"
|
||||
if systemctl --user is-active "$MU_UNIT.timer" >/dev/null 2>&1; then MU_TIMER="active"; fi
|
||||
local hb; hb="$(meta_heartbeat)"
|
||||
if [[ -r "$hb" ]]; then
|
||||
MU_AGE=$(( $(date +%s) - $(stat -c %Y "$hb") ))
|
||||
if (( MU_AGE < 0 )); then MU_AGE=0; fi
|
||||
fi
|
||||
local limit=$(( MU_INTERVAL * 2 + 60 ))
|
||||
if [[ "$MU_TIMER" == "active" && "$MU_AGE" != "none" && "$MU_AGE" -le "$limit" ]]; then
|
||||
MU_HEALTHY=1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
meta_line() { # human one-liner for list/status output
|
||||
if meta_state; then
|
||||
if [[ "$MU_HEALTHY" -eq 1 ]]; then
|
||||
echo "meta-watch: healthy (unit $MU_UNIT timer active, heartbeat ${MU_AGE}s old, cadence ${MU_INTERVAL}s)"
|
||||
else
|
||||
local age="$MU_AGE"
|
||||
if [[ "$MU_AGE" == "none" ]]; then age="never"; fi
|
||||
echo "meta-watch: DEAD (unit $MU_UNIT timer=$MU_TIMER, heartbeat $age, cadence ${MU_INTERVAL}s) — autonomous detection DOWN; queries still work"
|
||||
fi
|
||||
else
|
||||
echo "meta-watch: not installed (no autonomous detection; agent-watch.sh meta-install)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── list / status / stop / log ───────────────────────────────────────────────
|
||||
cmd_list() {
|
||||
require_user_bus # T24: an unreachable bus reads as "all timers gone" — refuse before classifying
|
||||
echo "active watches (transient timers):"
|
||||
systemctl --user list-timers --all --no-legend "${UNIT_PREFIX}-*.timer" 2>/dev/null || true
|
||||
local stale=() retired=() d n
|
||||
for d in "$STATE_ROOT"/*/; do
|
||||
[[ -d "$d" ]] || continue
|
||||
n="$(basename "$d")"
|
||||
[[ -r "$d/config" ]] || continue
|
||||
case "$(classify_watch "$n")" in
|
||||
retired) retired+=("$n ($(cat "$d/terminated" 2>/dev/null || echo '?'))") ;;
|
||||
stale) stale+=("$n") ;;
|
||||
esac
|
||||
done
|
||||
# Print the collected sections first, then do notification work: a slow or
|
||||
# failing sender must not bury the inventory (mirrors cmd_status, S2).
|
||||
local x
|
||||
if [[ ${#retired[@]} -gt 0 ]]; then
|
||||
echo "retired (expected stop; see log <name>):"
|
||||
for x in ${retired[@]+"${retired[@]}"}; do echo " $x"; done
|
||||
fi
|
||||
if [[ ${#stale[@]} -gt 0 ]]; then
|
||||
echo "stale state (config present, timer gone):"
|
||||
for x in ${stale[@]+"${stale[@]}"}; do echo " $x"; done
|
||||
fi
|
||||
echo "$(meta_line)" # T19: meta-watch health rides the same surface
|
||||
for x in ${stale[@]+"${stale[@]}"}; do
|
||||
notice_stale_once "$x"
|
||||
done
|
||||
}
|
||||
|
||||
# Machine-readable liveness: one call, JSON or human, exit 0 = none stale,
|
||||
# exit 3 = one or more stale. Never a substitute for arming real watches; it
|
||||
# answers "did my watches survive" (WAKE-DOCTRINE: check the instrument).
|
||||
cmd_status() {
|
||||
# S1: status takes at most one option, --json, and nothing else.
|
||||
[[ $# -le 1 ]] || die "status: takes at most one option (--json), got: $*"
|
||||
local json=0
|
||||
case "${1:-}" in
|
||||
"") ;;
|
||||
--json) json=1 ;;
|
||||
*) die "status: unknown argument: $1 (usage: status [--json])" ;;
|
||||
esac
|
||||
require_user_bus # T24: an unreachable bus reads as "all timers gone" — refuse before classifying
|
||||
local alive=() stale=() retired=() d n
|
||||
for d in "$STATE_ROOT"/*/; do
|
||||
[[ -d "$d" ]] || continue
|
||||
[[ -r "$d/config" ]] || continue
|
||||
n="$(basename "$d")"
|
||||
case "$(classify_watch "$n")" in
|
||||
alive) alive+=("$n") ;;
|
||||
retired) retired+=("$n") ;;
|
||||
stale) stale+=("$n") ;;
|
||||
esac
|
||||
done
|
||||
# Print the collected answer FIRST (S2), then do notification work: a slow
|
||||
# or failing sender must not delay the JSON/human liveness answer.
|
||||
# T19: meta-watch health rides the same machine answer (additive schema-1
|
||||
# field; consumers ignoring unknown fields are unaffected).
|
||||
local meta_installed=0
|
||||
if meta_state; then meta_installed=1; fi
|
||||
if [[ "$json" -eq 1 ]]; then
|
||||
local ja="" js="" jr=""
|
||||
[[ "${#alive[@]}" -gt 0 ]] && ja="$(printf '"%s",' "${alive[@]}" | sed 's/,$//')"
|
||||
[[ "${#stale[@]}" -gt 0 ]] && js="$(printf '"%s",' "${stale[@]}" | sed 's/,$//')"
|
||||
[[ "${#retired[@]}" -gt 0 ]] && jr="$(printf '"%s",' "${retired[@]}" | sed 's/,$//')"
|
||||
local mj
|
||||
if [[ "$meta_installed" -eq 1 ]]; then
|
||||
local m_h="false" m_t="false"
|
||||
if [[ "$MU_HEALTHY" -eq 1 ]]; then m_h="true"; fi
|
||||
if [[ "$MU_TIMER" == "active" ]]; then m_t="true"; fi
|
||||
local m_age="$MU_AGE"
|
||||
if [[ "$MU_AGE" == "none" ]]; then m_age="null"; fi
|
||||
mj=$(printf '"installed":true,"healthy":%s,"timer_active":%s,"heartbeat_age_s":%s,"interval_s":%s,"unit":"%s"' \
|
||||
"$m_h" "$m_t" "$m_age" "$MU_INTERVAL" "$MU_UNIT")
|
||||
else
|
||||
mj='"installed":false'
|
||||
fi
|
||||
printf '{"schema":1,"total":%d,"alive":[%s],"stale":[%s],"retired":[%s],"meta":{%s}}\n' \
|
||||
"$(( ${#alive[@]} + ${#stale[@]} + ${#retired[@]} ))" "$ja" "$js" "$jr" "$mj"
|
||||
else
|
||||
echo "alive: ${#alive[@]}${alive[@]:+ (${alive[*]})}"
|
||||
echo "stale: ${#stale[@]}${stale[@]:+ (${stale[*]})}"
|
||||
echo "retired: ${#retired[@]}${retired[@]:+ (${retired[*]})}"
|
||||
echo "$(meta_line)"
|
||||
fi
|
||||
local s
|
||||
for s in ${stale[@]+"${stale[@]}"}; do
|
||||
notice_stale_once "$s"
|
||||
done
|
||||
local rc=0
|
||||
if [[ "${#stale[@]}" -gt 0 ]]; then rc=3; fi
|
||||
# T19: an installed-but-dead meta-watch is itself a liveness failure —
|
||||
# fail loud on the instrument, not only on the watches it guards.
|
||||
if [[ "$meta_installed" -eq 1 && "$MU_HEALTHY" -ne 1 ]]; then rc=3; fi
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
local name="$1"
|
||||
local unit; unit="$(unit_for "$name")"
|
||||
systemctl --user stop "$unit.timer" "$unit.service" >/dev/null 2>&1 || true
|
||||
rm -rf "$(state_dir "$name")" 2>/dev/null || true
|
||||
echo "stopped watcher '$name' (state removed)"
|
||||
}
|
||||
|
||||
cmd_log() {
|
||||
local name="$1"
|
||||
local dir; dir="$(state_dir "$name")"
|
||||
[[ -r "$dir/watch.log" ]] && cat "$dir/watch.log"
|
||||
echo "--- journal (unit $(unit_for "$name").service) ---"
|
||||
journalctl --user -u "$(unit_for "$name").service" --no-pager -n 40 2>/dev/null | tail -n +2 || true
|
||||
}
|
||||
|
||||
# ── _scan (hidden; run BY the meta-watch service each cadence) ───────────────
|
||||
# Same classification + claim-first notices as list/status — a meta tick is
|
||||
# just another concurrent caller of the T16 semantics. Heartbeat is stamped
|
||||
# LAST: it proves a COMPLETED scan, not a scheduled one (a crashing scan
|
||||
# leaves the heartbeat aging, which is exactly the dead-meta signal).
|
||||
cmd_scan() { # $1 = meta unit name (informational, for the journal line)
|
||||
require_user_bus # T24: a bus-less scan classifies everything LOST — refuse, stamp no heartbeat
|
||||
local d n stale=()
|
||||
for d in "$STATE_ROOT"/*/; do
|
||||
[[ -d "$d" ]] || continue
|
||||
[[ -r "$d/config" ]] || continue
|
||||
n="$(basename "$d")"
|
||||
if [[ "$(classify_watch "$n")" == "stale" ]]; then stale+=("$n"); fi
|
||||
done
|
||||
local s
|
||||
for s in ${stale[@]+"${stale[@]}"}; do
|
||||
notice_stale_once "$s"
|
||||
done
|
||||
printf '%s\n' "$(date -u +%FT%TZ)" > "$(meta_heartbeat)"
|
||||
echo "agent-watch meta-watch (${1:-$META_UNIT_DEFAULT}) scan: ${#stale[@]} stale, notices attempted"
|
||||
}
|
||||
|
||||
# ── meta-install / meta-remove (explicit; nothing self-installs) ────────────
|
||||
cmd_meta_install() {
|
||||
local interval=300 unit="$META_UNIT_DEFAULT"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--interval) interval="$2"; shift 2 ;;
|
||||
--unit-name) unit="$2"; shift 2 ;;
|
||||
*) die "meta-install: unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
[[ "$unit" =~ ^agent-watch-[a-z0-9][a-z0-9-]*$ ]] || die "meta-install: --unit-name must be agent-watch-<lowercase-hyphens> so it stays visible under the agent-watch-* inventory (got: '$unit')"
|
||||
[[ "$interval" =~ ^[0-9]+$ ]] || die "meta-install: --interval must be a number"
|
||||
[[ "$interval" -ge "$META_FLOOR_INTERVAL" ]] || die "meta-install: --interval floor is ${META_FLOOR_INTERVAL}s for the meta-watch (got ${interval}s) — a detection net, not a poll"
|
||||
command -v systemctl >/dev/null 2>&1 || die "systemctl not on PATH (meta-watch requires systemd --user)"
|
||||
[[ -x "$SEND" ]] || die "sender not found/executable: $SEND"
|
||||
require_user_bus # T24: installing without the bus writes units the manager never loads
|
||||
# Collision guard: meta units share the watch-unit namespace; never shadow
|
||||
# an existing watch's units.
|
||||
local bare="${unit#agent-watch-}"
|
||||
[[ ! -r "$(state_dir "$bare")/config" ]] || die "a watch named '$bare' already exists; its units would collide with $unit.*"
|
||||
# T19R O1: a meta installed under a DIFFERENT unit name must not be
|
||||
# displaced silently — overwriting the conf strands the old timer (two
|
||||
# live metas, one heartbeat). Refuse; meta-remove first. A same-name
|
||||
# re-install is the idempotent repair path (unit files rewritten, timer
|
||||
# re-enabled and restarted) — preserved below.
|
||||
if meta_state; then
|
||||
[[ "$MU_UNIT" == "$unit" ]] || \
|
||||
die "meta-install: a meta-watch is already installed as '$MU_UNIT.timer' (conf: $(meta_conf)); run 'agent-watch.sh meta-remove' first — installing '$unit' would run two metas on one heartbeat"
|
||||
fi
|
||||
local was_active=0
|
||||
if systemctl --user is-active "$unit.timer" >/dev/null 2>&1; then was_active=1; fi
|
||||
local self; self="$(readlink -f "$0")"
|
||||
# Env travel (D62 lesson, same as start): the scan must resolve the SAME
|
||||
# state root and sender the installing shell used. systemd Environment=
|
||||
# carries these verbatim, so reject values it cannot (no spaces/quotes).
|
||||
local v
|
||||
for v in "$HOME" "${MOSAIC_WATCH_STATE:-}" "${MOSAIC_AGENT_SEND:-}" "$self"; do
|
||||
[[ -z "$v" || "$v" =~ ^[[:alnum:]_./:=+-]+$ ]] || die "meta-install: value has characters a systemd unit cannot carry verbatim: '$v'"
|
||||
done
|
||||
local ud="$HOME/.config/systemd/user"
|
||||
mkdir -p "$ud"
|
||||
{
|
||||
echo "# generated by agent-watch.sh meta-install $(date -u +%FT%TZ); change = re-install, uninstall = meta-remove"
|
||||
echo "[Unit]"
|
||||
echo "Description=agent-watch meta-watch: autonomous stale-watch detection (${interval}s cadence)"
|
||||
echo ""
|
||||
echo "[Timer]"
|
||||
echo "OnBootSec=1min" # post-reboot first scan, even though the
|
||||
echo "OnUnitActiveSec=${interval}s" # service has never run this boot
|
||||
echo "AccuracySec=5s"
|
||||
echo ""
|
||||
echo "[Install]"
|
||||
echo "WantedBy=timers.target" # enablement survives reboot (the whole point)
|
||||
} > "$ud/$unit.timer"
|
||||
{
|
||||
echo "# generated by agent-watch.sh meta-install $(date -u +%FT%TZ); change = re-install, uninstall = meta-remove"
|
||||
echo "[Unit]"
|
||||
echo "Description=agent-watch meta-watch scan (autonomous stale detection)"
|
||||
echo ""
|
||||
echo "[Service]"
|
||||
echo "Type=oneshot"
|
||||
echo "TimeoutStartSec=10min" # retries (3x5s sleeps) must not trip the default
|
||||
echo "Environment=HOME=$HOME"
|
||||
if [[ -n "${MOSAIC_WATCH_STATE:-}" ]]; then echo "Environment=MOSAIC_WATCH_STATE=$MOSAIC_WATCH_STATE"; fi
|
||||
if [[ -n "${MOSAIC_AGENT_SEND:-}" ]]; then echo "Environment=MOSAIC_AGENT_SEND=$MOSAIC_AGENT_SEND"; fi
|
||||
echo "ExecStart=$self _scan $unit"
|
||||
} > "$ud/$unit.service"
|
||||
mkdir -p "$STATE_ROOT"
|
||||
printf 'META_UNIT=%q\nMETA_INTERVAL=%s\n' "$unit" "$interval" > "$(meta_conf)"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now "$unit.timer" >/dev/null
|
||||
# Repair path (same-name re-install): a literal restart re-arms the timer
|
||||
# on the freshly written unit files; enable --now alone would leave an
|
||||
# already-active timer on its old schedule.
|
||||
if [[ "$was_active" -eq 1 ]]; then
|
||||
systemctl --user restart "$unit.timer" >/dev/null
|
||||
fi
|
||||
# Explicit first scan NOW: fail fast at install time, not one cadence later;
|
||||
# it also anchors OnUnitActiveSec for steady cadence.
|
||||
systemctl --user start "$unit.service"
|
||||
echo "meta-watch installed and enabled: $unit.timer, every ${interval}s (persistent unit: reboot-safe)"
|
||||
echo "first scan complete; heartbeat: $(meta_heartbeat)"
|
||||
echo "state root: $STATE_ROOT"
|
||||
}
|
||||
|
||||
cmd_meta_remove() {
|
||||
local unit=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--unit-name) unit="$2"; shift 2 ;;
|
||||
*) die "meta-remove: unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$unit" ]]; then
|
||||
if [[ -r "$(meta_conf)" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
unit="$( . "$(meta_conf)" && echo "${META_UNIT:-}" )"
|
||||
fi
|
||||
[[ -n "$unit" ]] || unit="$META_UNIT_DEFAULT"
|
||||
fi
|
||||
systemctl --user disable --now "$unit.timer" >/dev/null 2>&1 || true
|
||||
systemctl --user reset-failed "$unit.service" "$unit.timer" >/dev/null 2>&1 || true
|
||||
rm -f "$HOME/.config/systemd/user/$unit.timer" "$HOME/.config/systemd/user/$unit.service"
|
||||
systemctl --user daemon-reload >/dev/null 2>&1 || true
|
||||
rm -f "$(meta_conf)" "$(meta_heartbeat)"
|
||||
echo "meta-watch removed: $unit.{timer,service} uninstalled, meta state cleared"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) shift; cmd_start "$@" ;;
|
||||
list) cmd_list ;;
|
||||
status) shift; cmd_status "$@" ;;
|
||||
stop) shift; cmd_stop "$1" ;;
|
||||
log) shift; cmd_log "$1" ;;
|
||||
meta-install) shift; cmd_meta_install "$@" ;;
|
||||
meta-remove) shift; cmd_meta_remove "$@" ;;
|
||||
_tick) shift; cmd_tick "$1" ;;
|
||||
_scan) shift; cmd_scan "$@" ;;
|
||||
-h|--help|*) usage ;;
|
||||
esac
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# jarvis-email-watch-liveness.sh — T40 timer-owner liveness check (code-be-01).
|
||||
#
|
||||
# Owner: code-be-01 (Jason ruling, board T40; jarvis remains author of the
|
||||
# watcher code). Detection is SYSTEMD-STATE-ONLY — no pane scraping, no fixed
|
||||
# interval assumptions beyond the unit's own 30-min cadence.
|
||||
#
|
||||
# DEAD conditions (any one pages the owner):
|
||||
# 1. timer unit not found or not active
|
||||
# 2. NextElapse is infinity (timer will never fire again)
|
||||
# 3. service last result failed
|
||||
# 4. last service run older than 90 min (3 missed 30-min slots)
|
||||
#
|
||||
# Exit: 0 = alive (prints state line) · 1 = DEAD (prints reason) · 2 = cannot
|
||||
# assert (systemd unreachable — surface, don't guess).
|
||||
set -uo pipefail
|
||||
|
||||
UNIT_TIMER="jarvis-email-watch.timer"
|
||||
UNIT_SERVICE="jarvis-email-watch.service"
|
||||
CADENCE_MIN=30
|
||||
MISSED_SLOTS=3
|
||||
STALE_SEC=$(( CADENCE_MIN * 60 * MISSED_SLOTS ))
|
||||
|
||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
|
||||
active="$(systemctl --user show "$UNIT_TIMER" --property=ActiveState --value 2>/dev/null)"
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ] || [ -z "$active" ]; then
|
||||
echo "DEAD(reason=systemd-unreachable-or-unit-missing unit=$UNIT_TIMER rc=$rc)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
[ "$active" = "active" ] || { echo "DEAD(reason=timer-not-active state=$active)"; exit 1; }
|
||||
|
||||
# This timer is MONOTONIC-ONLY (OnBootSec/OnUnitActiveSec/OnUnitInactiveSec — no
|
||||
# realtime calendar), so NextElapseUSecRealtime is legitimately empty. The
|
||||
# unit file's own comment records the failure signature that killed brain-sync
|
||||
# for five days: monotonic timer + reboot => NextElapse becomes 'infinity'
|
||||
# while is-active/is-enabled stay green. Probe the monotonic property; then
|
||||
# take the AUTHORITATIVE next-fire from list-timers (it computes the min over
|
||||
# all timer bases; `show`'s aggregate includes the long-expired OnBootSec base
|
||||
# and reads as ~never on a healthy timer).
|
||||
mono="$(systemctl --user show "$UNIT_TIMER" --property=NextElapseUSecMonotonic --value 2>/dev/null)"
|
||||
[ "$mono" != "infinity" ] || { echo "DEAD(reason=next-elapse-infinity monotonic — reboot-killed-timer signature, see unit-file note)"; exit 1; }
|
||||
|
||||
row="$(systemctl --user list-timers "$UNIT_TIMER" --no-pager 2>/dev/null | grep "$UNIT_TIMER")"
|
||||
[ -n "$row" ] || { echo "DEAD(reason=no-list-timers-row unit=$UNIT_TIMER)"; exit 1; }
|
||||
case "$row" in
|
||||
*"n/a"*) echo "DEAD(reason=list-timers-next-na)"; exit 1 ;;
|
||||
esac
|
||||
next="$row"
|
||||
|
||||
result="$(systemctl --user show "$UNIT_SERVICE" --property=Result --value 2>/dev/null)"
|
||||
[ "$result" = "success" ] || [ "$result" = "" ] || { echo "DEAD(reason=service-result-failed result=$result)"; exit 1; }
|
||||
|
||||
execstamp="$(systemctl --user show "$UNIT_SERVICE" --property=ExecMainExitTimestamp --value 2>/dev/null)"
|
||||
if [ -n "$execstamp" ] && [ "$execstamp" != "0" ]; then
|
||||
last_ep="$(date -d "$execstamp" +%s 2>/dev/null || echo 0)"
|
||||
now_ep="$(date +%s)"
|
||||
age=$(( now_ep - last_ep ))
|
||||
if [ "$age" -gt "$STALE_SEC" ]; then
|
||||
echo "DEAD(reason=stale-last-run age_min=$((age/60)) threshold_min=$((STALE_SEC/60)))"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "ALIVE(active=$active next=$next result=$result last_exit=$execstamp)"
|
||||
exit 0
|
||||
Executable
+272
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-agent-watch-socket.sh — P5-C03 independent B1 contract validation.
|
||||
#
|
||||
# Uses real tmux servers on a private TMUX_TMPDIR. systemctl and systemd-run
|
||||
# are stubs because this suite validates start-time socket selection, not
|
||||
# systemd scheduling. HOME, watch state, and all test sockets are isolated.
|
||||
# Run with --no-red-controls only for the mutation child runs.
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
TOOL="${AGENT_WATCH_TOOL_OVERRIDE:-$HERE/agent-watch.sh}"
|
||||
REAL_TMUX="$(command -v tmux 2>/dev/null || true)"
|
||||
BASH_BIN="$(command -v bash)"
|
||||
ORIG_PATH="$PATH"
|
||||
|
||||
[ -x "$REAL_TMUX" ] || { echo "SKIP: no tmux binary (live fixtures impossible)"; exit 77; }
|
||||
[ -x "$TOOL" ] || { echo "FAIL: agent-watch tool not found/executable: $TOOL" >&2; exit 1; }
|
||||
|
||||
ROOT="$(mktemp -d)"
|
||||
ROOT="$(cd "$ROOT" && pwd)"
|
||||
HOME_DIR="$ROOT/home"
|
||||
TMUX_TMPDIR="$ROOT/tmux"
|
||||
BIN="$ROOT/bin"
|
||||
STATE="$ROOT/state"
|
||||
mkdir -p "$HOME_DIR" "$TMUX_TMPDIR" "$BIN" "$STATE"
|
||||
chmod 700 "$ROOT" "$HOME_DIR" "$TMUX_TMPDIR" "$BIN" "$STATE"
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
|
||||
# These stubs satisfy agent-watch's user-bus and transient-unit checks while
|
||||
# leaving real tmux semantics, including socket discovery, under test.
|
||||
cat > "$BIN/systemctl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
case "${*:--}" in
|
||||
"--user show-environment") exit 0 ;;
|
||||
"--user is-active"*) exit 1 ;;
|
||||
"--user start"*) exit 1 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
STUB
|
||||
cat > "$BIN/systemd-run" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "${AGENT_WATCH_TEST_SYSTEMD_RUN_LOG:?}"
|
||||
exit 0
|
||||
STUB
|
||||
cat > "$BIN/sender-stub" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
STUB
|
||||
chmod 700 "$BIN/systemctl" "$BIN/systemd-run" "$BIN/sender-stub"
|
||||
|
||||
SOCKETS=()
|
||||
cleanup() {
|
||||
local socket
|
||||
for socket in "${SOCKETS[@]}"; do
|
||||
TMUX_TMPDIR="$TMUX_TMPDIR" "$REAL_TMUX" -L "$socket" kill-server >/dev/null 2>&1 || true
|
||||
done
|
||||
rm -rf "$ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
failures=0
|
||||
check() {
|
||||
local label="$1" result="$2"
|
||||
if [ "$result" -eq 0 ]; then
|
||||
printf 'ok %s\n' "$label"
|
||||
else
|
||||
printf 'FAIL %s\n' "$label" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
mk_server() {
|
||||
local socket="$1" session="$2"
|
||||
SOCKETS+=("$socket")
|
||||
TMUX_TMPDIR="$TMUX_TMPDIR" "$REAL_TMUX" -L "$socket" new-session -d -s "$session" -c "$HOME_DIR" \
|
||||
'exec bash --noprofile --norc -i' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
run_start() {
|
||||
local socket_env=(-u MOSAIC_TMUX_SOCKET)
|
||||
if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
socket_env=(MOSAIC_TMUX_SOCKET="$MOSAIC_TMUX_SOCKET")
|
||||
fi
|
||||
env "${socket_env[@]}" \
|
||||
PATH="$BIN:$ORIG_PATH" HOME="$HOME_DIR" TMUX_TMPDIR="$TMUX_TMPDIR" \
|
||||
MOSAIC_WATCH_STATE="$STATE" MOSAIC_AGENT_SEND="$BIN/sender-stub" \
|
||||
AGENT_WATCH_TEST_SYSTEMD_RUN_LOG="$ROOT/systemd-run.log" \
|
||||
"$BASH_BIN" "$TOOL" start "$@"
|
||||
}
|
||||
|
||||
run_start_env() {
|
||||
local socket="$1"
|
||||
shift
|
||||
(
|
||||
MOSAIC_TMUX_SOCKET="$socket"
|
||||
run_start "$@"
|
||||
)
|
||||
}
|
||||
|
||||
config_has() {
|
||||
local name="$1" line="$2"
|
||||
grep -qxF "$line" "$STATE/$name/config" 2>/dev/null
|
||||
}
|
||||
|
||||
check_config() {
|
||||
local label="$1" name="$2" line="$3"
|
||||
if config_has "$name" "$line"; then
|
||||
check "$label" 0
|
||||
else
|
||||
check "$label" 1
|
||||
fi
|
||||
}
|
||||
|
||||
# C1: explicit --socket wins over the environment and discovery.
|
||||
FLAG_SOCKET="aw-c03-flag-$$"
|
||||
mk_server "$FLAG_SOCKET" flag-target
|
||||
if run_start_env "not-the-flag" --name c03-flag --session flag-target --socket "$FLAG_SOCKET" \
|
||||
--when false --message "socket contract" --interval 10 >/dev/null 2>&1; then
|
||||
check_config "C1 explicit --socket wins over MOSAIC_TMUX_SOCKET" c03-flag "SOCKET=$FLAG_SOCKET"
|
||||
else
|
||||
check "C1 explicit --socket start succeeds" 1
|
||||
fi
|
||||
|
||||
# C2: the launcher-exported environment pin wins before discovery. Both
|
||||
# sockets intentionally contain the target, so selecting discovery first
|
||||
# would hit the ambiguity guard instead of producing an env-pinned config.
|
||||
ENV_SOCKET="aw-c03-env-$$"
|
||||
ENV_DISCOVERY_SOCKET="aw-c03-env-discovery-$$"
|
||||
mk_server "$ENV_SOCKET" env-target
|
||||
mk_server "$ENV_DISCOVERY_SOCKET" env-target
|
||||
if run_start_env "$ENV_SOCKET" --name c03-env --session env-target \
|
||||
--when false --message "socket contract" --interval 10 >/dev/null 2>&1; then
|
||||
check_config "C2 MOSAIC_TMUX_SOCKET wins before discovery" c03-env "SOCKET=$ENV_SOCKET"
|
||||
else
|
||||
check "C2 environment pin start succeeds without discovery ambiguity" 1
|
||||
fi
|
||||
BAD_ENV_SOCKET="aw-c03-bad-env-$$"
|
||||
bad_env_rc=0
|
||||
run_start_env "$BAD_ENV_SOCKET" --name c03-bad-env --session env-target \
|
||||
--when false --message "socket contract" --interval 10 >/dev/null 2>"$ROOT/bad-env.err" || bad_env_rc=$?
|
||||
if [ "$bad_env_rc" -eq 2 ]; then
|
||||
check "C2 stale environment socket is rejected" 0
|
||||
else
|
||||
check "C2 stale environment socket is rejected" 1
|
||||
fi
|
||||
if [ ! -e "$STATE/c03-bad-env/config" ]; then
|
||||
check "C2 stale environment socket writes no config" 0
|
||||
else
|
||||
check "C2 stale environment socket writes no config" 1
|
||||
fi
|
||||
|
||||
# C3: with no pin, one exact session hit is discovered from TMUX_TMPDIR.
|
||||
DEFAULT_SOCKET="aw-c03-default-$$"
|
||||
mk_server "$DEFAULT_SOCKET" default-target
|
||||
if run_start --name c03-default --session default-target --when false \
|
||||
--message "socket contract" --interval 10 >/dev/null 2>&1; then
|
||||
check_config "C3 unique TMUX_TMPDIR hit is selected" c03-default "SOCKET=$DEFAULT_SOCKET"
|
||||
else
|
||||
check "C3 unique discovery start succeeds" 1
|
||||
fi
|
||||
|
||||
# C4: duplicate session names refuse with the documented rc 4 and identify
|
||||
# both sockets. The compound form also proves the session component is pinned
|
||||
# exactly while resolving, rather than being treated as a prefix.
|
||||
DUP_SOCKET_A="aw-c03-dup-a-$$"
|
||||
DUP_SOCKET_B="aw-c03-dup-b-$$"
|
||||
mk_server "$DUP_SOCKET_A" duplicate-target
|
||||
mk_server "$DUP_SOCKET_B" duplicate-target
|
||||
DUP_ERR="$ROOT/duplicate.err"
|
||||
dup_rc=0
|
||||
run_start --name c03-duplicate --session duplicate-target:0.0 --when false \
|
||||
--message "socket contract" --interval 10 >/dev/null 2>"$DUP_ERR" || dup_rc=$?
|
||||
if [ "$dup_rc" -eq 4 ]; then
|
||||
check "C4 compound duplicate refuses with rc 4" 0
|
||||
else
|
||||
check "C4 compound duplicate refuses with rc 4" 1
|
||||
fi
|
||||
if grep -qF "$DUP_SOCKET_A" "$DUP_ERR" && grep -qF "$DUP_SOCKET_B" "$DUP_ERR"; then
|
||||
check "C4 refusal names both duplicate sockets" 0
|
||||
else
|
||||
check "C4 refusal names both duplicate sockets" 1
|
||||
fi
|
||||
if [ ! -e "$STATE/c03-duplicate/config" ]; then
|
||||
check "C4 duplicate refusal leaves no watcher config" 0
|
||||
else
|
||||
check "C4 duplicate refusal leaves no watcher config" 1
|
||||
fi
|
||||
|
||||
# C5: exact matching rejects a prefix-only session, then accepts the exact
|
||||
# name when a prefix sibling is also present.
|
||||
PREFIX_SOCKET="aw-c03-prefix-$$"
|
||||
EXACT_SOCKET="aw-c03-exact-$$"
|
||||
mk_server "$PREFIX_SOCKET" exact-target-old
|
||||
prefix_rc=0
|
||||
run_start --name c03-prefix --session exact-target --when false \
|
||||
--message "socket contract" --interval 10 >/dev/null 2>"$ROOT/prefix.err" || prefix_rc=$?
|
||||
if [ "$prefix_rc" -eq 2 ]; then
|
||||
check "C5 prefix-only session is not an exact hit" 0
|
||||
else
|
||||
check "C5 prefix-only session is not an exact hit" 1
|
||||
fi
|
||||
|
||||
mk_server "$EXACT_SOCKET" exact-target
|
||||
if run_start --name c03-exact --session =exact-target --when false \
|
||||
--message "socket contract" --interval 10 >/dev/null 2>&1; then
|
||||
check_config "C5 =name selects the exact session despite prefix sibling" c03-exact "SOCKET=$EXACT_SOCKET"
|
||||
check_config "C5 config retains the exact =name target" c03-exact 'SESSION==exact-target'
|
||||
else
|
||||
check "C5 exact =name start succeeds" 1
|
||||
fi
|
||||
|
||||
# C6: compound input without a leading '=' is normalized to '=session:rest'
|
||||
# and discovered. This is the regression that catches constructing '==...'.
|
||||
COMPOUND_SOCKET="aw-c03-compound-$$"
|
||||
mk_server "$COMPOUND_SOCKET" compound-target
|
||||
if run_start --name c03-compound --session compound-target:0.0 --when false \
|
||||
--message "socket contract" --interval 10 >/dev/null 2>&1; then
|
||||
check_config "C6 compound session discovery pins the session component" c03-compound 'SESSION==compound-target:0.0'
|
||||
check_config "C6 compound session selects its unique socket" c03-compound "SOCKET=$COMPOUND_SOCKET"
|
||||
else
|
||||
check "C6 compound session discovery succeeds" 1
|
||||
fi
|
||||
|
||||
run_mutation() {
|
||||
local label="$1" mutation="$2"
|
||||
local mutant="$ROOT/$label-mutant.sh" output="$ROOT/$label.out"
|
||||
python3 - "$TOOL" "$mutant" "$mutation" <<'PY'
|
||||
import sys
|
||||
source, destination, mutation = sys.argv[1:]
|
||||
text = open(source, encoding="utf-8").read()
|
||||
if mutation == "env":
|
||||
old = 'if [[ -z "$socket" && -n "${MOSAIC_TMUX_SOCKET:-}" ]]; then\n'
|
||||
new = 'if false; then\n'
|
||||
else:
|
||||
old = 'tmux -L "$sname" has-session -t "$session" 2>/dev/null'
|
||||
new = 'tmux -L "$sname" has-session -t "${session#=}" 2>/dev/null'
|
||||
assert text.count(old) == 1, f"mutation anchor count for {mutation}: {text.count(old)}"
|
||||
mutant_text = text.replace(old, new)
|
||||
assert mutant_text != text
|
||||
open(destination, "w", encoding="utf-8").write(mutant_text)
|
||||
PY
|
||||
chmod 700 "$mutant"
|
||||
if cmp -s "$TOOL" "$mutant"; then
|
||||
echo "FAIL $label mutation was a no-op" >&2
|
||||
failures=$((failures + 1))
|
||||
return
|
||||
fi
|
||||
if AGENT_WATCH_TOOL_OVERRIDE="$mutant" "$BASH_BIN" "$0" --no-red-controls >"$output" 2>&1; then
|
||||
printf 'FAIL %s mutant survived (suite did not go red)\n' "$label" >&2
|
||||
failures=$((failures + 1))
|
||||
else
|
||||
printf 'ok %s mutant makes the behavioral suite red\n' "$label"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "${1:-}" != "--no-red-controls" ]; then
|
||||
# R1: moving env precedence out of its pre-discovery branch must be caught.
|
||||
run_mutation red-precedence env
|
||||
# R2: removing '=' from discovery must be caught by the exact/prefix arms.
|
||||
run_mutation red-exact exact
|
||||
fi
|
||||
|
||||
if [ "$failures" -eq 0 ]; then
|
||||
if [ "${1:-}" = "--no-red-controls" ]; then
|
||||
echo "agent-watch socket contract: contract arms OK (C1-C6)"
|
||||
else
|
||||
echo "agent-watch socket contract: all arms OK (C1-C6 + 2 red controls)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
printf 'agent-watch socket contract: %d failure(s)\n' "$failures" >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,103 @@
|
||||
# Inter-Agent tmux Comms — Standard & Tooling
|
||||
|
||||
Reliable, self-identifying messaging between Mosaic agents running in tmux panes
|
||||
(Claude Code / Codex / OpenCode REPLs), across hosts.
|
||||
|
||||
## The addressing standard (required)
|
||||
|
||||
Every cross-agent tmux message MUST begin with an addressing preamble:
|
||||
|
||||
```
|
||||
[<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
```
|
||||
|
||||
- `host` = `hostname -s` of the machine the agent runs on (e.g. `web1`, `sb-it-mgr-0-lt`).
|
||||
- `session` = the tmux session name (e.g. `mos-claude`, `rev0-4`, `installer-1`).
|
||||
- **Replies FLIP the preamble**: the recipient answers with `[<dst> -> <src>] ...`.
|
||||
|
||||
Why: a fresh or context-wiped agent always knows who sent a message and to whom.
|
||||
No ambiguity about origin or lane after a tmux wipe / session restart.
|
||||
|
||||
Example exchange:
|
||||
|
||||
```
|
||||
[web1:mos-claude -> sb-it-mgr-0-lt:installer-1] status on #29?
|
||||
[sb-it-mgr-0-lt:installer-1 -> web1:mos-claude] Q2 done, opening PR #34.
|
||||
```
|
||||
|
||||
## The helper: `agent-send.sh`
|
||||
|
||||
Prepends the preamble automatically (auto-detecting your own `host:session`) and
|
||||
delivers reliably to local OR remote panes.
|
||||
|
||||
```bash
|
||||
# Local target (same host, default tmux server)
|
||||
agent-send.sh -s <dst_session> -m "message"
|
||||
|
||||
# Local target on a Mosaic fleet socket
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "message"
|
||||
|
||||
# Remote target (over ssh)
|
||||
agent-send.sh -H user@host -s <dst_session> -m "message"
|
||||
|
||||
# From a file / stdin
|
||||
agent-send.sh -H user@host -s <dst_session> -f msg.txt
|
||||
echo "msg" | agent-send.sh -s <dst_session>
|
||||
```
|
||||
|
||||
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`
|
||||
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-fleet ls
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "status?"
|
||||
send-message.sh -L mosaic-fleet -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)
|
||||
|
||||
Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a
|
||||
trailing `Enter` is frequently swallowed and the message sits as an **unsubmitted
|
||||
draft** ("Press up to edit queued messages"). Over an `ssh -> nested tmux` hop the
|
||||
plain `Enter` keyname often does not register at all — `C-m` is needed.
|
||||
|
||||
`send-message.sh` solves this for a **local** pane: bracketed-paste the body
|
||||
(so multi-line content doesn't submit early), pause, then send `Enter` as its own
|
||||
keystroke and flush with a second, verifying against a draft heuristic.
|
||||
|
||||
`agent-send.sh` solves the **remote** case by _shipping `send-message.sh` over ssh_
|
||||
(`ssh host bash -s -- ... < send-message.sh`) and running it local to the target
|
||||
pane — so the reliable send-keys always happens on the pane's own host. The remote
|
||||
needs only `bash` + `tmux` + `base64`; **no mosaic install required there**. The
|
||||
message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
||||
|
||||
## Files
|
||||
|
||||
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
||||
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
|
||||
drafts on a coordinator pane (default target `mos-claude`); run it as a
|
||||
long-lived process alongside the coordinator session.
|
||||
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
|
||||
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
||||
|
||||
## Distribution
|
||||
|
||||
These live in the installed framework copy at
|
||||
`~/.mosaic/tools/tmux/`. `install.sh` rsyncs the framework **source tree**
|
||||
to each host, so to propagate permanently, land both files in the framework
|
||||
source repo and re-run the installer on each host. Until then, `agent-send.sh`
|
||||
already works against any reachable host because it ships `send-message.sh` over
|
||||
ssh per-send — no pre-install on the target host is needed to _send to_ it.
|
||||
Executable
+229
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-send.sh — standard inter-agent tmux messaging for the Mosaic stack.
|
||||
#
|
||||
# WHAT IT DOES
|
||||
# Sends a message to another agent's tmux pane (local or on a remote host)
|
||||
# with the canonical addressing preamble prepended:
|
||||
#
|
||||
# [<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
#
|
||||
# The preamble makes every inter-agent message self-identifying, so a fresh
|
||||
# or context-wiped agent always knows who sent a message and to whom — no
|
||||
# ambiguity about lanes or origin. Recipients replying should FLIP the
|
||||
# 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
|
||||
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
|
||||
# a trailing Enter is often swallowed and the message sits as an unsubmitted
|
||||
# DRAFT. tools/tmux/send-message.sh already solves that for a LOCAL pane via
|
||||
# bracketed-paste + Enter-flush + draft-detection. For REMOTE targets this
|
||||
# wrapper SHIPS send-message.sh over ssh (stdin) and runs it there, so the
|
||||
# reliable send-keys happens local to the target pane — sidestepping the
|
||||
# ssh->nested-tmux Enter/C-m swallow entirely. No mosaic install needed on
|
||||
# the remote host; only bash + tmux + base64 (standard).
|
||||
#
|
||||
# USAGE
|
||||
# agent-send.sh [-L socket] -s <dst_session> -m "message" # local target
|
||||
# agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target
|
||||
# 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>
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
|
||||
#
|
||||
# Exit 4: local target session exists on multiple socket servers and no
|
||||
# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard).
|
||||
# -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
|
||||
# -n DST_HOST hostname to show in the preamble for the target.
|
||||
# Default: local hostname, or (remote) resolved via one ssh.
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -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
|
||||
# digest machine-wake, coalescible; batched wake/heartbeat signal
|
||||
# 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)
|
||||
# -r N Enter-flush attempts passed through (default 2)
|
||||
# -v verbose: print pane tail after delivery
|
||||
# -h help
|
||||
#
|
||||
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
|
||||
# ^\[(\S+) -> (\S+?)(?: class=(terminal-log|actionable|human|reaction|digest))?\] (.*)$
|
||||
# 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, except 4)
|
||||
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
|
||||
# 4 agent-send refusal: local target session exists on multiple socket
|
||||
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
|
||||
set -uo pipefail
|
||||
|
||||
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
# Sender is overridable via env purely for testing (inject a capture stub). The
|
||||
# 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
|
||||
# short-option-only) can parse it. Every other argument passes through untouched,
|
||||
# so callers that never use --class hit the exact original getopts path.
|
||||
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=""; SOCKET_NAME=""
|
||||
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 "L:s:H:n:m:f:S:r:C:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$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 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -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; }
|
||||
|
||||
# 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|digest) CLASS_TOKEN=" class=${CLASS}" ;;
|
||||
*) echo "ERROR: invalid --class '$CLASS' (allowed: terminal-log, actionable, human, reaction, digest)" >&2; exit 3 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Message body from -f / -m / stdin.
|
||||
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)
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||
|
||||
# Source label: this agent's host:session (auto-detected, overridable).
|
||||
if [ -z "$SRC_LABEL" ]; then
|
||||
src_host=$(hostname -s 2>/dev/null || echo "?")
|
||||
src_sess=${MOSAIC_AGENT_NAME:-}
|
||||
if [ -z "$src_sess" ]; then
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
# Inside tmux: display-message resolves against this client's own session.
|
||||
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||
else
|
||||
# Outside tmux with no name: display-message reports the LAST-ACTIVE
|
||||
# session — someone else's identity (measured 2026-08-20: a nameless
|
||||
# non-tmux sender was stamped "peggy", a live seat, forged silently).
|
||||
# Stamp an explicit unverified label instead; deliberate senders use -S.
|
||||
src_sess="unverified"
|
||||
fi
|
||||
fi
|
||||
SRC_LABEL="${src_host}:${src_sess}"
|
||||
fi
|
||||
|
||||
# Destination host label for the preamble.
|
||||
if [ -z "$DST_HOST" ]; then
|
||||
if [ -n "$SSH_TARGET" ]; then
|
||||
DST_HOST=$(ssh -o ConnectTimeout=8 -o BatchMode=yes "$SSH_TARGET" 'hostname -s' 2>/dev/null || echo "${SSH_TARGET#*@}")
|
||||
else
|
||||
DST_HOST=$(hostname -s 2>/dev/null || echo "local")
|
||||
fi
|
||||
fi
|
||||
|
||||
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}${CLASS_TOKEN}]"
|
||||
FULL="${PREAMBLE} ${MSG}"
|
||||
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||
|
||||
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
||||
|
||||
# Exact session matching for the sender target (codex PR #1466): without
|
||||
# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery
|
||||
# aimed at session X can land in X-old. Compound targets (session:win.pane)
|
||||
# and already-exact ('=...') forms pass through untouched. Computed BEFORE
|
||||
# socket discovery so the discovery probes use the same target semantics
|
||||
# (probing '==name' for an already-exact input was a false-negative hit).
|
||||
DST_TARGET="$DST_SESSION"
|
||||
case "$DST_SESSION" in
|
||||
=*) ;;
|
||||
*:*)
|
||||
# Compound target (session:win.pane): pin the SESSION component exact
|
||||
# (=session:win.pane); unpinned, the session part still prefix-matches
|
||||
# (codex PR #1466: 'agent:0.0' can resolve into 'agent-old').
|
||||
DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}"
|
||||
;;
|
||||
*) DST_TARGET="=$DST_SESSION" ;;
|
||||
esac
|
||||
|
||||
# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L >
|
||||
# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on
|
||||
# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when
|
||||
# no explicit or env choice exists and the session name lives on multiple
|
||||
# servers (measured 2026-08-28/29: tasking sends landed in a stale
|
||||
# default-socket twin; rc 0 reported honest delivery to the wrong pane).
|
||||
# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID
|
||||
# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets).
|
||||
# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not
|
||||
# leak into remote sends, where -L would target a socket on the remote
|
||||
# host (codex PR #1466).
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
|
||||
socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
|
||||
hits=""
|
||||
for sf in "$socket_dir"/*; do
|
||||
[ -S "$sf" ] || continue
|
||||
sname="${sf##*/}"
|
||||
# '=' forces exact session-name matching: tmux target syntax otherwise
|
||||
# accepts an unambiguous PREFIX, so a session named X-old on a socket
|
||||
# would count as a false hit for target X (codex PR #1466).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" 2>/dev/null && hits="$hits$sname"$'\n'
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | grep -c . || true)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2
|
||||
printf ' %s\n' $hits >&2
|
||||
echo " Pass -L <socket> explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2
|
||||
exit 4
|
||||
elif [ "$hit_count" -eq 1 ]; then
|
||||
SOCKET_NAME="$(printf '%s' "$hits")"
|
||||
fi
|
||||
fi
|
||||
|
||||
socket_args=()
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
socket_args=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
if [ -z "$SSH_TARGET" ]; then
|
||||
# Local pane: call the canonical sender directly.
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
|
||||
else
|
||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
fi
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/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.
|
||||
# 8. MOSAIC_AGENT_NAME is authoritative for sender identity.
|
||||
# 9. sender fallback queries local tmux, never the destination -L socket.
|
||||
# 10. an undeterminable sender is stamped as "?".
|
||||
# 11. --class digest is accepted (machine-wake, coalescible canon class).
|
||||
# 12. -C digest short form parses identically.
|
||||
# 13. the documented consumer regex parses producer output for class=digest.
|
||||
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)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT
|
||||
cat >"$STUB" <<'STUB_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
b64=""
|
||||
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
||||
printf '%s' "$b64" | base64 -d
|
||||
STUB_EOF
|
||||
chmod +x "$STUB"
|
||||
|
||||
# Fake tmux distinguishes the sender's default socket from a destination socket.
|
||||
cat >"$FAKE_BIN/tmux" <<'TMUX_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
case "${FAKE_TMUX_MODE:-sessions}" in
|
||||
unavailable) exit 1 ;;
|
||||
sessions)
|
||||
if [ "${1:-}" = "-L" ]; then
|
||||
printf '%s\n' 'destination-holder'
|
||||
else
|
||||
printf '%s\n' 'local-agent'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
TMUX_EOF
|
||||
chmod +x "$FAKE_BIN/tmux"
|
||||
|
||||
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 "$@"; }
|
||||
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
|
||||
# depend on whether the caller running this suite sits inside tmux.
|
||||
run_auto() { # models a sender OUTSIDE tmux (no client context)
|
||||
env -u MOSAIC_AGENT_NAME -u TMUX \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
run_auto_in_tmux() { # models a sender INSIDE tmux (client context exists)
|
||||
env -u MOSAIC_AGENT_NAME TMUX=/fake/socket \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
|
||||
# Documented consumer grammar — the daemon will mirror exactly this.
|
||||
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction|digest)\] (.*)$'
|
||||
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
|
||||
|
||||
# 11. --class digest (space form) is accepted.
|
||||
got=$(run -s mos --class digest -m "wake payload")
|
||||
want='[a:src -> dsthost:mos class=digest] wake payload'
|
||||
if [ "$got" = "$want" ]; then ok "--class digest emits token"
|
||||
else no "--class digest emits token" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 12. -C digest short form.
|
||||
got=$(run -s mos -C digest -m "coalesced wake")
|
||||
want='[a:src -> dsthost:mos class=digest] coalesced wake'
|
||||
if [ "$got" = "$want" ]; then ok "-C digest (short form)"
|
||||
else no "-C digest (short form)" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 7. consumer grammar parses every class + classic line.
|
||||
for c in terminal-log actionable human reaction digest; 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]"
|
||||
|
||||
# 8. Exported pane identity wins even when dispatch targets another tmux socket.
|
||||
src_host=$(hostname -s)
|
||||
got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity")
|
||||
want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|
||||
[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \
|
||||
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
|
||||
|
||||
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
|
||||
# Sender is INSIDE tmux: the only context where display-message self-lookup
|
||||
# is safe (it resolves against this client's own session).
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto_in_tmux -L destination-socket -s mos -m "local fallback")
|
||||
want="[$src_host:local-agent -> dsthost:mos] local fallback"
|
||||
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|
||||
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
|
||||
[[ "$got" != *":destination-holder ->"* ]] \
|
||||
&& ok "cross-socket fallback rejects destination holder identity" \
|
||||
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
|
||||
|
||||
# 9b. NO tmux context: display-message answers with the LAST-ACTIVE session —
|
||||
# someone else's identity (forgery vector). The label must be `unverified`,
|
||||
# never a borrowed name, even though a tmux server exists here and the fake
|
||||
# would confidently answer `local-agent`.
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto -s mos -m "no tmux context")
|
||||
want="[$src_host:unverified -> dsthost:mos] no tmux context"
|
||||
[ "$got" = "$want" ] && ok "no-tmux sender labeled unverified, never borrowed" \
|
||||
|| no "no-tmux sender labeled unverified, never borrowed" "got=[$got] want=[$want]"
|
||||
|
||||
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
|
||||
got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback")
|
||||
want="[$src_host:? -> dsthost:mos] unknown fallback"
|
||||
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|
||||
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# auto-submit-drafts.sh — watchdog for Claude Code panes that receive channel
|
||||
# messages but leave them as unsubmitted prompt drafts. Intended for Mos only.
|
||||
set -uo pipefail
|
||||
|
||||
TARGET="${1:-mos-claude}"
|
||||
INTERVAL="${INTERVAL:-2}"
|
||||
STABLE_SECONDS="${STABLE_SECONDS:-4}"
|
||||
LOG_PREFIX="[auto-submit-drafts:$TARGET]"
|
||||
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
|
||||
prompt_text() {
|
||||
tmux capture-pane -t "$TARGET" -p 2>/dev/null | python3 -c '
|
||||
import sys, re
|
||||
lines = sys.stdin.read().splitlines()
|
||||
idx = None
|
||||
for i in range(len(lines)-1, -1, -1):
|
||||
if "❯" in lines[i]:
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
raise SystemExit
|
||||
parts = []
|
||||
after = lines[idx].split("❯", 1)[1]
|
||||
parts.append(after)
|
||||
for line in lines[idx+1:]:
|
||||
# Stop at Claude Code separator/border lines.
|
||||
if "─" in line or "╰" in line or "╭" in line:
|
||||
break
|
||||
s = line.replace("\u00a0", " ")
|
||||
s = re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
|
||||
if s:
|
||||
parts.append(s)
|
||||
text = " ".join(parts).replace("\u00a0", " ")
|
||||
text = re.sub(r"[\x00-\x1f\x7f]", "", text).strip()
|
||||
print(text)
|
||||
'
|
||||
}
|
||||
|
||||
while true; do
|
||||
if ! tmux has-session -t "$TARGET" 2>/dev/null; then
|
||||
echo "$LOG_PREFIX target missing; waiting" >&2
|
||||
sleep "$INTERVAL"
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
continue
|
||||
fi
|
||||
|
||||
current="$(prompt_text || true)"
|
||||
now="$(date +%s)"
|
||||
|
||||
if [[ -z "$current" ]]; then
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
sleep "$INTERVAL"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$current" != "$last_prompt" ]]; then
|
||||
last_prompt="$current"
|
||||
first_seen="$now"
|
||||
sleep "$INTERVAL"
|
||||
continue
|
||||
fi
|
||||
|
||||
age=$(( now - first_seen ))
|
||||
if (( age >= STABLE_SECONDS )); then
|
||||
echo "$LOG_PREFIX submitting stable draft after ${age}s: ${current:0:120}" >&2
|
||||
tmux send-keys -t "$TARGET" C-j
|
||||
sleep 0.8
|
||||
tmux send-keys -t "$TARGET" C-m
|
||||
sleep 2
|
||||
last_prompt=""
|
||||
first_seen=0
|
||||
else
|
||||
sleep "$INTERVAL"
|
||||
fi
|
||||
done
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# send-message.sh — reliably deliver a message to a tmux pane running an
|
||||
# interactive REPL (e.g. a Claude Code / Codex agent).
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# Pasting multi-line text into an interactive agent REPL via `tmux send-keys`
|
||||
# is unreliable: the text lands in the input box but a single trailing Enter
|
||||
# in the same keystroke stream is frequently swallowed, so the message sits as
|
||||
# an UNSUBMITTED DRAFT ("Press up to edit queued messages") and the agent never
|
||||
# sees it. The mechanical fix is: paste as a bracketed paste (so embedded
|
||||
# newlines don't submit early), pause, then send Enter as its OWN keystroke,
|
||||
# pause, and send Enter again to flush. An extra Enter on an empty prompt is a
|
||||
# no-op in Claude Code, so the double-Enter is safe.
|
||||
#
|
||||
# USAGE
|
||||
# send-message.sh [-L socket_name] -t <target> -m "message"
|
||||
# send-message.sh [-L socket_name] -t <target> -f <file>
|
||||
# echo "message" | send-message.sh [-L socket_name] -t <target>
|
||||
# ssh host bash -s -- -L socket -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` (optional)
|
||||
# -t TARGET tmux target: session, or session:window.pane [required]
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
# -b BASE64 message as base64 (ssh-safe transport; decoded internally)
|
||||
# -r N Enter-flush attempts (default 2)
|
||||
# -v verbose: print a short tail of the pane after delivery
|
||||
# -h help
|
||||
#
|
||||
# EXIT CODES
|
||||
# 0 delivered (submitted) or queued (agent busy; will process when free)
|
||||
# 1 tmux target not found
|
||||
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
||||
# input box could not be located to confirm the message actually landed.
|
||||
# Locating the box is runtime-specific; see locate_input_box() below, and
|
||||
# add a shape there before pointing this tool at a new runtime.
|
||||
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
||||
# see the input box clear of the message (or the queued banner), we fail loud
|
||||
# so the sender learns immediately instead of a silent worker->lead stall.
|
||||
# 3 usage error
|
||||
set -uo pipefail
|
||||
|
||||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
|
||||
usage() { sed -n '2,34p' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:t:m:f:b:r:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$TARGET" ] || { echo "ERROR: -t TARGET is required" >&2; usage 3; }
|
||||
if [ -n "$B64" ]; then MSG=$(printf '%s' "$B64" | base64 -d) || { echo "ERROR: bad -b base64" >&2; exit 3; }
|
||||
elif [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
fi
|
||||
[ -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.
|
||||
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux target not found: $TARGET" >&2; exit 1
|
||||
fi
|
||||
|
||||
QUEUED_RE='Press up to edit queued messages'
|
||||
# A distinctive tail of the message to spot an unsubmitted draft on the input line.
|
||||
snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g' | tail -c 32)
|
||||
|
||||
# 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`.
|
||||
# Buffer name MUST be unique per invocation: concurrent senders on the shared
|
||||
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
|
||||
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
|
||||
# simultaneous restart (briefs swapped between sessions).
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
||||
# -p = bracketed paste when the client supports it; fall back if not.
|
||||
"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|
||||
|| "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
|
||||
# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail
|
||||
# (e.g. the target vanished since the liveness check), delete explicitly —
|
||||
# named buffers are exempt from tmux's buffer-limit eviction, so orphans
|
||||
# would otherwise accumulate forever.
|
||||
sleep 0.5
|
||||
|
||||
# Locate the REPL input box in a captured pane. Prints the box's contents on
|
||||
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
|
||||
# located at all. Found-but-empty is a real, distinct answer (an empty input box
|
||||
# is what a submitted message leaves behind), so the caller must branch on the
|
||||
# return code, never on whether the output is empty.
|
||||
#
|
||||
# Two REPL shapes are recognised:
|
||||
# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most
|
||||
# readline REPLs.
|
||||
# * a box drawn as two horizontal `─` rules with the input between them and NO
|
||||
# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is
|
||||
# what makes it safe: agent output can contain its own rules, but nothing is
|
||||
# drawn below the input box except the status line.
|
||||
#
|
||||
# Adding a runtime means adding its shape HERE. A shape that is missing does not
|
||||
# degrade gracefully: it turns every send to that runtime into a false
|
||||
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
|
||||
# arm of the same probe.
|
||||
locate_input_box() {
|
||||
local pane=$1 glyph_line rule_lines top bottom
|
||||
glyph_line=$(printf '%s\n' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||
if [ -n "$glyph_line" ]; then printf '%s\n' "$glyph_line"; return 0; fi
|
||||
rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*─{4,}[[:space:]]*$' | cut -d: -f1 | tail -2)
|
||||
[ -n "$rule_lines" ] || return 1
|
||||
# Split the (at most two) captured line numbers with parameter expansion. Not
|
||||
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
|
||||
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
|
||||
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
|
||||
# With one rule captured both halves resolve to the same value and the
|
||||
# ordering test below rejects it, which is the answer we want anyway.
|
||||
top=${rule_lines%%$'\n'*}
|
||||
bottom=${rule_lines##*$'\n'}
|
||||
[ "$top" != "$bottom" ] || return 1
|
||||
[ "$bottom" -gt "$top" ] || return 1
|
||||
# An empty range (adjacent rules) prints nothing and still returns 0: found,
|
||||
# empty, which is the delivered shape.
|
||||
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
|
||||
return 0
|
||||
}
|
||||
|
||||
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
|
||||
# still a draft. Success requires positive evidence — the queued banner, OR the
|
||||
# REPL input box located AND clear of our message tail. The historical bug was
|
||||
# treating ABSENCE of a draft as delivery: if the input box was never located
|
||||
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
|
||||
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
|
||||
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
|
||||
status="unconfirmed"
|
||||
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
||||
sleep 1.2
|
||||
pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
|
||||
|
||||
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||
status="queued"; break
|
||||
fi
|
||||
# If we cannot see the input box, we have NO evidence of submission state —
|
||||
# stay UNCONFIRMED and retry; never infer delivery.
|
||||
if ! inputbox=$(locate_input_box "$pane"); then
|
||||
status="unconfirmed"; continue
|
||||
fi
|
||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
||||
# (Submitted messages scroll up into history; a draft stays in the box.)
|
||||
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
|
||||
status="draft"; continue
|
||||
fi
|
||||
# Input box located AND clear of our tail => positively submitted. This is the
|
||||
# only path to success besides the queued banner.
|
||||
status="delivered"; break
|
||||
done
|
||||
|
||||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||
|
||||
case "$status" in
|
||||
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
||||
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
|
||||
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
|
||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
||||
esac
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-agent-send-socket-live.sh — S2 v2 INDEPENDENT contract validation (P5).
|
||||
#
|
||||
# Author: code-be-02 (fresh-seat; derives from the DOCUMENTED CONTRACT of PR
|
||||
# #1466's socket resolution, deliberately not from test-send-message-socket.sh's
|
||||
# structure — marcie's arms cover the implementation, these cover the contract).
|
||||
#
|
||||
# LIVE tmux fixtures on PRIVATE scratch sockets under a scratch TMUX_TMPDIR:
|
||||
# the discovery loop reads ${TMUX_TMPDIR:-/tmp}/tmux-UID, so pointing
|
||||
# TMUX_TMPDIR at a scratch dir makes production sockets (mosaic-fleet included)
|
||||
# invisible to the tested process. Live tmux semantics ('=' targets, prefix
|
||||
# matching, socket dirs) are exercised for real.
|
||||
#
|
||||
# Contract under test (agent-send.sh, canonical usage/EXIT CODES sections):
|
||||
# C1 explicit -L wins over MOSAIC_TMUX_SOCKET; when pinned, discovery is
|
||||
# skipped ENTIRELY (zero has-session probes, not merely zero hits)
|
||||
# C2 MOSAIC_TMUX_SOCKET applies when no -L (local sends only)
|
||||
# C3 session on multiple sockets with no -L/env -> refusal rc 4, message
|
||||
# names the conflicting sockets and the -L hint; nothing sent
|
||||
# C4 socket discovery reads TMUX_TMPDIR (never plain TMPDIR)
|
||||
# C5 no unique hit -> default socket (sender invoked with no -L);
|
||||
# remote (-H) sends do NO local discovery and do not forward the env
|
||||
# C6 '=name' targets match exactly (no prefix); explicit '=X' passes
|
||||
# through verbatim; compound 'sess:win.pane' pins the session component
|
||||
# exact ('=sess:win.pane')
|
||||
#
|
||||
# Seams: AGENT_SEND_SENDER (intended stub seam) captures the sender args;
|
||||
# a PATH-front tmux wrapper logs probes then execs the real binary; a PATH
|
||||
# ssh stub captures the remote command line. Sabotage controls prove the
|
||||
# arms bind: moved env-default -> C2 red; dropped exit-4 -> C3 red.
|
||||
# Skip rc 77 without a tmux binary. Scratch servers killed via trap.
|
||||
set -uo pipefail
|
||||
|
||||
# NOTE: running a COPY of this suite from another directory resolves TOOL next
|
||||
# to the COPY (readlink -f) — agent-send.sh must sit beside it, or set
|
||||
# AGENT_SEND_TOOL_OVERRIDE. Debugging artifact of the here-relative design.
|
||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
TOOL="${AGENT_SEND_TOOL_OVERRIDE:-$HERE/agent-send.sh}"
|
||||
REAL_TMUX="$(command -v tmux 2>/dev/null || true)"
|
||||
[ -n "$REAL_TMUX" ] || { echo "SKIP: no tmux binary (live fixtures impossible)"; exit 77; }
|
||||
|
||||
SCRATCH="$(mktemp -d)"; SCRATCH="$(cd "$SCRATCH" && pwd)" # absolute (marcie input b)
|
||||
DECOY="$(mktemp -d)"; DECOY="$(cd "$DECOY" && pwd)"
|
||||
mkdir -p "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
# tmux refuses socket dirs with group/other bits ('unsafe permissions'):
|
||||
# mktemp -d is 0700 but mkdir'd children default to umask (0755) — pin 0700
|
||||
chmod 700 "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
BIN="$SCRATCH/bin"; mkdir -p "$BIN"
|
||||
CAP="$SCRATCH/sender-captured"; PROBES="$SCRATCH/tmux-probes"; SSHLOG="$SCRATCH/ssh-captured"
|
||||
: > "$PROBES"
|
||||
|
||||
# sender stub: capture args, "send" nothing (socket/target choice is the test)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" > %s\nexit 0\n' "$CAP" > "$BIN/sender-stub"
|
||||
# tmux wrapper: log invocations, exec the real binary (live semantics)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> %s\nexec %s "$@"\n' "$PROBES" "$REAL_TMUX" > "$BIN/tmux"
|
||||
# ssh stub: capture the remote command line; swallow stdin (the sender script)
|
||||
printf '#!/usr/bin/env bash\nprintf "SSH:%%s\\n" "$*" >> %s\ncat > /dev/null\nexit 0\n' "$SSHLOG" > "$BIN/ssh"
|
||||
chmod +x "$BIN/sender-stub" "$BIN/tmux" "$BIN/ssh"
|
||||
|
||||
sock_pid_a=""; sock_pid_b=""
|
||||
cleanup() {
|
||||
[ -n "$sock_pid_a" ] && kill "$sock_pid_a" 2>/dev/null
|
||||
for s in sockA sockB sockX decoyD; do
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null
|
||||
TMUX_TMPDIR="$DECOY" "$REAL_TMUX" -L "decoyD" kill-server 2>/dev/null
|
||||
done
|
||||
rm -rf "$SCRATCH" "$DECOY"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mk_server() { # $1 socket, $2 session-name, $3 dir (SCRATCH|DECOY)
|
||||
TMUX_TMPDIR="${3:?}" "$REAL_TMUX" -L "$1" new-session -d -s "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
run() { # passes through; caller sets env per arm
|
||||
PATH="$BIN:$PATH" AGENT_SEND_SENDER="$BIN/sender-stub" MOSAIC_AGENT_NAME=code-be-02 \
|
||||
bash "$TOOL" -S test:src "$@"
|
||||
}
|
||||
|
||||
probe_count() { grep -c "has-session" "$PROBES" || true; }
|
||||
cap_has() { grep -qF -e "$1" "$CAP" 2>/dev/null; }
|
||||
|
||||
fail=0
|
||||
ck() { if [ "$2" -eq 0 ]; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi; }
|
||||
probes_reset() { : > "$PROBES"; }
|
||||
cap_reset() { rm -f "$CAP"; }
|
||||
|
||||
# --- fixtures: sockA=t1, sockB=t1 (same name, two sockets), sockX=t1 ------------
|
||||
mk_server sockA t1 "$SCRATCH"
|
||||
mk_server sockB t1 "$SCRATCH"
|
||||
mk_server sockX t1 "$SCRATCH"
|
||||
|
||||
# --- C1: explicit -L beats env; discovery skipped entirely -----------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -L sockX -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L sockX" && ! grep -qF -- "-L envsock" "$CAP"
|
||||
ck "C1: explicit -L wins over MOSAIC_TMUX_SOCKET (sender got -L sockX, not envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C1: pinned -L skips discovery ENTIRELY (0 has-session probes, not 0 hits)" $?
|
||||
|
||||
# --- C2: env applies when no -L; discovery skipped -------------------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L envsock"
|
||||
ck "C2: MOSAIC_TMUX_SOCKET used when no -L (sender got -L envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C2: env pin skips discovery (0 probes)" $?
|
||||
|
||||
# --- C3: multi-socket ambiguity refuses rc 4, names sockets, sends nothing -------
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
err="$(TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 4 ]
|
||||
ck "C3: ambiguous session (no -L/env) refuses with rc 4 (contract-stable)" $?
|
||||
echo "$err" | grep -q "multiple sockets" && echo "$err" | grep -qF "sockA" && echo "$err" | grep -qF "sockB"
|
||||
ck "C3: refusal message names BOTH conflicting sockets (sockA, sockB)" $?
|
||||
echo "$err" | grep -qF -- "-L"
|
||||
ck "C3: refusal message carries the -L disambiguation hint" $?
|
||||
[ ! -f "$CAP" ]
|
||||
ck "C3: nothing sent on refusal (sender never invoked)" $?
|
||||
|
||||
# --- C4: discovery reads TMUX_TMPDIR, never plain TMPDIR -------------------------
|
||||
# decoy server lives under $DECOY/tmux-UID; TMPDIR points there, TMUX_TMPDIR at $SCRATCH
|
||||
mk_server decoyD onlydecoy "$DECOY"
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s onlydecoy -m hi >/dev/null 2>&1
|
||||
! cap_has "-L decoyD"
|
||||
ck "C4: a TMPDIR-only socket is NOT consulted (no -L decoyD despite TMPDIR=decoy)" $?
|
||||
# and a session unique in the TMUX_TMPDIR tree IS discovered there
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -ge 2 ]
|
||||
ck "C4: TMUX_TMPDIR tree probed when unpinned (discovery active; ambiguous name exercises the probe loop)" $?
|
||||
|
||||
# --- C5: no unique hit -> default socket; remote sends: no local resolution ------
|
||||
# kill sockA/sockB/sockX so the scratch tree holds only decoy-free empties
|
||||
for s in sockA sockB sockX; do TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null; done
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C5: zero unique hit -> default socket (sender invoked with NO -L)" $?
|
||||
cap_reset; probes_reset
|
||||
rm -f "$SSHLOG"
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -H user@fakehost -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C5: remote send does NO local discovery (0 probes with -H)" $?
|
||||
[ -f "$SSHLOG" ] && ! grep -qF -- "-L envsock" "$SSHLOG"
|
||||
ck "C5: MOSAIC_TMUX_SOCKET not forwarded to remote (ssh line carries no -L envsock)" $?
|
||||
|
||||
# --- C6: '=name' exact matching; verbatim '=X'; compound pinning -----------------
|
||||
mk_server sockA t1old "$SCRATCH" # ONLY t1old exists now
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C6: t1 does NOT prefix-match t1old ('=t1' probe exact; zero hit -> default)" $?
|
||||
grep -qF 'has-session -t =t1' "$PROBES"
|
||||
ck "C6: discovery probes used the exact ('=t1') target form" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s =t1old -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old' "$CAP"
|
||||
ck "C6: already-exact '=X' input passes through verbatim" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s t1old:0.0 -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old:0.0' "$CAP"
|
||||
ck "C6: compound 'sess:win.pane' pins the session component exact (=sess:0.0)" $?
|
||||
|
||||
# --- red controls: the arms bind --------------------------------------------------
|
||||
SAB="$SCRATCH/agent-send-sabotaged.sh"
|
||||
# (a) move the env-default AFTER discovery: C2 must go red
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
envblk = '''if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
'''
|
||||
assert s.count(envblk) == 1
|
||||
s2 = s.replace(envblk, "")
|
||||
anchor = 'socket_args=()'
|
||||
assert s.count(anchor) == 1
|
||||
s2 = s2.replace(anchor, envblk + anchor)
|
||||
assert s2 != s
|
||||
open(dst, "w").write(s2)
|
||||
PY
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if cap_has "-L envsock"; then ck "red-a: sabotaged precedence (env moved after discovery) is CAUGHT by C2 shape" 0; else ck "red-a: sabotaged precedence CAUGHT (envsock lost -> discovered/default socket used)" 0; fi
|
||||
# control validity: with sabotage, the SABOTAGED tool must NOT pin envsock with 0 probes
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if [ "$(probe_count)" -gt 0 ] || ! cap_has "-L envsock"; then
|
||||
ck "red-a validity: sabotage effective (behavior differs from clean tool)" 0
|
||||
else
|
||||
ck "red-a validity: sabotage was a NO-OP — control invalid" 1
|
||||
fi
|
||||
# (b) drop the exit 4: C3 must go red (send proceeds instead of refusing)
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
old = " exit 4\n"
|
||||
assert s.count(old) == 1
|
||||
s = s.replace(old, " :\n")
|
||||
open(dst, "w").write(s)
|
||||
PY
|
||||
mk_server sockB t1old "$SCRATCH" # second socket carrying the same name -> ambiguity shape
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1; src_rc=$?
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L sockB kill-server 2>/dev/null
|
||||
if [ "$src_rc" -eq 4 ]; then
|
||||
ck "red-b: sabotaged refusal still exits 4 — sabotage was a NO-OP, control invalid" 1
|
||||
else
|
||||
ck "red-b: sabotage effective (exit 4 dropped; rc=$src_rc) — C3 pins what the clean tool restores" 0
|
||||
fi
|
||||
|
||||
# --- verdict -----------------------------------------------------------------------
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "agent-send socket contract (live): all arms OK (C1-C6 + both red controls)"
|
||||
exit 0
|
||||
fi
|
||||
echo "agent-send socket contract (live): FAILURES above"
|
||||
exit 1
|
||||
Executable
+200
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live tmux semantics on private sockets only. A caller may run this suite from
|
||||
# inside mosaic-fleet, where inherited TMUX otherwise overrides TMUX_TMPDIR for
|
||||
# every bare tmux command. Clear pane context and keep both the named and
|
||||
# default fixtures below one scratch TMUX_TMPDIR.
|
||||
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)
|
||||
TEST_TMUX_TMPDIR="$TMPDIR/tmux"
|
||||
mkdir -p "$TEST_TMUX_TMPDIR"
|
||||
chmod 700 "$TEST_TMUX_TMPDIR"
|
||||
unset TMUX TMUX_PANE
|
||||
export TMUX_TMPDIR="$TEST_TMUX_TMPDIR"
|
||||
ART_OUT=$(mktemp)
|
||||
AMB_OUT=$(mktemp)
|
||||
AMB_ERR=$(mktemp)
|
||||
A2_OUT=$(mktemp)
|
||||
A2_ERR=$(mktemp)
|
||||
UNIQ_OUT=$(mktemp)
|
||||
UNIQ_ERR=$(mktemp)
|
||||
TWIN="twin-$RANDOM-$$"
|
||||
cleanup() {
|
||||
local test_rc=$? residue=0
|
||||
trap - EXIT
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default kill-server >/dev/null 2>&1 || true
|
||||
sleep 0.2
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: named scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: default scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
rm -rf "$TMPDIR" "$ART_OUT" "$AMB_OUT" "$AMB_ERR" "$A2_OUT" "$A2_ERR" "$UNIQ_OUT" "$UNIQ_ERR"
|
||||
if [ "$test_rc" -ne 0 ]; then
|
||||
exit "$test_rc"
|
||||
fi
|
||||
exit "$residue"
|
||||
}
|
||||
trap cleanup 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" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "named socket hello" <<<"$default_pane"; 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
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble"
|
||||
grep -qF "agent socket hello" <<<"$named_pane" || fail "agent-send.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "agent socket hello" <<<"$default_pane"; then
|
||||
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
# Concurrency: parallel senders on one server must not cross-deliver or drop.
|
||||
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
|
||||
# load overwrote load, -d deleted underneath — messages swapped between panes).
|
||||
CONC_N=5
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
done
|
||||
pids=()
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
|
||||
pids+=($!)
|
||||
done
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
|
||||
done
|
||||
sleep 0.2
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
|
||||
grep -qF "CONCPAYLOAD-${i}-END" <<<"$pane" \
|
||||
|| fail "concurrent send dropped payload for pane conc-$i"
|
||||
for j in $(seq 1 "$CONC_N"); do
|
||||
[ "$j" = "$i" ] && continue
|
||||
if grep -qF "CONCPAYLOAD-${j}-END" <<<"$pane"; then
|
||||
fail "concurrent send cross-delivered payload $j to pane conc-$i"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured
|
||||
# defect: tasking sends without -L landed in a stale default-socket twin of
|
||||
# the target seat; rc 0 reported honest delivery to the wrong pane.
|
||||
|
||||
# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both.
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
amb_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$?
|
||||
[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))"
|
||||
grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list"
|
||||
grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket"
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous:
|
||||
# the env var disambiguates by precedence (codex PR #1466 blocker).
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
a2_rc=0
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$?
|
||||
[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))"
|
||||
sleep 0.2
|
||||
a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)"
|
||||
grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket"
|
||||
a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true
|
||||
if grep -qF "env disambiguated" <<<"$a2_default"; then
|
||||
fail "env disambiguation cross-delivered to the default-socket twin"
|
||||
fi
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket
|
||||
# and deliver there.
|
||||
# Arm A3: prefix matching must not produce false socket hits (codex PR
|
||||
# #1466): a session named TWIN-old must not count as a hit for target
|
||||
# TWIN (tmux target syntax prefix-matches without '=').
|
||||
PSEUDO="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A3_ERR=$(mktemp)
|
||||
a3_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$?
|
||||
# TWIN exists nowhere (both twins killed after arm A2); with '=' the
|
||||
# PSEUDO session is not a hit, so the sender must fail target-not-found
|
||||
# (rc 1) instead of delivering into the prefix-named session.
|
||||
[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null | grep -qF "prefix trap"; then
|
||||
fail "delivery landed in the prefix-named session (false socket hit)"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true
|
||||
rm -f "$A3_ERR"
|
||||
|
||||
# Arm A4: compound targets pin the SESSION component exact (codex PR
|
||||
# #1466): 'TWIN:0.0' must not resolve into the prefix-named session.
|
||||
PSEUDO2="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A4_ERR=$(mktemp)
|
||||
a4_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$?
|
||||
[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null | grep -qF "compound trap"; then
|
||||
fail "compound delivery landed in the prefix-named session"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true
|
||||
rm -f "$A4_ERR"
|
||||
|
||||
uniq_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$?
|
||||
[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))"
|
||||
sleep 0.2
|
||||
auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)"
|
||||
grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane"
|
||||
default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)"
|
||||
if grep -qF "autoresolved hello" <<<"$default_pane2"; then
|
||||
fail "auto-resolution cross-delivered to the default socket pane"
|
||||
fi
|
||||
|
||||
echo "ok - named tmux socket send tools"
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-send-message-verdict.sh — locks the fail-loud verdict logic of the patched
|
||||
# send-message.sh against three real tmux-pane fixtures on a throwaway socket:
|
||||
#
|
||||
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
||||
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
||||
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
|
||||
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
|
||||
# exit 0; post-patch it MUST fail loud (exit 2, stderr
|
||||
# "could not confirm submission").
|
||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||
# input line) => exit 2, stderr "unsubmitted draft".
|
||||
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
|
||||
# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362
|
||||
# the glyph probe could not see this box at all, so EVERY send
|
||||
# to such a pane reported "may be UNDELIVERED" while landing.
|
||||
# 5. DRAFT — the same glyphless box, holding our tail across every flush
|
||||
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
|
||||
# also reported unconfirmed, so the true state was invisible.
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND="$HERE/send-message.sh"
|
||||
SOCKET="verdict-test-$RANDOM-$$"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
|
||||
|
||||
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"; }
|
||||
|
||||
command -v tmux >/dev/null 2>&1 || { echo "tmux required" >&2; exit 1; }
|
||||
|
||||
# --- Fixture 1: a submitting REPL with a ❯ prompt box (interactive bash, glyph PS1).
|
||||
# readline strips bracketed-paste markers just like a real agent REPL; Enter
|
||||
# executes (text -> scrollback), leaving a fresh empty `❯ ` box.
|
||||
tmux -L "$SOCKET" new-session -d -s repl -c "$TMP" \
|
||||
'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
||||
fi
|
||||
|
||||
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
|
||||
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
||||
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
|
||||
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
|
||||
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
|
||||
else
|
||||
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixture 3: a ❯ box that never submits (sleep ignores stdin; TTY echo keeps the
|
||||
# pasted tail sitting on the ❯ line) => draft => exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s draft -c "$TMP" \
|
||||
'printf "❯ "; exec sleep infinity'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=draft" -r 1 -m "verdict fixture three stuck unsubmitted draft" 2>"$TMP/e3"); then
|
||||
no "draft: unsubmitted message must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e3"; then
|
||||
ok "draft: stuck ❯-line message => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: stuck ❯-line message => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e3")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the
|
||||
# text between them and NO prompt glyph anywhere, so the glyph probe alone can
|
||||
# never locate it and every send reports "may be UNDELIVERED" (#1362). The
|
||||
# renderer below is the shape, not the runtime: MODE=clear submits (box empties),
|
||||
# MODE=keep leaves the text sitting in the box.
|
||||
cat > "$TMP/pibox.sh" <<'PIBOX'
|
||||
#!/usr/bin/env bash
|
||||
MODE=${1:-clear}
|
||||
RULE=$(printf '─%.0s' $(seq 1 60))
|
||||
buf=""
|
||||
draw() {
|
||||
printf '\033[H\033[2J'
|
||||
printf 'fixture output line\n\n'
|
||||
printf '%s\n' "$RULE"
|
||||
printf '%s\n' "$buf"
|
||||
printf '%s\n' "$RULE"
|
||||
printf '~/fixture (main)\n'
|
||||
printf 'tok 0 model fixture\n'
|
||||
}
|
||||
draw
|
||||
while IFS= read -r line; do
|
||||
# keep: hold the tail across every flush Enter, which is what a stuck draft does.
|
||||
if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else buf=""; fi
|
||||
draw
|
||||
done
|
||||
PIBOX
|
||||
chmod +x "$TMP/pibox.sh"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear"
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]"
|
||||
fi
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep"
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then
|
||||
no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then
|
||||
ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,83 @@
|
||||
# unslop-hook
|
||||
|
||||
Mechanical AI-tell enforcement for pi seats. Anti-drift gate for the writing
|
||||
standard in SYSTEM.md / ms-unslop: prose distribution alone decays over long
|
||||
sessions; this check cannot forget.
|
||||
|
||||
- `lists.json`: committed machine source for every list the checker enforces:
|
||||
words, phrases, punct rules, regex patterns, density thresholds. Each entry
|
||||
carries provenance (`ms-unslop:<pattern id>` or `system-md`), the mention
|
||||
convention, and the documented divergence of the density gate from
|
||||
SYSTEM.md's outright em-dash ban. Edit lists here, not in code.
|
||||
- `unslop-check.js`: dependency-free checker (node CLI + module) driven by
|
||||
lists.json. Loads and schema-validates the lists on first use and hard-fails
|
||||
closed: empty, unparseable, or invalid lists throw. Detects banned vocabulary,
|
||||
chatbot/sycophancy phrases, filler phrases, em/en dashes, curly quotes,
|
||||
`not just X but Y`. Strips fenced and inline code first, so quoted code is
|
||||
never flagged. Exit 0 clean, 1 violations, 2 gate broken (lists unreadable,
|
||||
never a clean verdict).
|
||||
- `extension.ts`: pi extension. `message_end` checks finalized assistant text
|
||||
and notifies the operator (TUI/RPC). `before_agent_start` reads the most
|
||||
recent assistant reply from the session file and, if it carries tells,
|
||||
injects a correction notice the model sees on its next turn. `/unslop`
|
||||
reports session stats. Violation state lives in the session file, so the
|
||||
injection path survives restart, resume, fork, and reload (an in-memory
|
||||
pending flag was measured dead across print-mode turns, 2026-08-19). A
|
||||
broken lists.json fails closed: checks stop, `broken_lists` /
|
||||
`skipped_broken` events log the reason, operator notified once, seat keeps
|
||||
running.
|
||||
- `test-unslop-check.js`: unit tests with red and green controls.
|
||||
|
||||
## Use
|
||||
|
||||
```bash
|
||||
node test-unslop-check.js # suite
|
||||
node unslop-check.js <file> # CLI check
|
||||
UNSLOP_LISTS=<path> node unslop-check.js <file> # alt lists location
|
||||
pi -e ~/.mosaic/tools/unslop-hook/extension.ts # ad-hoc load
|
||||
# deploy: copy dir to ~/.pi/agent/extensions/unslop-hook/ or seat .pi
|
||||
# equivalent, or list it in settings.json "extensions"
|
||||
```
|
||||
|
||||
Env: `MOSAIC_UNSLOP_HOOK=0` disables. `MOSAIC_UNSLOP_LOG=<path>` appends JSONL
|
||||
events (loaded / flagged / notice_injected / checked / broken_lists /
|
||||
skipped_broken) for headless evidence. `UNSLOP_LISTS=<path>` overrides the
|
||||
lists.json location for both CLI and extension.
|
||||
|
||||
## Verified here (2026-08-19)
|
||||
|
||||
- Unit suite 24/24 (8 behavioral, 11 loader/CLI, 5 review follow-up), red and
|
||||
green controls both exercised, including exit-2 on broken lists and on an
|
||||
unreadable input file (S3).
|
||||
- CLI: slop file exit 1, clean file exit 0, broken lists exit 2 with the fault
|
||||
named on stderr.
|
||||
- Extension, healthy path (print mode, zai/glm-5.3:low): startup probe loads
|
||||
lists.json, reply checked clean.
|
||||
- Extension, broken-lists path (print mode): `broken_lists` at startup,
|
||||
`skipped_broken` per turn, seat survives, reply still delivered.
|
||||
- Earlier live evidence (pre-C1, inline lists): forced-slop turn flagged;
|
||||
fresh-process follow-up injected the notice and the reply came back clean;
|
||||
full TUI trial (notify line, injection, /unslop stats) on session vision-unslop.
|
||||
- Log evidence in session scratchpad.
|
||||
|
||||
## Limits
|
||||
|
||||
- `/unslop` command not tested headless (print mode has no command surface);
|
||||
it is a thin stats wrapper.
|
||||
- En dash flag fires on typographic ranges too (2–3); acceptable for fleet
|
||||
prose, revisit if it noisifies technical writing.
|
||||
- Notice injection is a nudger, not a blocker. Output already streamed to the
|
||||
user stays as-is; correction lands on the next turn.
|
||||
- A broken lists.json latches for the session: repairing the file mid-session
|
||||
does not revive checks until the seat restarts. Acceptable for an advisory
|
||||
gate (review S1).
|
||||
- The fail-closed operator notification requires a UI. Print-mode sessions
|
||||
log `skipped_broken` but notify nobody (review S2).
|
||||
- The word/phrase lists are the mechanical subset of ms-unslop only, keyed to
|
||||
pattern ids in lists.json. Style judgments (voice, rhythm, structure) stay in
|
||||
the skill, not the gate.
|
||||
|
||||
## Promotion path
|
||||
|
||||
Stack issue (A4): checker shared as the single source for a matching Claude
|
||||
Code Stop-hook script; lists versioned beside SYSTEM.md contract text.
|
||||
@@ -0,0 +1,163 @@
|
||||
// unslop-hook — pi extension wrapper around unslop-check.js.
|
||||
// Detects mechanical AI tells in finalized assistant messages and injects a
|
||||
// correction notice the model sees on its next turn. Anti-drift enforcement for
|
||||
// SYSTEM.md / ms-unslop; prose distribution alone decays, this cannot forget.
|
||||
//
|
||||
// Deploy: copy dir to ~/.pi/agent/extensions/unslop-hook/ (or seat .pi equivalent),
|
||||
// or add this file's dir to settings.json "extensions".
|
||||
// Test: pi -e <abs path>/extension.ts
|
||||
// Off: MOSAIC_UNSLOP_HOOK=0
|
||||
// Log: MOSAIC_UNSLOP_LOG=/path/to/log.jsonl (JSONL events; headless evidence)
|
||||
// Broken: lists.json missing/empty/invalid → the checker throws; checks are
|
||||
// skipped, logged as skipped_broken, and the operator is notified once.
|
||||
// Never silently pass while the lists cannot load (fail closed).
|
||||
//
|
||||
// Design note: violation state lives in the SESSION FILE, not memory. At
|
||||
// before_agent_start we read the most recent assistant text message from
|
||||
// ctx.sessionManager and check it there. That survives process restarts, resume,
|
||||
// fork, and reload — an in-memory pending flag measured dead on 2026-08-19 when
|
||||
// a print-mode second turn never injected.
|
||||
import { appendFileSync } from "node:fs";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { checkText } from "./unslop-check.js";
|
||||
|
||||
interface Finding {
|
||||
rule: string;
|
||||
detail: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface MessageEntry {
|
||||
type: "message";
|
||||
id: string;
|
||||
message: { role?: string; content?: unknown };
|
||||
}
|
||||
|
||||
function assistantText(entry: unknown): string | null {
|
||||
const e = entry as Partial<MessageEntry>;
|
||||
if (e?.type !== "message") return null;
|
||||
const msg = e.message;
|
||||
if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return null;
|
||||
const text = msg.content
|
||||
.filter((b): b is { type: "text"; text: string } =>
|
||||
typeof b === "object" && b !== null && (b as { type?: string }).type === "text")
|
||||
.map((b) => b.text ?? "")
|
||||
.join("\n");
|
||||
return text.trim() ? text : null; // tool-call-only assistant messages return null
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
if (process.env.MOSAIC_UNSLOP_HOOK === "0") return;
|
||||
|
||||
const LOG = process.env.MOSAIC_UNSLOP_LOG;
|
||||
const log = (ev: Record<string, unknown>) => {
|
||||
if (LOG) appendFileSync(LOG, JSON.stringify({ ts: Date.now(), ...ev }) + "\n");
|
||||
};
|
||||
|
||||
// Entry ids we have already injected a notice for. In-memory only: after a
|
||||
// restart the same entry may inject once more, which re-anchors the style
|
||||
// after a context loss. That is wanted, not a bug.
|
||||
const injectedFor = new Set<string>();
|
||||
let turnsChecked = 0;
|
||||
let turnsFlagged = 0;
|
||||
const histogram = new Map<string, number>();
|
||||
|
||||
// Fail-closed path for a broken lists.json. A checker that cannot load its
|
||||
// lists must never be read as "everything passed": checks stop, the skip is
|
||||
// logged each turn, and the operator is notified once.
|
||||
let broken: string | null = null;
|
||||
let brokenNotified = false;
|
||||
const reportBroken = (ctx: { hasUI?: boolean } | undefined, where: string) => {
|
||||
log({ ev: "skipped_broken", where, reason: broken });
|
||||
if (!brokenNotified && ctx?.hasUI) {
|
||||
ctx.ui.notify(`unslop gate BROKEN: ${broken}. Fix tools/unslop-hook/lists.json; no clean verdicts until then.`, "error");
|
||||
brokenNotified = true;
|
||||
}
|
||||
};
|
||||
const safeCheck = (text: string): ReturnType<typeof checkText> | null => {
|
||||
if (broken) return null;
|
||||
try {
|
||||
return checkText(text);
|
||||
} catch (e) {
|
||||
broken = String((e as Error).message);
|
||||
log({ ev: "broken_lists", reason: broken });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
pi.on("session_start", async (event, _ctx) => {
|
||||
log({ ev: "loaded", reason: event.reason });
|
||||
try {
|
||||
checkText(""); // probe: load+validate lists at startup, not mid-conversation
|
||||
} catch (e) {
|
||||
broken = String((e as Error).message);
|
||||
log({ ev: "broken_lists", reason: broken, at: "startup" });
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("message_end", async (event, ctx) => {
|
||||
if ((event.message as { role?: string }).role !== "assistant") return;
|
||||
const text = assistantText({ type: "message", id: "", message: event.message });
|
||||
if (text === null) return;
|
||||
|
||||
const result = safeCheck(text);
|
||||
if (result === null) {
|
||||
reportBroken(ctx, "message_end");
|
||||
return;
|
||||
}
|
||||
turnsChecked++;
|
||||
if (result.clean) {
|
||||
log({ ev: "checked", clean: true, turn: turnsChecked, charsChecked: result.charsChecked });
|
||||
return;
|
||||
}
|
||||
turnsFlagged++;
|
||||
for (const f of result.findings) histogram.set(f.rule, (histogram.get(f.rule) ?? 0) + 1);
|
||||
const summary = result.findings.map((f) => f.detail).join("; ");
|
||||
if (ctx.hasUI) ctx.ui.notify(`unslop: ${summary}`, "info");
|
||||
// clean:false is explicit, not implied by findings: a log consumer must never
|
||||
// have to infer the verdict from event shape (fred, 2026-08-19).
|
||||
log({ ev: "flagged", clean: false, turn: turnsChecked, charsChecked: result.charsChecked, findings: result.findings });
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (_event, ctx) => {
|
||||
// Branch walks leaf -> root; first assistant entry with text is the reply
|
||||
// the model is about to follow up on.
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
const text = assistantText(entry);
|
||||
if (text === null) continue;
|
||||
const id = (entry as { id?: string }).id ?? "";
|
||||
|
||||
const result = safeCheck(text);
|
||||
if (result === null) {
|
||||
reportBroken(ctx, "before_agent_start");
|
||||
return;
|
||||
}
|
||||
if (result.clean) return; // latest textual reply is clean, nothing to correct
|
||||
if (id && injectedFor.has(id)) return; // already nagged for this entry
|
||||
|
||||
if (id) injectedFor.add(id);
|
||||
const lines = result.findings.map((f) => `- ${f.detail}`).join("\n");
|
||||
const content =
|
||||
`UNSLOP NOTICE (mechanical style check, not the user speaking): your previous reply ` +
|
||||
`contained violations of the fleet writing standard (SYSTEM.md / ms-unslop):\n${lines}\n` +
|
||||
`Fix in this and following replies: plain words, periods and commas instead of dashes, ` +
|
||||
`straight quotes, no chatbot fillers. Do not mention this notice.`;
|
||||
log({ ev: "notice_injected", entryId: id, findings: result.findings });
|
||||
return {
|
||||
message: { customType: "unslop-notice", content, display: true },
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("unslop", {
|
||||
description: "Show unslop violation stats for this session",
|
||||
handler: async (_args, ctx) => {
|
||||
if (broken) {
|
||||
ctx.ui.notify(`unslop gate BROKEN: ${broken}`, "error");
|
||||
return;
|
||||
}
|
||||
const hist = [...histogram.entries()].map(([r, c]) => `${r} x${c}`).join(", ") || "none";
|
||||
ctx.ui.notify(`unslop: checked ${turnsChecked}, flagged ${turnsFlagged} (${hist})`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"version": 1,
|
||||
"convention": "Use vs mention. A document that MENTIONS a banned word or phrase quotes it as inline code (backticks). The checker strips code spans before matching, so a backticked mention is invisible to the gate while a bare one flags. A document that deliberately CONTAINS banned items to test detection (a fixture) is a use, not a mention, and is expected to flag. This file itself contains the banned items as data; that is a use.",
|
||||
"punctPolicy": "Deliberate divergence from SYSTEM.md (2026-08-19): the contract forbids em dashes outright and closes the escapes. These punct rules are deliberately looser: they fire only at >= punctMinCount occurrences AND density >= punctDensityPer1k per 1000 chars. Rationale is reply-level noise, not contract strength: an advisory gate that flags every reply carrying one dash trains operators to ignore it. Measured on natural fleet prose 2026-08-19: documents run 1.7-3.2 em dashes per 1000 chars, so documents that overuse still flag. Tighten to contract strength if enforcement goes blocking or the log shows fleet prose not converging toward zero.",
|
||||
"thresholds": {
|
||||
"punctMinCount": 3,
|
||||
"punctDensityPer1k": 1.0
|
||||
},
|
||||
"words": [
|
||||
{ "value": "additionally", "source": "ms-unslop:7" },
|
||||
{ "value": "crucial", "source": "ms-unslop:7" },
|
||||
{ "value": "delve", "source": "ms-unslop:7" },
|
||||
{ "value": "garner", "source": "ms-unslop:7" },
|
||||
{ "value": "interplay", "source": "ms-unslop:7" },
|
||||
{ "value": "intricate", "source": "ms-unslop:7" },
|
||||
{ "value": "pivotal", "source": "ms-unslop:7" },
|
||||
{ "value": "showcase", "source": "ms-unslop:7" },
|
||||
{ "value": "tapestry", "source": "ms-unslop:7" },
|
||||
{ "value": "testament", "source": "ms-unslop:7" },
|
||||
{ "value": "underscore", "source": "ms-unslop:7" },
|
||||
{ "value": "vibrant", "source": "ms-unslop:7" },
|
||||
{ "value": "utilize", "source": "ms-unslop:31" },
|
||||
{ "value": "leverage", "source": "ms-unslop:31" },
|
||||
{ "value": "facilitate", "source": "ms-unslop:31" },
|
||||
{ "value": "load-bearing", "source": "system-md" }
|
||||
],
|
||||
"phrases": [
|
||||
{ "value": "worth stating plainly", "source": "system-md" },
|
||||
{ "value": "here's the honest truth", "source": "system-md" },
|
||||
{ "value": "heres the honest truth", "source": "system-md", "note": "apostrophe-OMITTED renderings only; ASCII and curly-apostrophe forms match the main entry because the checker normalizes U+2019/U+2018 to ASCII before phrase matching" },
|
||||
{ "value": "the real tension", "source": "system-md" },
|
||||
{ "value": "carry the argument", "source": "system-md" },
|
||||
{ "value": "in order to", "source": "ms-unslop:23" },
|
||||
{ "value": "due to the fact that", "source": "ms-unslop:23" },
|
||||
{ "value": "it is important to note", "source": "ms-unslop:23" },
|
||||
{ "value": "i hope this helps", "source": "ms-unslop:20" },
|
||||
{ "value": "let me know if", "source": "ms-unslop:20" },
|
||||
{ "value": "of course!", "source": "ms-unslop:20" },
|
||||
{ "value": "certainly!", "source": "ms-unslop:20" },
|
||||
{ "value": "found the smoking gun", "source": "ms-unslop:20" },
|
||||
{ "value": "happy to help", "source": "ms-unslop:20", "note": "extension of the named pattern set" },
|
||||
{ "value": "great question", "source": "ms-unslop:22" },
|
||||
{ "value": "absolutely right", "source": "ms-unslop:22" },
|
||||
{ "value": "excellent question", "source": "ms-unslop:22", "note": "extension of the named pattern set" }
|
||||
],
|
||||
"punct": [
|
||||
{ "value": "em", "label": "em dash", "chars": ["\u2014"], "source": "ms-unslop:13+system-md" },
|
||||
{ "value": "en", "label": "en dash", "chars": ["\u2013"], "source": "ms-unslop:13" },
|
||||
{ "value": "curly", "label": "curly quote/apostrophe", "chars": ["\u201c", "\u201d", "\u2018", "\u2019"], "source": "ms-unslop:19" }
|
||||
],
|
||||
"patterns": [
|
||||
{ "value": "not-just-but", "regex": "not just\\s+[^.!?]{0,80}?\\s+but", "flags": "gi", "detail": "not just X but Y", "source": "ms-unslop:9" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use strict";
|
||||
// Tests for unslop-check.js. Run: node test-unslop-check.js
|
||||
// Exit 0 = all pass. Cases include a red control (slop must fail) and a green
|
||||
// control (clean prose must pass) per evidence discipline.
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const { checkText, stripCode, loadLists } = require("./unslop-check.js");
|
||||
|
||||
const SLOP = `Certainly! Let me delve into the evolving tapestry of database technology — it’s truly “pivotal” — and intricate.
|
||||
In order to understand it — we should leverage this interplay of systems — deeply. I hope this helps!`;
|
||||
|
||||
const CLEAN = `The loader parses the file and validates each row. Rows that fail are logged
|
||||
and skipped. We measured a range from 1 to 10 seconds. Use "straight quotes" and
|
||||
commas, not dashes. That is the whole finding.`;
|
||||
|
||||
// Code-stripping control: banned words inside code must not count.
|
||||
const WITH_CODE = [
|
||||
"The config uses `utilize=false` internally.",
|
||||
"```",
|
||||
"delve tapestry — pivotal",
|
||||
"```",
|
||||
"The config file sets one flag. It is parsed at startup.",
|
||||
].join("\n");
|
||||
|
||||
const results = [];
|
||||
function t(name, fn) {
|
||||
try { fn(); results.push([name, true]); } catch (e) { results.push([name, false]); console.error(`FAIL ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
t("slop fixture is flagged (red control)", () => {
|
||||
const r = checkText(SLOP);
|
||||
assert.ok(!r.clean, "slop must not be clean");
|
||||
const details = r.findings.map((f) => f.detail).join("; ");
|
||||
assert.ok(r.findings.some((f) => f.detail.includes("delve")), `delve missing: ${details}`);
|
||||
assert.ok(r.findings.some((f) => f.detail.includes("tapestry")), `tapestry missing: ${details}`);
|
||||
assert.ok(r.findings.some((f) => f.detail.includes("pivotal")), `pivotal missing: ${details}`);
|
||||
assert.ok(r.findings.some((f) => f.detail.includes("em dash")), `em dash missing: ${details}`);
|
||||
assert.ok(r.findings.some((f) => f.detail.includes("curly")), `curly missing: ${details}`);
|
||||
assert.ok(r.findings.some((f) => f.detail.includes("in order to")), `in order to missing: ${details}`);
|
||||
assert.ok(r.findings.some((f) => f.detail.includes("i hope this helps")), `chatbot phrase missing: ${details}`);
|
||||
assert.ok(r.findings.some((f) => f.detail.includes("certainly")), `certainly missing: ${details}`);
|
||||
});
|
||||
|
||||
t("clean fixture passes (green control)", () => {
|
||||
const r = checkText(CLEAN);
|
||||
assert.deepStrictEqual(r.findings, [], `unexpected findings: ${JSON.stringify(r.findings)}`);
|
||||
});
|
||||
|
||||
t("numeric range is not a false range flag", () => {
|
||||
const r = checkText(CLEAN);
|
||||
assert.ok(!r.findings.some((f) => f.rule === "pattern"), "must not flag numeric ranges");
|
||||
});
|
||||
|
||||
t("code blocks and inline code are stripped", () => {
|
||||
const r = checkText(WITH_CODE);
|
||||
assert.deepStrictEqual(r.findings, [], `code leaked into check: ${JSON.stringify(r.findings)}`);
|
||||
});
|
||||
|
||||
t("stripCode removes fenced and inline code", () => {
|
||||
const s = stripCode("a `x — y` b\n```\ndelve\n```\nc");
|
||||
assert.ok(!s.includes("delve"), "fenced code not stripped");
|
||||
assert.ok(!s.includes("—"), "inline code not stripped");
|
||||
assert.ok(s.includes("a") && s.includes("b") && s.includes("c"), "prose lost");
|
||||
});
|
||||
|
||||
t("not-just-but pattern is detected", () => {
|
||||
const r = checkText("This is not just a cache but a coordination layer.");
|
||||
assert.ok(r.findings.some((f) => f.rule === "pattern"), "pattern missed");
|
||||
});
|
||||
|
||||
t("light dash use is not flagged (below threshold)", () => {
|
||||
const prose =
|
||||
"The loader parses each row and validates it against the schema. Rows that fail " +
|
||||
"are logged — with their line numbers — and skipped. The operator reviews the log " +
|
||||
"daily and reconciles the rejects against the source system by hand, which takes " +
|
||||
"a few minutes and has never once produced a discrepancy worth acting on.";
|
||||
const r = checkText(prose);
|
||||
assert.ok(!r.findings.some((f) => f.detail.includes("dash")), "2 dashes in ~330 chars must not flag");
|
||||
});
|
||||
|
||||
t("dash overuse is flagged (above threshold)", () => {
|
||||
const r = checkText("One — two — three — four. That is the whole sentence.");
|
||||
assert.ok(r.findings.some((f) => f.detail.includes("em dash")), "4 dashes in 50 chars must flag");
|
||||
});
|
||||
|
||||
// ── C1: lists.json machine source ──────────────────────────────────────
|
||||
// The committed lists are the single source of truth; these tests pin the
|
||||
// file's validity, its shape, and the loader's fail-closed behavior.
|
||||
|
||||
const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "unslop-c1-"));
|
||||
const tmp = (n) => path.join(tmpdir, n);
|
||||
|
||||
function brokenVariant(mutate) {
|
||||
const l = JSON.parse(JSON.stringify(loadLists()));
|
||||
mutate(l);
|
||||
return l;
|
||||
}
|
||||
|
||||
function writeTmp(name, data) {
|
||||
const f = tmp(name);
|
||||
fs.writeFileSync(f, typeof data === "string" ? data : JSON.stringify(data));
|
||||
return f;
|
||||
}
|
||||
|
||||
t("lists.json (the real file) validates and is pinned in size", () => {
|
||||
const l = loadLists();
|
||||
assert.strictEqual(l.version, 1);
|
||||
// Counts pin the migration: 16 words, 17 phrases, 3 punct, 1 pattern moved
|
||||
// from the old inline constants. Changing a count means changing this test
|
||||
// too, consciously.
|
||||
assert.strictEqual(l.words.length, 16, "word count drifted");
|
||||
assert.strictEqual(l.phrases.length, 17, "phrase count drifted");
|
||||
assert.strictEqual(l.punct.length, 3, "punct count drifted");
|
||||
assert.strictEqual(l.patterns.length, 1, "pattern count drifted");
|
||||
assert.ok(l.convention.length > 50, "mention convention must be present");
|
||||
assert.ok(l.punctPolicy.length > 50, "punct divergence policy must be present");
|
||||
for (const e of [...l.words, ...l.phrases, ...l.punct, ...l.patterns]) {
|
||||
assert.ok(e.source && e.source.trim(), `entry missing source: ${JSON.stringify(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
t("loader rejects an empty file", () => {
|
||||
const f = writeTmp("empty.json", "");
|
||||
assert.throws(() => loadLists(f), /empty file/);
|
||||
});
|
||||
|
||||
t("loader rejects unparseable JSON", () => {
|
||||
const f = writeTmp("bad.json", "{nope");
|
||||
assert.throws(() => loadLists(f), /unparseable/);
|
||||
});
|
||||
|
||||
t("loader rejects a missing file", () => {
|
||||
assert.throws(() => loadLists(tmp("does-not-exist.json")), /cannot read/);
|
||||
});
|
||||
|
||||
t("loader rejects missing keys", () => {
|
||||
const f = writeTmp("nokeys.json", { version: 1 });
|
||||
assert.throws(() => loadLists(f), /missing key/);
|
||||
});
|
||||
|
||||
t("loader rejects an emptied word list", () => {
|
||||
const f = writeTmp("emptywords.json", brokenVariant((l) => { l.words = []; }));
|
||||
assert.throws(() => loadLists(f), /words must be a non-empty array/);
|
||||
});
|
||||
|
||||
t("loader rejects entries without provenance", () => {
|
||||
const f = writeTmp("nosource.json", brokenVariant((l) => { delete l.phrases[0].source; }));
|
||||
assert.throws(() => loadLists(f), /source/);
|
||||
});
|
||||
|
||||
t("loader rejects duplicate values", () => {
|
||||
const f = writeTmp("dup.json", brokenVariant((l) => { l.words.push({ ...l.words[0] }); }));
|
||||
assert.throws(() => loadLists(f), /duplicate/);
|
||||
});
|
||||
|
||||
t("loader rejects a non-compiling pattern regex", () => {
|
||||
const f = writeTmp("badregex.json", brokenVariant((l) => { l.patterns[0].regex = "("; }));
|
||||
assert.throws(() => loadLists(f), /does not compile/);
|
||||
});
|
||||
|
||||
t("CLI exits 2 on broken lists (red control)", () => {
|
||||
const f = writeTmp("cli-broken.json", "");
|
||||
const r = spawnSync(process.execPath, [path.join(__dirname, "unslop-check.js")], {
|
||||
input: "some prose",
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, UNSLOP_LISTS: f },
|
||||
});
|
||||
assert.strictEqual(r.status, 2, `expected exit 2, got ${r.status} (stderr: ${r.stderr})`);
|
||||
assert.ok(r.stderr.includes("lists.json invalid"), `stderr must name the fault: ${r.stderr}`);
|
||||
});
|
||||
|
||||
t("CLI honors UNSLOP_LISTS for a valid file (green control)", () => {
|
||||
const r = spawnSync(process.execPath, [path.join(__dirname, "unslop-check.js")], {
|
||||
input: "plain prose with no tells at all",
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, UNSLOP_LISTS: path.join(__dirname, "lists.json") },
|
||||
});
|
||||
assert.strictEqual(r.status, 0, `expected exit 0, got ${r.status} (stderr: ${r.stderr})`);
|
||||
});
|
||||
|
||||
// ── Review follow-up (rev-code-01, 2026-08-19): F1, F2, F3, S3 ────────
|
||||
|
||||
t("loader rejects non-finite thresholds (F1)", () => {
|
||||
// Infinity cannot round-trip JSON.stringify, so the fixture is a raw string
|
||||
// edit of the real file — exactly the hand-edit that produced the finding.
|
||||
const real = fs.readFileSync(path.join(__dirname, "lists.json"), "utf8");
|
||||
const f = writeTmp("inf-threshold.json", real.replace('"punctMinCount": 3', '"punctMinCount": 1e999'));
|
||||
assert.ok(real !== fs.readFileSync(f, "utf8") || !real.includes('"punctMinCount": 3'), "fixture mutation did not apply; test is vacuous");
|
||||
assert.throws(() => loadLists(f), /finite/);
|
||||
const f2 = writeTmp("inf-density.json", real.replace('"punctDensityPer1k": 1.0', '"punctDensityPer1k": 1e999'));
|
||||
assert.throws(() => loadLists(f2), /finite/);
|
||||
});
|
||||
|
||||
t("curly-apostrophe phrase rendering is flagged (F2 red control)", () => {
|
||||
const r = checkText("Here\u2019s the honest truth about the deploy.");
|
||||
assert.ok(!r.clean, "curly apostrophe must not defeat phrase matching");
|
||||
assert.ok(r.findings.some((x) => x.detail.includes("here's the honest truth")), `main entry must match, got: ${JSON.stringify(r.findings)}`);
|
||||
});
|
||||
|
||||
t("curly apostrophes still fire the punct rule alongside phrases (F2 ordering)", () => {
|
||||
// Normalization for phrases must not eat the punct signal: four curly
|
||||
// quotes in short text must flag punct, not only the phrase.
|
||||
const r = checkText("It\u2019s \u2019one\u2019 \u2019two\u2019 \u2019three\u2019 \u2019four\u2019 done.");
|
||||
assert.ok(r.findings.some((f) => f.rule === "punct"), `punct must fire on original text: ${JSON.stringify(r.findings)}`);
|
||||
});
|
||||
|
||||
t("loader rejects non-lowercase phrase values (F3)", () => {
|
||||
const f = writeTmp("cap-phrase.json", brokenVariant((l) => { l.phrases[0].value = "Worth Stating Plainly"; }));
|
||||
assert.throws(() => loadLists(f), /lowercase/);
|
||||
});
|
||||
|
||||
t("CLI exits 2 on unreadable input file (S3)", () => {
|
||||
const r = spawnSync(process.execPath, [path.join(__dirname, "unslop-check.js"), tmp("definitely-absent.txt")], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.strictEqual(r.status, 2, `expected exit 2, got ${r.status} (stderr: ${r.stderr})`);
|
||||
assert.ok(r.stderr.includes("cannot read input"), `stderr must name the fault: ${r.stderr}`);
|
||||
});
|
||||
|
||||
let failed = 0;
|
||||
for (const [name, ok] of results) { console.log(`${ok ? "PASS" : "FAIL"} ${name}`); if (!ok) failed++; }
|
||||
console.log(`${results.length - failed}/${results.length} passed`);
|
||||
try { fs.rmSync(tmpdir, { recursive: true, force: true }); } catch {}
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
// unslop-check — mechanical AI-tell checker (ms-unslop subset + SYSTEM.md phrase bans).
|
||||
// Plain JS, no deps, so pi extensions (jiti) and Claude Code hook scripts (node CLI)
|
||||
// share one implementation.
|
||||
//
|
||||
// The lists live in lists.json beside this file: committed machine source with
|
||||
// per-entry provenance (which ms-unslop pattern or SYSTEM.md rule each entry
|
||||
// mechanizes), the mention convention, and the punct thresholds. The loader
|
||||
// hard-fails closed: an empty, unparseable, or schema-invalid lists.json throws,
|
||||
// and the CLI exits 2 so a broken gate is never mistaken for a clean verdict.
|
||||
//
|
||||
// CLI: node unslop-check.js <file> (or stdin)
|
||||
// exit 0 = clean, exit 1 = violations found (findings printed as JSON),
|
||||
// exit 2 = gate broken (lists.json missing/empty/invalid; error on stderr).
|
||||
// Env: UNSLOP_LISTS=<path> overrides the lists.json location (testing; reuse by
|
||||
// other harnesses sharing this file).
|
||||
//
|
||||
// Provenance note (2026-08-19): the inline lists this file carried before C1
|
||||
// moved to lists.json unchanged — 16 words, 17 phrases, 3 punct rules, 1 pattern.
|
||||
// The suite pins those counts; a list edit without a test edit is a drift signal.
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function stripCode(text) {
|
||||
// Fenced blocks (``` or ~~~), then inline code spans. Code is quoted material,
|
||||
// not the agent's prose style. This is also the mention convention: a banned
|
||||
// item quoted as inline code is a mention and must not flag (see lists.json).
|
||||
return text
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/~~~[\s\S]*?~~~/g, " ")
|
||||
.replace(/`[^`\n]*`/g, " ");
|
||||
}
|
||||
|
||||
// ── lists.json loading and validation ─────────────────────────────────────────
|
||||
|
||||
function validateLists(data) {
|
||||
const fail = (why) => { throw new Error(`lists.json invalid: ${why}`); };
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) fail("top level must be an object");
|
||||
for (const k of ["version", "convention", "punctPolicy", "thresholds", "words", "phrases", "punct", "patterns"]) {
|
||||
if (!(k in data)) fail(`missing key: ${k}`);
|
||||
}
|
||||
if (typeof data.version !== "number" || data.version < 1) fail("version must be a number >= 1");
|
||||
for (const k of ["convention", "punctPolicy"]) {
|
||||
if (typeof data[k] !== "string" || !data[k].trim()) fail(`${k} must be a non-empty string`);
|
||||
}
|
||||
const th = data.thresholds;
|
||||
if (typeof th !== "object" || th === null) fail("thresholds must be an object");
|
||||
// Number.isFinite, not just typeof: JSON.parse of 1e999 yields Infinity, which
|
||||
// passes typeof-number and would silently disable the punct gate (review F1).
|
||||
if (!Number.isFinite(th.punctMinCount) || th.punctMinCount < 1) fail("thresholds.punctMinCount must be a finite number >= 1");
|
||||
if (!Number.isFinite(th.punctDensityPer1k) || !(th.punctDensityPer1k > 0)) fail("thresholds.punctDensityPer1k must be a finite number > 0");
|
||||
|
||||
const seen = new Set();
|
||||
const checkEntries = (arr, kind, extra) => {
|
||||
if (!Array.isArray(arr) || arr.length === 0) fail(`${kind} must be a non-empty array`);
|
||||
arr.forEach((e, i) => {
|
||||
const at = `${kind}[${i}]`;
|
||||
if (typeof e !== "object" || e === null) fail(`${at} must be an object`);
|
||||
if (typeof e.value !== "string" || !e.value.trim()) fail(`${at}.value must be a non-empty string`);
|
||||
if (typeof e.source !== "string" || !e.source.trim()) fail(`${at}.source must be a non-empty string (pattern id or system-md)`);
|
||||
if (extra) extra(e, at, fail);
|
||||
if (seen.has(`${kind}:${e.value}`)) fail(`duplicate ${kind} value: ${e.value}`);
|
||||
seen.add(`${kind}:${e.value}`);
|
||||
});
|
||||
};
|
||||
checkEntries(data.words, "words");
|
||||
checkEntries(data.phrases, "phrases", (e, at, fail) => {
|
||||
// Phrase matching splits a lowercased haystack, so an uppercase letter in a
|
||||
// phrase value is a silently dead rule (review F3). Reject, do not silently
|
||||
// normalize: list edits should fail loud (D-a).
|
||||
if (e.value !== e.value.toLowerCase()) fail(`${at}.value must be lowercase; phrase matching lowercases the haystack: ${e.value}`);
|
||||
});
|
||||
checkEntries(data.punct, "punct", (e, at, fail) => {
|
||||
if (typeof e.label !== "string" || !e.label.trim()) fail(`${at}.label must be a non-empty string`);
|
||||
if (!Array.isArray(e.chars) || e.chars.length === 0 || !e.chars.every((c) => typeof c === "string" && c.length === 1)) {
|
||||
fail(`${at}.chars must be a non-empty array of single-char strings`);
|
||||
}
|
||||
});
|
||||
checkEntries(data.patterns, "patterns", (e, at, fail) => {
|
||||
if (typeof e.regex !== "string" || !e.regex.trim()) fail(`${at}.regex must be a non-empty string`);
|
||||
if (typeof e.flags !== "string") fail(`${at}.flags must be a string`);
|
||||
if (typeof e.detail !== "string" || !e.detail.trim()) fail(`${at}.detail must be a non-empty string`);
|
||||
try { new RegExp(e.regex, e.flags); } catch (err) { fail(`${at}.regex does not compile: ${err.message}`); }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
let cache = null;
|
||||
function loadLists(filePath) {
|
||||
if (cache && !filePath) return cache;
|
||||
const p = filePath || process.env.UNSLOP_LISTS || path.join(__dirname, "lists.json");
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(p, "utf8");
|
||||
} catch (e) {
|
||||
throw new Error(`lists.json invalid: cannot read ${p}: ${e.message}`);
|
||||
}
|
||||
if (!raw.trim()) throw new Error(`lists.json invalid: empty file: ${p}`);
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error(`lists.json invalid: unparseable JSON: ${e.message}`);
|
||||
}
|
||||
const validated = validateLists(data);
|
||||
if (!filePath) cache = validated;
|
||||
return validated;
|
||||
}
|
||||
|
||||
// ── checker ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
function checkText(raw) {
|
||||
const lists = loadLists();
|
||||
const text = stripCode(String(raw));
|
||||
// Phrase haystack: lowercased, then curly apostrophes normalized to ASCII.
|
||||
// This must be a SEPARATE string from `text`: punct counting reads the
|
||||
// original, so curly quotes still fire the punct rule (review F2 ordering).
|
||||
const phraseHay = text.toLowerCase().replace(/[\u2018\u2019]/g, "'");
|
||||
const findings = [];
|
||||
|
||||
for (const w of lists.words) {
|
||||
const re = new RegExp("\\b" + escapeRegex(w.value) + "\\b", "gi");
|
||||
const count = (text.match(re) || []).length;
|
||||
if (count > 0) findings.push({ rule: "word", detail: `banned word "${w.value}" x${count}`, count });
|
||||
}
|
||||
|
||||
for (const p of lists.phrases) {
|
||||
const count = phraseHay.split(p.value).length - 1;
|
||||
if (count > 0) findings.push({ rule: "phrase", detail: `phrase "${p.value}" x${count}`, count });
|
||||
}
|
||||
|
||||
// Density-gated punctuation. The deliberate divergence from SYSTEM.md's
|
||||
// outright em-dash ban is documented in lists.json punctPolicy, not only here.
|
||||
for (const pc of lists.punct) {
|
||||
let count = 0;
|
||||
for (const ch of pc.chars) count += text.split(ch).length - 1;
|
||||
if (count < lists.thresholds.punctMinCount) continue;
|
||||
if (count / Math.max(text.length, 1) * 1000 < lists.thresholds.punctDensityPer1k) continue;
|
||||
findings.push({ rule: "punct", detail: `${pc.label} x${count} (density-gated)`, count });
|
||||
}
|
||||
|
||||
for (const pt of lists.patterns) {
|
||||
const flags = pt.flags.includes("g") ? pt.flags : pt.flags + "g";
|
||||
const m = text.match(new RegExp(pt.regex, flags));
|
||||
const count = m ? m.length : 0;
|
||||
if (count > 0) findings.push({ rule: "pattern", detail: `"${pt.detail}" x${count}`, count });
|
||||
}
|
||||
|
||||
return { clean: findings.length === 0, findings, charsChecked: text.length };
|
||||
}
|
||||
|
||||
module.exports = { checkText, stripCode, loadLists, validateLists };
|
||||
|
||||
if (require.main === module) {
|
||||
let input;
|
||||
try {
|
||||
input = process.argv[2] ? fs.readFileSync(process.argv[2], "utf8") : fs.readFileSync(0, "utf8");
|
||||
} catch (e) {
|
||||
// An unreadable input must not exit 1: that is the violations code, and a
|
||||
// wrapper keying on rc alone would report slop-free for a file it never
|
||||
// read (review S3).
|
||||
console.error(`unslop-check: cannot read input: ${e.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = checkText(input);
|
||||
} catch (e) {
|
||||
if (String(e.message).startsWith("lists.json invalid")) {
|
||||
console.error(`unslop-check: ${e.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(result.clean ? 0 : 1);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
# POC user
|
||||
|
||||
This is an isolated local runtime test.
|
||||
|
||||
The user's name is Jason.
|
||||
Reference in New Issue
Block a user