Compare commits

...
Author SHA1 Message Date
jason.woltje dd3ff944a1 chore(release): 0.0.11 2026-09-03 12:30:52 -05:00
jason.woltje 8eb81ebec1 feat(onboard): user onboarding - no default USER.md, guided creation (#37)
- bootstrap no longer creates user/USER.md (owner direction)
- scripts/onboard.sh: name REQUIRED (interactive loop or --name),
  optional fields prompted (profession, marital, age, gender, education,
  location, timezone, skillset, interests, hobbies, pets); flag-driven
  non-interactive mode for automation
- templates/USER.md: canon skeleton, placeholder rendering, unfilled
  optional = (not provided)
- agent.sh: auto-runs onboarding when profile missing (TTY gate);
  headless run-task warns and continues without user context
- user profile dispatched to all launches (M14 layer)

Closes #37 (onboarding requirements from owner layout review)
2026-09-03 12:30:42 -05:00
jason.woltje 530597cc84 docs(plan): CURRENT.md flush 2026-09-03 11:59:49 -05:00
jason.woltje 9a0d44f96a docs(plan): CURRENT.md - deduplicated completed log (marked correction), M15 review queued 2026-09-03 11:59:37 -05:00
jason.woltje 121b331c6c docs(log): back-fill phases 16-19 (M10, M12, M14, M15) - recorded retroactively with ground-truth sources 2026-09-03 11:59:14 -05:00
jason.woltje 34e06e7de7 Merge M15: agent seats - per-agent SOUL and role contracts
Closes #36
2026-09-03 11:56:38 -05:00
jason.woltje 9bd4f1c405 feat(agents): agent seats - per-agent SOUL, role, definitions dir (#36)
- agents/<name>/ holds agent.json (strictly validated: version, name,
  role?, capabilities?, workspace?, session?) + SOUL.md (persona prose)
- agent.sh: definition loading (quote-safe node defaults file), runtime
  SOUL copy to dataRoot/agents/<name>/, MOSAIC_AGENT_SOUL_FILE ->
  loader fills the SOUL slot from the seat's persona (contract SOUL =
  default persona; governance never overridden)
- seat.json written once at instantiation (seatVersion, name, role, at)
- identity section gains agent role; compose passthrough for role+SOUL
- live user context (M14) + seat SOUL compose the full persona:
  governance -> persona -> identity -> user -> mission
- RELEASE -> 0.0.10; packaged and health-gated activated
- example seat committed: agents/researcher

Closes #36
2026-09-03 11:56:38 -05:00
jason.woltje a7b612435b docs: BUILD-LOG Phase 15, CURRENT.md - M13 shipped 2026-09-03 11:25:41 -05:00
jason.woltje 87f10772ce Merge M13: interactive TUI agent + TOOLS.md
Closes #35
2026-09-03 11:24:56 -05:00
jason.woltje 7db4c5c2ed feat(agent): interactive TUI launcher + identity + TOOLS.md (#35)
- scripts/agent.sh <name>: launches interactive pi TUI in the container
  with contracts + optional mission + agent identity + named session +
  optional workspace/tools; the Mosaic alternative to vanilla pi
- pi adapter: MOSAIC_INTERACTIVE branch (clean TUI, no -p, no initial
  prompt); headless exec rebuilt via positional args (no word-splitting
  on the request); MOSAIC_AGENT_NAME optional in headless
- loader: AGENT IDENTITY section when the launcher names the agent
- compose: fixed command removed (request defaults live in run-agent.sh);
  MOSAIC_INTERACTIVE/MOSAIC_AGENT_NAME passthrough
- docs/TOOLS.md: full on-demand tool reference; AGENTS.md routes to it
- RELEASE -> 0.0.8 (container change); build verified

Closes #35
2026-09-03 11:24:56 -05:00
jason.woltje 0273a84549 docs: AGENTS.md - session recovery shim, invariants canon, session registry
- AGENTS.md at root: pi loads it automatically at every session start
  (conductor-level sessions; workers deliberately exclude it via
  --no-context-files). Deliberately short: invariants, session protocol,
  role model, command surface, data map, pointers - depth stays in docs/.
- docs/SESSIONS.md: append-only session registry, mandatory per session.
- Recovery rule encoded: compaction/restart loses nothing - AGENTS.md +
  CURRENT.md + git log + suites reconstruct state; never guess.
2026-09-03 11:02:19 -05:00
jason.woltje 3b674b7a66 Merge: roles/ directory convention - root is bootstrap-only 2026-09-03 10:56:07 -05:00
jason.woltje 527bc581ca refactor(layout): role contracts move to roles/ - root is bootstrap-only
Owner direction: the repository root holds first-class, bootstrap-required
configuration only. conductor-policy.json is a ROLE contract (the
conductor's authority), one of scores of future role contracts
(agent-policy, coder-policy, ...) - such files get a dedicated home.

- roles/conductor-policy.json (git mv)
- conductor-apply.sh + test-conductor.sh read the new path
- CONDUCTOR.md records the roles/ convention

Closes UX follow-up from owner layout review; no issue (convention change).
2026-09-03 10:56:07 -05:00
jason.woltje 1249714a9a docs(plan): CURRENT.md - M12 shipped 2026-09-03 07:04:37 -05:00
jason.woltje e175616885 feat(conductor): auto-apply policy gate for worker patches (#34)
- conductor-policy.json (tracked, strictly validated): enabled switch,
  path allowlist globs, gating suites - the autonomy decision lives in a
  declarative file the owner controls
- scripts/conductor-apply.sh <runId> [--dry-run]: succeeded-run check ->
  clean target tree -> diff from worker workspace -> allowlist -> syntax
  gates (node/bash/json) -> apply -> policy suites -> attribution commit;
  ANY failure reverts the tree; push is never automatic
- scripts/test-conductor.sh: 17 sandbox cases covering every gate incl.
  suite-failure auto-revert and disabled policy
- policy defaults: scripts/docs/tasks/missions/adapters + README; all
  three suites gate

Closes #34
2026-09-03 07:03:29 -05:00
jason.woltje 6955717612 Merge M11: session forking from a common ancestor
Closes #33
2026-09-03 06:43:02 -05:00
jason.woltje 88d9cf750f feat(sessions): sessionForkFrom - branch conversations from a common ancestor (#33)
- task schema: optional sessionForkFrom (source session name); requires
  session target; self-fork rejected
- runner: resolves source newest .jsonl (fail 4 if none/outside dataRoot);
  passes MOSAIC_SESSION_FORK + MOSAIC_SESSION_DIR; result records lineage
- pi adapter: --fork <source> --session-dir <target> when forking;
  ephemeral default unchanged; plain session resume unchanged
- compose passthrough; RELEASE -> 0.0.7 (adapter changed)
- suite +9 cases (58 total): plumbing via mock stderr, validation
  negatives, live fork - child recalls ancestor code word, ancestor
  session file untouched

Closes #33
2026-09-03 06:43:02 -05:00
jason.woltje c038706eed docs(plan): CURRENT.md - M10 shipped, retention next in review 2026-09-03 06:33:54 -05:00
jason.woltje 8622c9d826 Merge M10: run-record retention
Closes #32
2026-09-03 06:33:27 -05:00
jason.woltje 88eef507b0 feat(retention): run-record pruning - keep newest N, dry-run default (#32)
- mosaic-task.mjs prune [--keep=N] [--yes]: default keep 50; without
  --yes lists candidates without deleting
- only r-* directories under the runs root; symlinks skipped;
  sessions/workspaces/state/config untouched (asserted by suite sentinels)
- append-only receipt runs/.pruned.log records every pruned id
- test-task.sh: +8 retention cases (dry-run no-delete, keep-N, newest
  kept, receipt, isolation, invalid keep, empty no-op)

Also: suite hardening - prune section scopes its config per-command
(no export/unset leaking into later sections); duplicated check()
removed; latest_reason hoisted to helpers; status colors now green OK /
red FAIL (terminal-only, NO_COLOR-aware) per owner UX feedback.

Closes #32
2026-09-03 06:33:27 -05:00
jason.woltje 439bea6915 ui(test): green OK/PASS, red FAIL - terminal-only, NO_COLOR-aware
Owner feedback: grep match-highlighting made the word 'policy' red while
status words were plain - counter-indicative. Suites + verify now emit
ANSI colors (green success, red failure) when stdout is a terminal;
piped/machine-parsed output stays plain, honoring NO_COLOR. Word 'ok'
promoted to 'OK' for scannability.

Verified byte-level via forced-pty run; piped output unchanged; suites
41/24/14 + verify green.
2026-09-03 06:23:47 -05:00
jason.woltje fad8a4718c Merge M9: mission-level capability policy
Closes #30
2026-09-03 06:16:01 -05:00
jason.woltje 2ff49adff4 feat(policy): mission-level capability policy - least-privilege intersection (#30)
- mission schema: optional capabilities.tools (same validation as task)
- merge semantics in runTask: neither -> none; mission only -> mission;
  task only -> task; both -> intersection (task narrows, never widens);
  empty intersection -> tool-free run with an explicit stderr note
- result.json records EFFECTIVE tools; task/mission snapshots remain the
  immutable declaration of intent
- adapters unchanged; host-side only (no image change, 0.0.6 still active)
- task suite +5 cases (41 total): all four merge cases asserted from run
  evidence + invalid mission capabilities rejected

Policy decision recorded: missions govern; tasks cannot escalate.

Closes #30
2026-09-03 06:16:01 -05:00
jason.woltje 44c476ebbf fix(ops): show displays retriedFrom lineage (#29)
result.json recorded lineage correctly; the human-facing show command
omitted the field. Found by owner test: show | grep retriedFrom was
empty on a run whose result.json contained it.

Closes #29
2026-09-03 06:09:51 -05:00
jason.woltje cde480eb60 docs(plan): CURRENT.md — retry lineage shipped, M9 queued for decision 2026-09-03 05:31:24 -05:00
jason.woltje 5808248707 Merge retry lineage + relative mission resolution
Closes #28
2026-09-03 05:30:57 -05:00
jason.woltje afd5827db8 fix(retry): lineage tracking + relative mission path resolution (#28)
- retryRun rewrites a snapshot's relative mission path to the run's own
  recorded mission.json (absolute) before execution — retries stay
  faithful to what originally ran
- runTask accepts options.retriedFrom; retry records lineage in
  result.json (additive optional field, no schema break)
- task suite +4 cases: retry succeeds, lineage recorded, mission section
  present after retry (36 total), missing-run retry exits 4

Closes #28
2026-09-03 05:30:57 -05:00
jason.woltje d9cc990376 Merge M8: conductor loop - self-orchestration
Closes #25, closes #26, closes #27
2026-09-02 22:42:52 -05:00
jason.woltje 83c4e9851e feat(orchestration): retry <runId> — authored by headless pi worker (#26, #27)
Collaboration record (conductor loop, docs/plans/CONDUCTOR.md):
- round 1 (worker session worker-1, 2m28s): retry implemented per spec
- conductor live test exposed spec gap: direct invocation lacked
  launcher env exports
- round 2 (same worker session, 59s): spawnEnv made self-sufficient,
  but used PI_* where compose interpolates MOSAIC_*
- conductor hotfix: 3-line rename to MOSAIC_PROVIDER/MOSAIC_MODEL/
  MOSAIC_DATA_ROOT

Final: node scripts/mosaic-task.mjs retry <runId> re-executes a run's
task snapshot as a new run; live retry replied REMEMBERED; all suites
green (24/32/14 + verify).

Known limitation: retrying a run whose task used a RELATIVE mission path
resolves it against the temp dir; lineage tracking deferred.

Closes #25, closes #26, closes #27
2026-09-02 22:42:52 -05:00
jason.woltje 22508170a2 docs(plan): CURRENT.md — single next-action pointer for cadence-driven work 2026-09-02 22:25:56 -05:00
42 changed files with 3454 additions and 69 deletions
+114
View File
@@ -0,0 +1,114 @@
# 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/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.
+125
View File
@@ -275,4 +275,129 @@ Adapter seam verified; harness boundary is now additive by construction. `main`
M5 tagged `workspace-capabilities-v1`, M6 tagged `sessions-v1`, M7 tagged `operator-ergonomics-v1`; release 0.0.6 active. Tracker: docs/plans/2026-09-03_autonomous-run.md.
---
## Phase 12: Conductor loop — self-orchestration (M8)
### Entry 12.1 — before
- Timestamp: 2026-09-03
- Intended action: Stand up the poor-man orchestration loop per docs/plans/CONDUCTOR.md: conductor (host, holds git) mirrors the repo into a worker workspace, dispatches a headless pi worker (session worker-1, tools read/write/edit/bash) to implement retry <runId>, reviews the diff, integrates, verifies (Gitea #25, #26, #27).
- Reason: The owner asked for circular task processing with agent workers; the stack now has every primitive needed — this proves it on the stack itself.
- Expected result: worker-authored retry merged with suites green and a live retry verified.
### Entry 12.2 — after
- Timestamp: 2026-09-03
- Commands run: repo mirror clone; worker dispatch (tasks/worker-retry.json); diff review; apply; live retry; refinement dispatch (tasks/worker-retry-refine.json); second review; reverse+reapply combined patch; conductor interpolation fix; live retry; full sweep.
- Observed result:
- Worker round 1: implemented retry correctly per spec in 2m28s; diff reviewed clean.
- Live retry exposed a spec gap (direct invocation lacks launcher env exports).
- Worker round 2 (same session, 59s): made spawnEnv self-sufficient, but used PI_* names where compose interpolates MOSAIC_*.
- Conductor hotfix: 3-line rename to MOSAIC_PROVIDER/MOSAIC_MODEL/MOSAIC_DATA_ROOT (too trivial for a worker round).
- Final: live retry succeeded (replied REMEMBERED, new run recorded); all suites green.
- Failure or correction: three rounds total — one spec gap (conductor), one naming mismatch (worker), one trivial rename (conductor). Each was caught by mechanical verification (run record stderr), never by hope.
- Attribution: feature authored by headless pi worker (glm-5.3-flash) in sessions worker-1; conductor reviewed, integrated, and hotfixed.
## Result (M8)
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.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+1 -1
View File
@@ -1 +1 @@
0.0.6
0.0.11
+3 -1
View File
@@ -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
+39 -11
View File
@@ -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,13 +47,25 @@ fi
TOOLS_FLAG="--no-tools"
[ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS"
# 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 \
@@ -51,5 +76,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 "$@"
+5
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
{
"agentVersion": 1,
"name": "researcher",
"role": "researcher",
"capabilities": { "tools": ["read", "bash"] }
}
+10 -4
View File
@@ -18,8 +18,14 @@ 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:-}
# 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 +39,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.
+9
View File
@@ -0,0 +1,9 @@
# 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.10.0.7; issues #1#34 closed |
+85
View File
@@ -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` (5600), `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.
+58
View File
@@ -0,0 +1,58 @@
# Conductor protocol — poor-man orchestration loop
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 |
|---|---|---|---|
| **Conductor** | host (assistant or owner) | git (clone/commit/push), task dispatch, review, verification suites, Gitea | nothing new |
| **Worker** | container (headless pi via `scripts/run-task.sh`) | read/write/edit/bash inside its workspace; persistent session on request | git credentials, docker socket, host filesystem |
## The loop
1. **Decompose**: conductor turns a goal into worker tasks small enough to
specify completely in one prompt (file paths, acceptance criteria, style
constraints, verification the worker can run itself, e.g. `node --check`).
2. **Mirror**: conductor maintains the repo clone at
`<dataRoot>/workspaces/stack-repo` (host-side git; workers see it read-write
through their workspace mount).
3. **Dispatch**: `scripts/run-task.sh run <worker-task.json>` — worker edits the
clone. Session name `worker-<n>` keeps continuity across refinement rounds.
4. **Extract**: `git -C <workspace> diff > patch` — the worker's entire output
is a reviewable diff. Run record (result.json, stderr.txt) is the receipt.
5. **Review**: conductor reads the diff line by line. Bad output → refine the
prompt, re-dispatch (same session: "your patch had these problems…").
6. **Integrate**: conductor applies the patch to the real repo, runs the full
suites, commits and pushes. Suites failing → revert apply, back to step 5.
7. **Record**: update CURRENT.md, BUILD-LOG, close the Gitea issue.
## Guardrails
- Workers never receive credentials; they never run git; they never leave the
workspace (container is the boundary; tools allowlist is the gate).
- Every worker diff is reviewed by the conductor before integration. No
auto-apply. (Auto-apply would be a capability-policy decision for later.)
- Verification is mechanical: suites + `node --check` / `bash -n` gates.
- Recursive decomposition = "fail → smaller task", never "hope."
## Worker task template
```json
{
"taskVersion": 1,
"id": "t-worker-<name>",
"prompt": "<full spec: goal, files, constraints, acceptance, self-checks>",
"workspace": "stack-repo",
"capabilities": { "tools": ["read", "write", "edit", "bash"] },
"session": "worker-1",
"timeoutSeconds": 600
}
```
+50
View File
@@ -0,0 +1,50 @@
# CURRENT — single source of "what happens next"
This file always names exactly one next action. Any "continue" / "next" /
"proceed" message means: execute the action below, fully (implement → test →
verify against its acceptance criteria → commit → push → close the issue →
update this file to the next action). No ambiguity, no re-planning.
## Next action
Owner review of M15 (agent seats + live user context) — then name the next target.
## Queue (ordered, not started)
1. Second real adapter (parked — owner focused on Pi)
2. UX/DX backlog #31 (grep highlight vs status colors — likely closed by owner's unalias)
3. Push policy decision: auto-apply commits locally; push remains explicit (documented in CONDUCTOR.md)
4. Session forking UX for agent seats (fork a seat's history into an experiment branch)
## 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
(none)
## Completed log
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)
+1 -1
View File
@@ -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."
+19
View File
@@ -0,0 +1,19 @@
{
"policyVersion": 1,
"autoApply": {
"enabled": true,
"allowedPaths": [
"scripts/**",
"docs/**",
"tasks/**",
"missions/**",
"adapters/**",
"README.md"
],
"suites": [
"test-config",
"test-task",
"test-release"
]
}
}
+126
View File
@@ -0,0 +1,126 @@
#!/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=""
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 ;;
--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
# 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=""
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 + "\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=""
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=}" ;;
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}"
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
+4
View File
@@ -50,4 +50,8 @@ 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"
}
+139
View File
@@ -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."
+200 -7
View File
@@ -28,6 +28,7 @@
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { randomBytes } from "node:crypto";
@@ -83,7 +84,7 @@ function validateId(value, what) {
function validateMission(document, file) {
if (!isPlainObject(document)) fail(2, "mission must be a JSON object");
rejectUnknownKeys(document, ["missionVersion", "id", "objective", "directives"], "mission");
rejectUnknownKeys(document, ["missionVersion", "id", "objective", "directives", "capabilities"], "mission");
if (document.missionVersion !== 1) {
fail(2, `unsupported missionVersion: ${JSON.stringify(document.missionVersion)} (supported: 1)`);
}
@@ -101,12 +102,34 @@ function validateMission(document, file) {
return d;
});
}
return { missionVersion: document.missionVersion, id: document.id, objective: document.objective, directives };
// Governing capability constraints (M9): same validation as task
// capabilities; semantically these BOUND tasks (least-privilege
// intersection at run time), never grant beyond them.
let capabilities = null;
if (document.capabilities !== undefined && document.capabilities !== null) {
if (!isPlainObject(document.capabilities)) fail(2, 'mission "capabilities" must be a JSON object');
rejectUnknownKeys(document.capabilities, ["tools"], 'mission "capabilities"');
if (!Array.isArray(document.capabilities.tools) || document.capabilities.tools.length === 0) {
fail(2, 'mission "capabilities.tools" must be a non-empty array of tool names');
}
const seen = new Set();
for (const tool of document.capabilities.tools) {
if (!SUPPORTED_TOOLS.includes(tool)) {
fail(2, `unsupported mission tool: ${JSON.stringify(tool)} (supported: ${SUPPORTED_TOOLS.join(", ")})`);
}
if (seen.has(tool)) fail(2, `duplicate tool in mission capabilities.tools: ${tool}`);
seen.add(tool);
}
capabilities = { tools: [...seen] };
}
return { missionVersion: document.missionVersion, id: document.id, objective: document.objective, directives, capabilities };
}
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)`);
}
@@ -169,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;
@@ -201,6 +241,7 @@ function validateTask(document, file) {
workspace,
tools,
session,
sessionForkFrom,
};
}
@@ -233,7 +274,7 @@ function writeOnce(file, content) {
}
}
function runTask(taskFile) {
function runTask(taskFile, options = {}) {
const resolved = JSON.parse(
spawnSync(process.execPath, [path.join(PROJECT_ROOT, "scripts", "mosaic-config.mjs"), "validate"], {
cwd: PROJECT_ROOT,
@@ -263,6 +304,15 @@ function runTask(taskFile) {
// CONTAINER path (dataRoot maps to /var/lib/mosaic in the image).
const spawnEnv = { ...process.env };
spawnEnv.MOSAIC_ADAPTER = resolved.execution.adapter;
// Self-sufficient env: direct invocation (e.g. `retry`) skips the shell
// launcher exports, so derive them from the resolved config and release.
// (Names here are the compose interpolation consumers, not PI_*.)
spawnEnv.MOSAIC_PROVIDER = resolved.execution.provider;
spawnEnv.MOSAIC_MODEL = resolved.execution.model;
spawnEnv.MOSAIC_DATA_ROOT = resolved.dataRoot;
const release = fs.readFileSync(path.join(PROJECT_ROOT, "RELEASE"), "utf8").trim();
const piVersion = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8")).dependencies["@earendil-works/pi-coding-agent"];
spawnEnv.MOSAIC_IMAGE_TAG = `mosaic-poc-agent:${piVersion}-r${release}`;
if (task.missionSnapshot) {
const relative = path.relative(resolved.dataRoot, runDir);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
@@ -281,7 +331,24 @@ function runTask(taskFile) {
workspaceContainerPath = `/var/lib/mosaic/workspaces/${task.workspace}`;
}
if (workspaceContainerPath) spawnEnv.MOSAIC_WORKSPACE = workspaceContainerPath;
spawnEnv.MOSAIC_TOOLS = task.tools ? task.tools.join(",") : "";
// Capability policy (M9): least-privilege intersection. A task may narrow
// a mission's tool grant, never widen it. Empty intersection = tool-free.
let effectiveTools = task.tools;
let policyNote = null;
if (task.missionSnapshot?.capabilities) {
const missionTools = task.missionSnapshot.capabilities.tools;
if (effectiveTools) {
effectiveTools = effectiveTools.filter((t) => missionTools.includes(t));
if (effectiveTools.length === 0) {
policyNote = `capability policy: mission ${task.missionSnapshot.id} and task request no tools in common -> tool-free run`;
}
} else {
effectiveTools = [...missionTools];
}
}
if (policyNote) process.stderr.write(`mosaic-task: ${policyNote}\n`);
spawnEnv.MOSAIC_TOOLS = effectiveTools ? effectiveTools.join(",") : "";
// Session (M6): persistent named session dir, passed as container path.
if (task.session) {
@@ -289,6 +356,26 @@ function runTask(taskFile) {
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],
@@ -335,9 +422,11 @@ function runTask(taskFile) {
request: task.prompt,
response,
expectedExact: expected,
...(options.retriedFrom ? { retriedFrom: options.retriedFrom } : {}),
workspace: task.workspace,
tools: task.tools,
tools: effectiveTools,
session: task.session,
sessionForkFrom: task.sessionForkFrom,
exitCode: proc.status,
signal: proc.signal ?? null,
provider: resolved.execution.provider,
@@ -415,6 +504,7 @@ function showRun(runId) {
`request: ${JSON.stringify(result.request)}`,
`response: ${JSON.stringify(result.response)}`,
result.expectedExact !== null && result.expectedExact !== undefined ? `expected: ${JSON.stringify(result.expectedExact)}` : null,
result.retriedFrom ? `retriedFrom: ${result.retriedFrom}` : null,
`timing: ${result.startedAt} -> ${result.finishedAt} (${result.durationMs} ms)`,
`exit: ${result.exitCode}${result.signal ? ` signal=${result.signal}` : ""}`,
].filter((line) => line !== null).join("\n") + "\n",
@@ -428,6 +518,102 @@ function showRun(runId) {
process.exit(0);
}
function retryRun(runId) {
if (!/^r-[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(runId)) {
fail(4, `invalid run id: ${JSON.stringify(runId)} (expected r-<id>)`);
}
const resolved = loadConfig();
const dir = path.join(runsRoot(resolved), runId);
if (!fs.existsSync(dir)) {
fail(4, `run not found: ${runId}`);
}
let snapshot;
try {
snapshot = fs.readFileSync(path.join(dir, "task.json"), "utf8");
} catch {
fail(4, `run task snapshot unreadable: ${runId}`);
}
// Relative mission paths in a snapshot resolve against the ORIGINAL task
// location, which no longer exists here — rewrite them to the run's own
// recorded mission.json so retries stay faithful.
let snapshotDoc;
try {
snapshotDoc = JSON.parse(snapshot);
} catch {
fail(4, `run task snapshot is not valid JSON: ${runId}`);
}
if (snapshotDoc.mission && !path.isAbsolute(snapshotDoc.mission)) {
const recordedMission = path.join(dir, "mission.json");
if (!fs.existsSync(recordedMission)) {
fail(4, `cannot retry ${runId}: relative mission path but no mission.json snapshot in run dir`);
}
snapshotDoc.mission = recordedMission;
snapshot = `${JSON.stringify(snapshotDoc, null, 2)}\n`;
}
// A retry is a brand-new run: replay the recorded task snapshot through
// the ordinary run path; existing run records stay untouched.
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mosaic-retry-"));
const tempTaskFile = path.join(tempDir, "task.json");
fs.writeFileSync(tempTaskFile, snapshot);
process.on("exit", () => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup only.
}
});
runTask(tempTaskFile, { retriedFrom: runId });
}
function pruneRuns(args) {
const resolved = loadConfig();
const root = runsRoot(resolved);
let keep = 50;
let apply = false;
for (const arg of args) {
if (arg === "--yes") apply = true;
else if (arg === "--keep") fail(4, "prune: --keep requires a value");
else if (arg.startsWith("--keep=")) {
keep = Number(arg.slice("--keep=".length));
if (!Number.isInteger(keep) || keep < 1) fail(4, `--keep must be a positive integer (got ${arg.slice(7)})`);
} else fail(4, `unknown prune option: ${arg}`);
}
let entries = [];
try {
entries = fs.readdirSync(root, { withFileTypes: true })
.filter((e) => e.name.startsWith("r-") && e.isDirectory() && !e.isSymbolicLink())
.map((e) => e.name)
.sort();
} catch {
// No runs yet.
}
if (entries.length <= keep) {
process.stdout.write(`prune: ${entries.length} run(s) present, keep=${keep} -> nothing to prune\n`);
process.exit(0);
}
const doomed = entries.slice(0, entries.length - keep); // oldest first
if (!apply) {
process.stdout.write(`prune (dry-run): would remove ${doomed.length} oldest run(s), keep ${entries.length - doomed.length}:\n`);
for (const id of doomed) process.stdout.write(` would remove: ${id}\n`);
process.stdout.write("prune: re-run with --yes to apply\n");
process.exit(0);
}
const receipt = path.join(root, ".pruned.log");
for (const id of doomed) {
fs.rmSync(path.join(root, id), { recursive: true, force: true });
fs.appendFileSync(receipt, `${JSON.stringify({ at: new Date().toISOString(), event: "pruned", runId: id })}\n`);
}
process.stdout.write(`prune: removed ${doomed.length} run(s), kept ${keep}; receipt: ${receipt}\n`);
process.exit(0);
}
const operation = process.argv[2];
const target = process.argv[3];
@@ -451,6 +637,13 @@ switch (operation) {
case "list":
listRuns();
process.exit(0);
case "prune":
pruneRuns(process.argv.slice(3));
break;
case "retry":
if (!target) fail(4, "usage: mosaic-task.mjs retry <runId>");
retryRun(target);
break;
default:
fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | show | list)`);
fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | show | list | retry | prune)`);
}
+124
View File
@@ -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"
+4
View File
@@ -14,4 +14,8 @@ load_config
load_release # compose requires MOSAIC_IMAGE_TAG; task runs are release-scoped too
bootstrap_runtime_dir
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 "$@"
+141
View File
@@ -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 ]
+17 -11
View File
@@ -10,10 +10,16 @@ SANDBOX="$(mktemp -d)"
trap 'rm -rf "$SANDBOX"' EXIT
PASS=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
FAIL=0
check() {
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "ok $1"; else FAIL=$((FAIL+1)); echo "FAIL $1"; fi
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
}
# expect_exit NAME EXPECTED_RC -- command...
@@ -25,10 +31,10 @@ expect_exit() {
rc=$?
if [ "$rc" -eq "$expected" ]; then
PASS=$((PASS + 1))
echo "ok $name (exit $rc)"
echo "${C_OK}OK${C_RESET} $name (exit $rc)"
else
FAIL=$((FAIL + 1))
echo "FAIL $name (exit $rc, expected $expected)"
echo "${C_FAIL}FAIL${C_RESET} $name (exit $rc, expected $expected)"
fi
}
@@ -62,8 +68,8 @@ check "env exports adapter" $?
rm -f "$SANDBOX/config.json"
expect_exit "bootstrap creates default when absent" 0 -- \
env MOSAIC_CONFIG="$SANDBOX/config.json" $CONFIG_OP bootstrap
[ -f "$SANDBOX/config.json" ] && { PASS=$((PASS+1)); echo "ok bootstrap wrote config file"; } \
|| { FAIL=$((FAIL+1)); echo "FAIL bootstrap wrote config file"; }
[ -f "$SANDBOX/config.json" ] && { PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} bootstrap wrote config file"; } \
|| { FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} bootstrap wrote config file"; }
SUM_BEFORE=$(sha256sum "$SANDBOX/config.json" | cut -d' ' -f1)
MTIME_BEFORE=$(stat -c %Y "$SANDBOX/config.json")
@@ -73,9 +79,9 @@ expect_exit "bootstrap is idempotent on existing config" 0 -- \
SUM_AFTER=$(sha256sum "$SANDBOX/config.json" | cut -d' ' -f1)
MTIME_AFTER=$(stat -c %Y "$SANDBOX/config.json")
if [ "$SUM_BEFORE" = "$SUM_AFTER" ] && [ "$MTIME_BEFORE" = "$MTIME_AFTER" ]; then
PASS=$((PASS+1)); echo "ok bootstrap did not rewrite existing config"
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} bootstrap did not rewrite existing config"
else
FAIL=$((FAIL+1)); echo "FAIL bootstrap rewrote existing config"
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} bootstrap rewrote existing config"
fi
# --- validate ---
@@ -140,9 +146,9 @@ cfg valid.json "$(valid_body "$DATA_ROOT")"
EVAL_OUT="$(MOSAIC_CONFIG="$SANDBOX/valid.json" $CONFIG_OP env)" || true
if eval "$EVAL_OUT" 2>/dev/null && [ "$MOSAIC_DATA_ROOT" = "$DATA_ROOT" ] \
&& [ "$MOSAIC_PROVIDER" = "zai" ] && [ "$MOSAIC_MODEL" = "glm-5.3-flash" ]; then
PASS=$((PASS+1)); echo "ok env exports resolve correctly"
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} env exports resolve correctly"
else
FAIL=$((FAIL+1)); echo "FAIL env exports resolve correctly"
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} env exports resolve correctly"
fi
# --- validation must not modify the file ---
@@ -150,9 +156,9 @@ SUM_INVALID_BEFORE=$(sha256sum "$SANDBOX/invalid.json" | cut -d' ' -f1)
MOSAIC_CONFIG="$SANDBOX/invalid.json" $CONFIG_OP validate >/dev/null 2>&1
SUM_INVALID_AFTER=$(sha256sum "$SANDBOX/invalid.json" | cut -d' ' -f1)
if [ "$SUM_INVALID_BEFORE" = "$SUM_INVALID_AFTER" ]; then
PASS=$((PASS+1)); echo "ok failed validation modified nothing"
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} failed validation modified nothing"
else
FAIL=$((FAIL+1)); echo "FAIL failed validation modified the file"
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} failed validation modified the file"
fi
echo
+9 -3
View File
@@ -15,6 +15,12 @@ cp RELEASE "$RELEASE_BACKUP"
trap 'cp "$RELEASE_BACKUP" RELEASE 2>/dev/null; rm -rf "$SANDBOX" "$RELEASE_BACKUP"' EXIT
PASS=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
FAIL=0
expect_exit() {
@@ -24,14 +30,14 @@ expect_exit() {
"$@" >/dev/null 2>&1
rc=$?
if [ "$rc" -eq "$expected" ]; then
PASS=$((PASS+1)); echo "ok $name (exit $rc)"
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $name (exit $rc)"
else
FAIL=$((FAIL+1)); echo "FAIL $name (exit $rc, expected $expected)"
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $name (exit $rc, expected $expected)"
fi
}
check() {
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "ok $1"; else FAIL=$((FAIL+1)); echo "FAIL $1"; fi
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
}
# ---------- fast: release identity ----------
+166 -17
View File
@@ -11,6 +11,12 @@ SANDBOX="$(mktemp -d)"
trap 'rm -rf "$SANDBOX"' EXIT
PASS=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
FAIL=0
expect_exit() {
@@ -20,14 +26,14 @@ expect_exit() {
"$@" >/dev/null 2>&1
rc=$?
if [ "$rc" -eq "$expected" ]; then
PASS=$((PASS+1)); echo "ok $name (exit $rc)"
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $name (exit $rc)"
else
FAIL=$((FAIL+1)); echo "FAIL $name (exit $rc, expected $expected)"
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $name (exit $rc, expected $expected)"
fi
}
check() {
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "ok $1"; else FAIL=$((FAIL+1)); echo "FAIL $1"; fi
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
}
latest_reason() {
@@ -36,10 +42,6 @@ latest_reason() {
[ -n "$latest" ] && node -e 'try{const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.reason??"")}catch{console.log("")}' "$latest/result.json" 2>/dev/null
}
check() {
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "ok $1"; else FAIL=$((FAIL+1)); echo "FAIL $1"; fi
}
CONFIG="$SANDBOX/config.json"
DATA_ROOT="$SANDBOX/data"
mkdir -p "$DATA_ROOT"
@@ -102,6 +104,37 @@ $TASK validate "$SANDBOX/ok.json" >/dev/null 2>&1
M2=$(stat -c %Y "$SANDBOX/ok.json")
[ "$M1" = "$M2" ] && check "validation does not modify the task file" 0 || check "validation does not modify the task file" 1
# ---------- retention: prune (deterministic, no Docker) ----------
mkdir -p "$SANDBOX/data"
cat > "$SANDBOX/prune-config.json" <<EOF
{"configVersion":1,"environment":"development","dataRoot":"$SANDBOX/data","execution":{"backend":"docker","provider":"zai","model":"m"}}
EOF
for i in 1 2 3 4 5; do
D="$SANDBOX/data/runs/r-20260903T0100_0${i}Z-suite00$i"
mkdir -p "$D"
printf '{"runVersion":1,"runId":"r-20260903T0100_0%sZ-suite00%s","taskId":"t","status":"succeeded"}' "$i" "$i" > "$D/result.json"
done
mkdir -p "$SANDBOX/data/sessions/sentinel" "$SANDBOX/data/workspaces/sentinel"
expect_exit "prune dry-run exits 0" 0 -- env MOSAIC_CONFIG="$SANDBOX/prune-config.json" node scripts/mosaic-task.mjs prune
[ "$(ls "$SANDBOX/data/runs" | grep -c '^r-')" -eq 5 ] \
&& check "dry-run deleted nothing" 0 || check "dry-run deleted nothing" 1
expect_exit "prune --keep=2 --yes removes oldest" 0 -- env MOSAIC_CONFIG="$SANDBOX/prune-config.json" node scripts/mosaic-task.mjs prune --keep=2 --yes
[ "$(ls "$SANDBOX/data/runs" | grep -c '^r-')" -eq 2 ] \
&& check "kept exactly 2 newest runs" 0 || check "kept exactly 2 newest runs" 1
NEWEST="r-20260903T0100_05Z-suite005"
[ -d "$SANDBOX/data/runs/$NEWEST" ] \
&& check "newest run kept, oldest pruned" 0 || check "newest run kept, oldest pruned" 1
[ -f "$SANDBOX/data/runs/.pruned.log" ] \
&& [ "$(grep -c 'pruned' "$SANDBOX/data/runs/.pruned.log")" -eq 3 ] \
&& check "append-only receipt written (3 entries)" 0 \
|| check "append-only receipt written (3 entries)" 1
[ -d "$SANDBOX/data/sessions/sentinel" ] && [ -d "$SANDBOX/data/workspaces/sentinel" ] \
&& check "sessions/workspaces untouched by prune" 0 \
|| check "sessions/workspaces untouched by prune" 1
expect_exit "prune with invalid keep exits 4" 4 -- env MOSAIC_CONFIG="$SANDBOX/prune-config.json" node scripts/mosaic-task.mjs prune --keep=0 --yes
# ---------- adapter seam: deterministic mock cases (Docker, no provider) ----------
if docker info >/dev/null 2>&1; then
good_task "$SANDBOX/ok.json"
@@ -142,6 +175,78 @@ EOF
&& grep -q 'Seam directive A.' "$SANDBOX/data/system-prompt.md" \
&& check "mission section injected into generated prompt" 0 \
|| check "mission section injected into generated prompt" 1
# retry lineage + relative mission path resolution
expect_exit "retry of mission run succeeds" 0 -- \
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOCKED \
node scripts/mosaic-task.mjs retry "$(ls -dt "$SANDBOX/data/runs"/r-* | head -1 | xargs basename)"
RT="$(ls -dt "$SANDBOX/data/runs"/r-* | head -1 | xargs basename)"
ORIG="$(ls -dt "$SANDBOX/data/runs"/r-* | sed -n 2p | xargs basename)"
node -e 'const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.exit(r.retriedFrom===process.argv[2]?0:1)' \
"$SANDBOX/data/runs/$RT/result.json" "$ORIG" \
&& check "retriedFrom lineage recorded" 0 || check "retriedFrom lineage recorded" 1
grep -q 'MISSION (runtime)' "$SANDBOX/data/system-prompt.md" \
&& check "mission section present after retry (relative path resolved)" 0 \
|| check "mission section present after retry (relative path resolved)" 1
expect_exit "retry of missing run exits 4" 4 -- \
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" node scripts/mosaic-task.mjs retry r-missing
# session fork plumbing (M11): fork source + target dir delivered
printf '{"taskVersion":1,"id":"t-forkplumb","prompt":"x","session":"fork-child","sessionForkFrom":"base"}' > "$SANDBOX/forkplumb.json"
mkdir -p "$SANDBOX/data/sessions/base"
printf '{}' > "$SANDBOX/data/sessions/base/20260903T000000-plumb.jsonl"
expect_exit "fork task runs via mock" 0 -- \
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOCKED \
scripts/run-task.sh run "$SANDBOX/forkplumb.json"
FL="$(ls -dt "$SANDBOX/data/runs"/r-* | head -1)"
grep -q '^MOSAIC_SESSION_FORK=/var/lib/mosaic/sessions/base/20260903T000000-plumb.jsonl$' "$FL/stderr.txt" 2>/dev/null \
&& grep -q '^MOSAIC_SESSION_DIR=/var/lib/mosaic/sessions/fork-child$' "$FL/stderr.txt" 2>/dev/null \
&& check "fork source + target delivered to adapter" 0 \
|| check "fork source + target delivered to adapter" 1
printf '{"taskVersion":1,"id":"t-f2","prompt":"x","sessionForkFrom":"base"}' > "$SANDBOX/noforktarget.json"
expect_exit "fork without session target exits 2" 2 -- $TASK validate "$SANDBOX/noforktarget.json"
printf '{"taskVersion":1,"id":"t-f3","prompt":"x","session":"base","sessionForkFrom":"base"}' > "$SANDBOX/selfork.json"
expect_exit "self-fork exits 2" 2 -- $TASK validate "$SANDBOX/selfork.json"
# 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
if [ "$1" = "ABSENT" ]; then
printf '{"missionVersion":1,"id":"m-pol","objective":"o"}' > "$SANDBOX/pol-m.json"
else
printf '{"missionVersion":1,"id":"m-pol","objective":"o","capabilities":{"tools":[%s]}}' "$1" > "$SANDBOX/pol-m.json"
fi
if [ "$2" = "ABSENT" ]; then
printf '{"taskVersion":1,"id":"t-pol","prompt":"x","mission":"pol-m.json"}' > "$SANDBOX/pol-t.json"
else
printf '{"taskVersion":1,"id":"t-pol","prompt":"x","mission":"pol-m.json","capabilities":{"tools":[%s]}}' "$2" > "$SANDBOX/pol-t.json"
fi
env MOSAIC_CONFIG="$SANDBOX/mock-adapters.json" MOSAIC_MOCK_RESPONSE=MOCKED \
scripts/run-task.sh run "$SANDBOX/pol-t.json" >/dev/null 2>&1
grep -o '^MOSAIC_TOOLS=.*' "$(ls -dt "$SANDBOX/data/runs"/r-* | head -1)/stderr.txt" 2>/dev/null
}
[ "$(pol_run '"read","bash"' ABSENT)" = 'MOSAIC_TOOLS=read,bash' ] \
&& check "policy: mission only -> mission tools" 0 || check "policy: mission only -> mission tools" 1
[ "$(pol_run ABSENT '"read","bash"')" = 'MOSAIC_TOOLS=read,bash' ] \
&& check "policy: task only -> task tools" 0 || check "policy: task only -> task tools" 1
[ "$(pol_run '"read"' '"bash","read"')" = 'MOSAIC_TOOLS=read' ] \
&& check "policy: both -> intersection (task narrowed)" 0 || check "policy: both -> intersection (task narrowed)" 1
[ "$(pol_run '"grep"' '"bash","read"')" = 'MOSAIC_TOOLS=' ] \
&& check "policy: empty intersection -> tool-free" 0 || check "policy: empty intersection -> tool-free" 1
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"
# 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
@@ -194,18 +299,12 @@ dump_latest_run() {
fi
}
latest_reason() {
local latest
latest="$(ls -dt "$SANDBOX/data/runs"/r-* 2>/dev/null | head -1)"
[ -n "$latest" ] && node -e 'try{const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.reason??"")}catch{console.log("")}' "$latest/result.json" 2>/dev/null
}
if docker info >/dev/null 2>&1; then
RUNS1=$(ls "$DATA_ROOT/runs" 2>/dev/null | wc -l)
if scripts/run-task.sh run "$SANDBOX/ok.json" >/dev/null 2>&1; then
PASS=$((PASS+1)); echo "ok live hello task succeeds with exact marker"
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} live hello task succeeds with exact marker"
else
FAIL=$((FAIL+1)); echo "FAIL live hello task succeeds with exact marker" >&2
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} live hello task succeeds with exact marker" >&2
dump_latest_run
fi
@@ -222,9 +321,9 @@ process.exit(r.status === "succeeded" && r.response === "MOSAIC_HELLO_OK" && r.e
RC=$?
WRONG_REASON="$(latest_reason)"
if [ "$RC" -eq 1 ] && [ "$WRONG_REASON" = "expect-mismatch" ]; then
PASS=$((PASS+1)); echo "ok wrong expectExact fails with exit 1 (reason: expect-mismatch)"
PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} wrong expectExact fails with exit 1 (reason: expect-mismatch)"
else
FAIL=$((FAIL+1)); echo "FAIL wrong expectExact (exit $RC, reason: '${WRONG_REASON:-none}')" >&2
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} wrong expectExact (exit $RC, reason: '${WRONG_REASON:-none}')" >&2
dump_latest_run
fi
@@ -234,10 +333,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 ]
+9 -2
View File
@@ -16,6 +16,13 @@ source scripts/common.sh
EXPECTED="${EXPECTED_MARKER:-MOSAIC_HELLO_OK}"
# 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
load_config
load_release
IMAGE="$MOSAIC_IMAGE_TAG"
@@ -51,11 +58,11 @@ TRIMMED="$(printf '%s' "$RESPONSE" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]
# 4-6. Exact comparison gate.
if [ "$TRIMMED" = "$EXPECTED" ]; then
echo "PASS: response matches expected marker"
echo "${C_OK}PASS${C_RESET}: response matches expected marker"
exit 0
fi
echo "FAIL: response does not match expected marker" >&2
echo "${C_FAIL}FAIL${C_RESET}: response does not match expected marker" >&2
printf 'expected: %s\n' "$EXPECTED" >&2
printf 'actual : %s\n' "$TRIMMED" >&2
exit 1
+14
View File
@@ -0,0 +1,14 @@
---
name: ms-tools
description: Contains a reference to all available tools for Mosaic Stack.
disable-model-invocation: true
---
# ms-tools
You are a Mosaic fleet agent. A maintained toolkit lives at `<dir>`.
Use it FIRST for the tasks below — improvising with raw CLIs causes the recurring failures this
skill exists to prevent. This is the high-frequency fast path; the full reference is the
`# Machine Tools` section already in your system prompt.
+85
View File
@@ -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.
+47 -4
View File
@@ -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
View File
@@ -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"
+9
View File
@@ -0,0 +1,9 @@
{
"taskVersion": 1,
"id": "t-worker-retry-refine",
"prompt": "Follow-up on your last change to scripts/mosaic-task.mjs (the retry operation). Conductor review found one defect: runTask builds spawnEnv from process.env, so a direct 'node scripts/mosaic-task.mjs retry <runId>' invocation lacks the launcher-exported variables and compose fails with: required variable MOSAIC_IMAGE_TAG is missing.\n\nFIX (in runTask, not retryRun): make spawnEnv self-sufficient —\n1. spawnEnv.PI_PROVIDER = resolved.execution.provider; spawnEnv.PI_MODEL = resolved.execution.model (from the already-loaded resolved config).\n2. spawnEnv.MOSAIC_IMAGE_TAG = 'mosaic-poc-agent:' + <pinned pi version> + '-r' + <RELEASE file content trimmed>, reading RELEASE and package.json the same way scripts/common.sh load_release does.\nKeep the change minimal; do not touch other files; keep code style.\n\nSELF-CHECK: node --check scripts/mosaic-task.mjs must pass.\nWhen finished, reply with exactly: REFINE_DONE",
"workspace": "stack-repo",
"capabilities": { "tools": ["read", "write", "edit", "bash"] },
"session": "worker-1",
"timeoutSeconds": 600
}
+9
View File
@@ -0,0 +1,9 @@
{
"taskVersion": 1,
"id": "t-worker-retry",
"prompt": "You are working alone inside a git repository checkout (your current working directory). Implement a new feature in scripts/mosaic-task.mjs.\n\nFEATURE: add a 'retry <runId>' operation alongside the existing 'validate | run | show | list' operations.\n\nBEHAVIOR: 'retry <runId>' re-executes a previously recorded run. Steps: (1) validate the runId argument with the same pattern showRun uses; (2) load the config exactly like showRun does; (3) locate the run directory under the runs root; if it does not exist, fail with exit code 4 and the message 'run not found: <runId>'; (4) read that run directory's task.json snapshot; if unreadable, fail with exit code 4; (5) write the snapshot to a temp file and execute it through the EXISTING runTask(path) function so a brand-new run is created — do not duplicate any execution logic; (6) exit with whatever exit code the new run produced.\n\nCONSTRAINTS: reuse runTask; do not refactor unrelated code; do not touch any file other than scripts/mosaic-task.mjs; keep the existing code style; update the default usage error message to include retry.\n\nSELF-CHECK (run it yourself with the bash tool): node --check scripts/mosaic-task.mjs must pass.\n\nWhen finished, reply with exactly: RETRY_DONE",
"workspace": "stack-repo",
"capabilities": { "tools": ["read", "write", "edit", "bash"] },
"session": "worker-1",
"timeoutSeconds": 600
}
+29
View File
@@ -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}}
+730
View File
@@ -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
View File
@@ -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
+272
View File
@@ -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
+83
View File
@@ -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 (23); 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.
+163
View File
@@ -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");
},
});
}
+54
View File
@@ -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" }
]
}
+228
View File
@@ -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 — its 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);
+181
View File
@@ -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);
}
+2
View File
@@ -1,3 +1,5 @@
# POC user
This is an isolated local runtime test.
The user's name is Jason.