diff --git a/README.md b/README.md index 9bccfc60..bfc6d6c4 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,14 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery. +Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi, +`/goal set ` starts a bounded persistent loop that checks every turn and successful +compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with +`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state +entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and +evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension +into `~/.pi/agent/extensions/`. + ### TUI & Gateway ```bash diff --git a/docs/PRD.md b/docs/PRD.md index c8fe7a74..806ea7d3 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -102,6 +102,128 @@ Context compaction, session replacement, and same-PID runtime reloads can leave --- +## Pi Persistent Goal Loop (#1150) + +### Problem and objective + +A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is +not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an +optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native +lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is +reached. + +The objective is a Mosaic-owned Pi extension deployed from the framework into +`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`. + +### Scope + +#### In scope + +1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under + `packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make + `mosaic pi` load it alongside the core Mosaic extension when present. +2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help + operations without silently replacing an active goal. +3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries, + restored on session start and tree navigation, and never rely on a compaction summary as its + source of truth. +4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every + model request so it remains effective across tool turns, retries, and post-compaction requests. +5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event. + A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`, + or `blocked` status without requiring a redundant model turn. +6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive + evidence-bearing verification report. Any continuation report or successful compaction during + verification SHALL reset the verification sequence. +7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily + `agent_settled`; manual compaction and restored active sessions may schedule a deferred idle + continuation without re-entering compaction handlers. +8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress + limits. Exhausted or blocked goals pause rather than continuing indefinitely. +9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the + goal extension is framework-owned under `runtime/**`, while no goal extension or configuration + asset is created or modified under the operator's main Pi configuration. Pi remains the owner of + its native session files used by `appendEntry()`. + +#### Out of scope + +1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete. +2. Automatically executing user-supplied shell predicates or accepting executable validation code in + `/goal` arguments. +3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime + supervisor owns process durability. +4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice. + +### User and stakeholder requirements + +- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report. +- The agent remains oriented after each turn and compaction until verified, paused, blocked, + exhausted, or cancelled. +- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an + extension asset to `~/.pi/agent/extensions/`. +- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest + sync path. + +### Non-functional requirements + +1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no + completion without non-empty reported evidence. +2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe + context injection, and stale-timer cancellation on session shutdown. +3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the + active agent's final terminating tool call. +4. **Observability:** Pi status/notifications expose phase and bounded counters without recording + credentials or hidden model reasoning. +5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi + provider/network access. + +### Acceptance criteria + +1. `AC-PGL-01`: A framework-sync fixture installs the extension at + `$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are + emitted in deterministic order while absent optional files remain backward-compatible. +2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement + refusal, and bounded input handling. +3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every + request, two evidence-bearing achievement reports are required, and `agent_settled` continues an + unmet goal without duplicate scheduling. +4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and + rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session + branch state is reconstructed correctly. +5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous + continuation, while pause/cancel/blocked states do not restart. +6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load + smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before + issue #1150 closes. + +### Constraints, risks, and assumptions + +- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`, + `context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating + tool results. +- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory + second verification pass, explicit semantic limitations, and operator-visible reports. +- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds + and paused terminal states. +- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from + `agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown. +- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy; + rationale: it provides a real recheck without doubling every turn's model cost. Future policy may + add independent or deterministic validators. +- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by + bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget. +- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication + is requested. + +### Testing and delivery intent + +Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle +objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests, +launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one +reviewed squash PR to `main` with terminal-green CI. + +--- + ## Fleet Declarative Configuration Management Workstream (FCM, #758) ### Problem and objective diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 3f5a296c..24d81285 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -14,6 +14,13 @@ - [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior. - [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes. +## Pi persistent goals + +- [Persistent goal user guide](guides/user-guide.md#pi-persistent-goals) — `/goal` commands, verification behavior, limits, compaction/resume semantics, and limitations. +- [Goal extension developer guide](guides/dev-guide.md#pi-persistent-goal-extension) — framework ownership, launcher ordering, lifecycle design, tests, and local Mosaic-path smoke workflow. +- [Goal loop operations](guides/admin-guide.md#pi-goal-loop-operations) — deployment ownership, bounded settings, pause/resume procedures, and supervisor boundary. +- [Pi runtime reference](../packages/mosaic/framework/runtime/pi/RUNTIME.md#extensions) — deployed paths, command summary, and bounded environment settings. + ## Fleet configuration management - [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map. diff --git a/docs/guides/admin-guide.md b/docs/guides/admin-guide.md index e08cfd8a..ed9d2a9d 100644 --- a/docs/guides/admin-guide.md +++ b/docs/guides/admin-guide.md @@ -7,7 +7,8 @@ 3. [Provider Configuration](#provider-configuration) 4. [MCP Server Configuration](#mcp-server-configuration) 5. [Environment Variables Reference](#environment-variables-reference) -6. [Local Fleet Canary](./fleet-local-canary.md) +6. [Pi Goal Loop Operations](#pi-goal-loop-operations) +7. [Local Fleet Canary](./fleet-local-canary.md) --- @@ -264,6 +265,16 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe | `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions | | `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users | +### Mosaic Pi goal loop + +| Variable | Default | Description | +| ----------------------------- | ------- | -------------------------------------------------------------------- | +| `MOSAIC_GOAL_MAX_TURNS` | `40` | Per-goal autonomous turn limit; accepted range `1..500` | +| `MOSAIC_GOAL_MAX_NO_PROGRESS` | `6` | Consecutive identical progress-report limit; accepted range `1..100` | + +These variables are consumed by the framework-owned Pi goal extension at goal creation. Invalid or +out-of-range values fall back to the defaults; they do not disable the bounds. + ### Providers | Variable | Default | Description | @@ -374,3 +385,29 @@ Session cleanup is scoped to one session identifier and only removes that sessio | Variable | Default | Description | | ----------------------- | ----------------------------- | ------------------------------------------ | | `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations | + +--- + +## Pi Goal Loop Operations + +The reviewed runtime asset is deployed at +`~/.config/mosaic/runtime/pi/goal-extension.ts` by framework install/update. Do not install another +copy under `~/.pi/agent/extensions/`; duplicate registration can create suffixed commands and two +competing lifecycle controllers. + +Operational checks: + +1. Run `mosaic pi` and verify `/goal help` is available. +2. Use `/goal status` to inspect phase, turn/no-progress limits, compaction checks, and evidence. + Reports persist in Pi session data; controller-owned state redacts common credential shapes, but + Pi's model/tool-call history is separate. Operators must not place secrets or raw sensitive output + in goals, pause reasons, or evidence. +3. Use `/goal pause ` before planned maintenance or manual investigation. Pause and cancel + abort the current goal-driven run when Pi is busy. +4. Use `/goal resume` only after addressing a blocker; counters restart with the configured bounds. +5. Use `/goal cancel` before replacing an unfinished goal. + +A blocked or exhausted goal remains stopped and visible; Mosaic does not automatically raise its +limits or restart the process. Framework sync owns file deployment, while Pi's native session file +owns branch replay. Process/host restart remains the responsibility of the existing runtime or fleet +supervisor. diff --git a/docs/guides/dev-guide.md b/docs/guides/dev-guide.md index 14e26f6d..81c2c050 100644 --- a/docs/guides/dev-guide.md +++ b/docs/guides/dev-guide.md @@ -9,8 +9,9 @@ 5. [Adding New MCP Tools](#adding-new-mcp-tools) 6. [Database Schema and Migrations](#database-schema-and-migrations) 7. [Claude Code Skill Bridge](#claude-code-skill-bridge) -8. [API Endpoint Reference](#api-endpoint-reference) -9. [Local Fleet Canary](./fleet-local-canary.md) +8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension) +9. [API Endpoint Reference](#api-endpoint-reference) +10. [Local Fleet Canary](./fleet-local-canary.md) --- @@ -385,6 +386,85 @@ M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the canonical root directly. Codex still relies on the existing full skill-sync linker and needs separate parity analysis before this lifecycle API is extended. +## Pi Persistent Goal Extension + +The source of the Mosaic-owned Pi goal controller is: + +```text +packages/mosaic/framework/runtime/pi/goal-extension.ts +``` + +The framework manifest classifies `runtime/**` as framework-owned. Both the bash installer and the +TypeScript file adapter therefore deploy the same reviewed source to: + +```text +$MOSAIC_HOME/runtime/pi/goal-extension.ts +# default: ~/.config/mosaic/runtime/pi/goal-extension.ts +``` + +Do not copy or link this extension into `~/.pi/agent/extensions/`. The launcher function +`discoverPiExtensionArgs()` emits the core `mosaic-extension.ts` first and the optional +`goal-extension.ts` second, preserving compatibility with an older installed framework that does +not have the goal file yet. + +### Lifecycle design + +| Pi API | Goal-controller responsibility | +| ------------------------------ | --------------------------------------------------------------------------------- | +| `registerCommand('goal')` | Set, inspect, pause, resume, or cancel one branch-specific goal | +| `registerTool(...)` | Record a terminating structured progress report with evidence | +| `context` | Inject the active goal contract before every provider request | +| `turn_end` | Record every turn, reject mixed final reports, and enforce the turn bound | +| `agent_settled` | Start one deduplicated continuation only after Pi has no retry/compact/queue work | +| `session_compact` | Record the compact check, reset provisional verification, and defer idle work | +| `session_start`/`session_tree` | Rebuild state from custom entries on the active branch | +| `session_shutdown` | Invalidate deferred callbacks and clear UI state | + +State is appended as `mosaic-goal-state` custom entries, which do not enter model context. The +`context` hook creates a fresh hidden `mosaic-goal-context` message for each request instead of +trusting compaction summaries. The `mosaic_goal_report` result uses `terminate: true`; when it is the +sole final tool call, Pi avoids an unnecessary model response before the controller decides whether +to verify, continue, or stop. + +Before state is appended or displayed, the controller applies bounded credential-pattern redaction +to the goal statement, report summary/evidence/next step, and stop reason. Fingerprints are computed +over redacted report content. Pi session entries are append-only, so a credential-bearing legacy +entry cannot honestly be erased by the extension: restoration fails closed, emits a warning, and +requires removal of the affected session before setting a new goal. This is defense-in-depth rather +than a secret-storage contract, and it does not rewrite Pi's separate model-message/tool-call +history. Goal prompts tell the agent not to submit credentials or raw sensitive output, and tests use +canaries to prove known forms do not reach new custom entries, status text, context, or tool details +while ordinary typed fields such as `token: string` remain intact. + +Completion remains evidence-gated but semantic: two consecutive `achieved` reports are required, +and the second run is explicitly a verification pass. This avoids an extra judge-model request after +every turn. Deterministic validator commands are intentionally not accepted as `/goal` input in this +slice, so never describe this mechanism as proof of arbitrary natural-language completion. + +### Tests and local smoke workflow + +```bash +pnpm --filter @mosaicstack/mosaic exec vitest run \ + src/runtime/pi-goal-extension.spec.ts \ + src/commands/launch.spec.ts \ + src/config/file-adapter.test.ts + +bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh +``` + +For an additive local smoke test without reseeding unrelated live framework files: + +```bash +install -D -m 0644 \ + packages/mosaic/framework/runtime/pi/goal-extension.ts \ + ~/.config/mosaic/runtime/pi/goal-extension.ts + +pi --extension ~/.config/mosaic/runtime/pi/goal-extension.ts +``` + +Use `/goal help`, `/goal set ...`, and `/goal status` in that test session. A released framework +sync installs the file, and a released Mosaic CLI loads it automatically through `mosaic pi`. + ## API Endpoint Reference All endpoints are served by the gateway at `http://localhost:14242` by default. diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index 18746e8b..eaf3ef0c 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -8,9 +8,10 @@ 4. [Tasks](#tasks) 5. [Settings](#settings) 6. [CLI Usage](#cli-usage) -7. [Sub-package Commands](#sub-package-commands) -8. [Telemetry](#telemetry) -9. [Local Fleet Canary](./fleet-local-canary.md) +7. [Pi Persistent Goals](#pi-persistent-goals) +8. [Sub-package Commands](#sub-package-commands) +9. [Telemetry](#telemetry) +10. [Local Fleet Canary](./fleet-local-canary.md) --- @@ -307,6 +308,57 @@ mosaic prdy mosaic quality-rails ``` +## Pi Persistent Goals + +`mosaic pi` loads a Mosaic-owned goal extension from +`~/.config/mosaic/runtime/pi/goal-extension.ts`. It is deliberately not installed in +`~/.pi/agent/extensions/`; framework installation and updates manage it with the rest of the Mosaic +runtime assets. + +Start Pi, then set a goal: + +```text +/goal set Deliver the feature, tests, documentation, and verification evidence +# Shorthand: +/goal Deliver the feature, tests, documentation, and verification evidence +``` + +Control and inspect the loop with: + +| Command | Behavior | +| ---------------------- | ------------------------------------------------------------------ | +| `/goal status` | Show phase, limits, compaction checks, latest report, and evidence | +| `/goal pause [reason]` | Stop autonomous continuation while preserving the goal | +| `/goal resume` | Resume with fresh turn and no-progress counters | +| `/goal cancel` | Cancel the goal and remove its active status | +| `/goal help` | Show command help | + +While a goal is active, Mosaic injects its contract before every Pi model request and checks every +completed model/tool turn. The agent ends each work cycle with the structured +`mosaic_goal_report` tool. `achieved` is provisional until a second consecutive report rechecks the +whole goal with evidence. A continuation report or a successful compaction resets provisional +verification. + +Goal statements and reports are stored in Pi session data. Mosaic redacts common credential shapes +before appending its goal-state entries and before goal tool output or `/goal status`, but +pattern-based redaction is not a secret store. Pi's own model-message and tool-call records are +outside that redactor. Never put tokens, passwords, private keys, connection strings, or raw +sensitive output in a goal or report; cite the command, artifact, and pass/fail result instead. + +The loop stops instead of running forever when it is paused, blocked, cancelled, verified, reaches +its turn limit, or repeats the same no-progress report too many times. Defaults are 40 turns and 6 +repeated no-progress reports. Operators may lower or raise them within enforced bounds before +launching Pi: + +```bash +MOSAIC_GOAL_MAX_TURNS=60 MOSAIC_GOAL_MAX_NO_PROGRESS=8 mosaic pi +``` + +Goal state is branch-specific Pi session data. It survives compaction and session resume, but Pi's +process still must be relaunched or supervised after a process/host failure. This initial verifier +checks structured evidence twice; it cannot mathematically prove every arbitrary natural-language +goal. Use explicit acceptance criteria and inspect `/goal status` for consequential work. + --- ### Claude Code Skill Registration diff --git a/docs/scratchpads/1150-pi-goal-extension.md b/docs/scratchpads/1150-pi-goal-extension.md new file mode 100644 index 00000000..e9fe8eae --- /dev/null +++ b/docs/scratchpads/1150-pi-goal-extension.md @@ -0,0 +1,156 @@ +# #1150 — Pi persistent goal extension + +- **Task ID:** ISSUE-1150 (no `docs/TASKS.md` row; that file is orchestrator-only) +- **Issue:** #1150 — `pi: add persistent /goal controller extension to Mosaic framework` +- **Branch:** `feat/1150-pi-goal-extension` +- **Mode:** Delivery +- **Status:** in progress + +## Objective + +Build and locally validate a Mosaic-owned Pi `/goal` extension. Source must ship from +`packages/mosaic/framework/runtime/pi/`, framework sync must deploy it under +`~/.config/mosaic/runtime/pi/`, and no extension/configuration asset may be written into `~/.pi`. +Pi's native session manager remains the owner of session entries. + +## Scope and acceptance source + +- Canonical requirements: `docs/PRD.md`, section **Pi Persistent Goal Loop (#1150)**. +- User intent: continuous goal orientation and status checking after each Pi turn and compaction, + tested locally before framework delivery. +- Documentation target: canonical in-repo user/developer/runtime docs; no external publication. + +## Assumptions + +- `ASSUMPTION:` Initial semantic verification uses two consecutive structured, evidence-bearing + reports from the working agent rather than a second model request after every turn. This keeps the + loop testable and avoids doubling model cost while making the limitation explicit. +- `ASSUMPTION:` Default autonomous bounds are 40 turns and 6 repeated no-progress reports, with only + bounded numeric environment overrides. +- `ASSUMPTION:` A local smoke copy to `~/.config/mosaic/runtime/pi/goal-extension.ts` is authorized by + the user's explicit request. Full framework reseed into the live home is not required for the smoke + test and would touch unrelated framework-owned files. + +## Budget + +- Working estimate: 30K implementation/review tokens. +- Hard user cap: none stated. +- Cost control: deterministic fake-Pi tests; no nested evaluator calls; only bounded arithmetic/load + smoke workflows against the installed runtime. + +## Plan + +1. Update PRD and create tracking/scratchpad artifacts. +2. Read launcher, installer ownership, Pi extension, and documentation surfaces. +3. TDD: add fake-Pi behavior tests for commands, state restoration, turn checks, compaction, limits, + verification, and continuation deduplication. +4. Implement `runtime/pi/goal-extension.ts` and deterministic launcher discovery. +5. Add framework-sync/deployment acceptance coverage. +6. Update user, developer, runtime, framework README, and sitemap documentation. +7. Run focused tests, local Mosaic-path smoke test, then baseline repository gates. +8. Run independent review, remediate, commit, push/PR/CI/merge/issue closure per delivery gates. + +## TDD decision + +Applied. The continuation state machine and lifecycle scheduling are control-path logic where a race +or false terminal state can cause unbounded work or premature completion. + +## Progress checkpoints + +- [x] Issue #1150 created through Mosaic wrapper. +- [x] Isolated worktree created from `origin/main`. +- [x] PRD requirements and acceptance criteria added. +- [x] Task scratchpad created. +- [x] RED controller and security-regression tests written and observed failing before implementation. +- [x] Goal controller, launcher discovery, framework deployment coverage, and bounded state machine + implemented. +- [x] User, admin, developer, runtime, adapter, README, and sitemap documentation updated. +- [x] Final source copied additively to `~/.config/mosaic/runtime/pi/goal-extension.ts`; source and + deployed SHA-256 are identical. +- [x] Live Pi RPC smoke from the exact Mosaic path reached `achieved` with two verification passes and + no extension errors. +- [x] Baseline and situational checks completed, except the explicitly documented unavailable + PostgreSQL-only root integration case. +- [x] Independent code and OWASP/security reviews completed; all findings remediated and re-reviewed. +- [ ] Commit, push, PR, terminal-green CI, squash merge, and issue closure complete. + +## Tests and evidence + +### Situational + +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/runtime/pi-goal-extension.spec.ts` + - final: 25 passed. + - Covers commands, per-turn checks, context injection, two-pass verification, mixed-report + rejection, bounded limits, compaction, branch restore, stale timers, credential redaction, + typed-field false-positive protection, and append-only legacy-state fail-closed behavior. +- Final focused launcher/controller/file-adapter run: 3 files / 67 tests passed. +- Final V8 coverage for `framework/runtime/pi/goal-extension.ts`: + - 99.17% statements/lines, 93.78% branches, 100% functions. +- Installer migration fixture: 24 passed and byte-compared the deployed framework asset. +- Standalone extension TypeScript check against installed Pi 0.84.1 types passed: + `pnpm --filter @mosaicstack/mosaic exec tsc --noEmit --pretty false --module NodeNext + --moduleResolution NodeNext --target ES2022 --skipLibCheck framework/runtime/pi/goal-extension.ts`. +- Live deployment/load evidence: + - source/deployed SHA-256: + `1f0a3806e0948ad5f49684273a7e535e9880c148f7fd16d13ee487fcd601f637`. + - `get_commands` identified `/goal` as an extension command sourced from + `~/.config/mosaic/runtime/pi/goal-extension.ts`; `/goal help` succeeded; zero extension errors. + - live arithmetic goal ended `achieved`, verification `2/2`, with 3 goal reports / 3 agent starts + and zero extension errors. + - no goal extension exists under `~/.pi` extension paths. + +### Baseline + +- `pnpm build`: passed before the final framework-only redaction remediation; the extension is not a + package build input and its final source passed the standalone Pi type check. +- `pnpm typecheck`: 45/45 tasks passed. +- `pnpm lint`: 25/25 tasks passed. +- `pnpm format:check`: passed. +- Final Mosaic package components: + - Vitest: 82 files / 1,539 tests passed. + - full `test:framework-shell` harness passed. + - the discovered pre-existing tmux loader-marker race was reproduced with constructor PID + evidence, fixed with a pane readiness/FIFO barrier, passed 3 consecutive focused runs, and passed + in the full shell harness. + - one combined rerun encountered the separate existing real-lease probe TOCTOU in + `install-ordering-guard.spec.ts`; an earlier final Vitest run was fully green and the changed + focused suites remained green. +- Gateway safe baseline excluding the prohibited PostgreSQL-only fixture: 55 files / 600 tests passed + (6 files / 12 tests skipped by their existing environment gates). +- Root `pnpm test` reached 43 successful workspace tasks and all changed-package Vitest tests, but + the unchanged `apps/gateway/src/__tests__/cross-user-isolation.test.ts` afterAll hook retried a + PostgreSQL connection and failed authentication (`28P01`). This checkout explicitly forbids local + PostgreSQL startup/access; the failure is unrelated to #1150 and cannot be remediated by starting + the database. The gateway suite excluding that PostgreSQL-only file and required CI are used as + the safe verification paths. + +### Independent review + +- Codex code review: approved, 0 findings across 15 files. +- Initial Codex security review: one medium CWE-532/A09 finding for raw report persistence. +- Remediation added central credential-pattern redaction, prompt/docs guidance, canary tests, typed + field false-positive guards, and sticky fail-closed restore for credential-bearing append-only + history. +- Codex security re-review: risk `none`, 0 findings, confidence 0.87. +- Focused remediation review findings were fixed; final focused re-review verdict: `APPROVE`. +- Focused independent review of the tmux readiness barrier: `APPROVE`, no actionable findings. + +## Risks and blockers + +- Live `~/.config/mosaic` is shared by active Pi/fleet processes. Local deployment remained a single + additive framework file and did not reload or restart unrelated sessions. +- Completion verification is semantic, not mathematical: the active agent supplies structured + evidence twice. Operators must still inspect consequential outcomes. +- Credential redaction is pattern-based defense-in-depth, not a secret store. It covers + controller-owned state/status/tool details, not Pi's separate model-message/tool-call history. + Goals and reports must never contain real secrets or raw sensitive output. Because Pi session + entries are append-only, a detected credential-bearing legacy branch fails closed and the affected + session must be removed. +- Current installed Pi is newer than the repository's historical gateway Pi dependency. The + extension was checked and smoke-tested against installed Pi 0.84.1 using stable documented APIs. +- Local root testing cannot safely execute the unchanged PostgreSQL-only integration fixture under + the checkout's explicit database safety constraints. Terminal-green PR CI remains mandatory before + merge. +- The unchanged real-lease default-probe test can observe different broker availability across its two + sequential probes; one combined package rerun hit that existing TOCTOU. The same final Vitest suite + passed in a separate run, and CI remains the merge authority. diff --git a/packages/mosaic/framework/adapters/pi.md b/packages/mosaic/framework/adapters/pi.md index 9837ea08..c9687818 100644 --- a/packages/mosaic/framework/adapters/pi.md +++ b/packages/mosaic/framework/adapters/pi.md @@ -13,7 +13,8 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher: 1. Injects the full runtime contract via `--append-system-prompt` 2. Loads Mosaic skills via `--skill` flags -3. Loads the Mosaic extension via `--extension` for lifecycle hooks +3. Loads framework-owned `mosaic-extension.ts` and `goal-extension.ts` from + `~/.config/mosaic/runtime/pi/` via ordered `--extension` flags 4. Detects active missions and injects initial prompts ## Capabilities vs Other Runtimes @@ -22,6 +23,7 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher: - Native thinking levels replace sequential-thinking MCP - Native skill discovery compatible with Mosaic SKILL.md format - Native extension system for lifecycle hooks (TypeScript, not bash shims) +- Bounded persistent `/goal` loop with per-turn, post-compaction, and two-pass evidence checks - Native session persistence and resume - Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers) diff --git a/packages/mosaic/framework/defaults/README.md b/packages/mosaic/framework/defaults/README.md index e96dfb70..56447b92 100644 --- a/packages/mosaic/framework/defaults/README.md +++ b/packages/mosaic/framework/defaults/README.md @@ -94,7 +94,14 @@ The launcher: 1. Verifies `~/.config/mosaic` exists 2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing) 3. Injects `AGENTS.md` into the runtime -4. Forwards all arguments to the runtime CLI +4. For Pi, loads the framework-owned core and persistent-goal extensions from + `~/.config/mosaic/runtime/pi/` +5. Forwards all arguments to the runtime CLI + +Inside `mosaic pi`, `/goal set ` starts a bounded persistent goal loop. Use `/goal status`, +`/goal pause`, `/goal resume`, or `/goal cancel` to control it. The extension remains part of Mosaic +under `~/.config/mosaic/runtime/pi/goal-extension.ts`; it is not installed in Pi's main extension +directory. You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`. @@ -114,7 +121,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim │ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks │ ├── codex/ ← instructions.md, RUNTIME.md │ ├── opencode/ ← AGENTS.md, RUNTIME.md -│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts +│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts, goal-extension.ts │ └── mcp/ ← MCP server configs ├── skills/ ← Universal skills (synced from mosaic/agent-skills) ├── skills-local/ ← Local cross-runtime skills @@ -126,7 +133,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim | Launch method | Injection mechanism | | ------------------- | ----------------------------------------------------------------------------------------- | -| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + extension | +| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + Mosaic extensions | | `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) | | `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch | | `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch | diff --git a/packages/mosaic/framework/runtime/pi/RUNTIME.md b/packages/mosaic/framework/runtime/pi/RUNTIME.md index 6a9f2820..93eeba12 100644 --- a/packages/mosaic/framework/runtime/pi/RUNTIME.md +++ b/packages/mosaic/framework/runtime/pi/RUNTIME.md @@ -51,12 +51,26 @@ Skills are discovered from: ### Extensions -The Mosaic Pi extension (`~/.config/mosaic/runtime/pi/mosaic-extension.ts`) handles: +`mosaic pi` loads framework-owned extensions directly from `~/.config/mosaic/runtime/pi/` in this +order: -- Session start/end lifecycle hooks -- Active mission detection and context injection -- Memory routing to `~/.config/mosaic/memory/` -- MACP queue status reporting +1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates, + and fleet heartbeat reporting. +2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction + checks. + +The goal extension is deployed by Mosaic and MUST NOT be copied into `~/.pi/agent/extensions/`. +Use `/goal set ` (or `/goal `) to start, then `/goal status`, `/goal pause`, +`/goal resume`, or `/goal cancel` to control it. An active goal is injected before every model +request, restored from branch-specific session entries, and considered achieved only after two +consecutive evidence-bearing reports. Common credential shapes are redacted before controller-owned +goal-state entries are persisted or +displayed; Pi's own model/tool-call history is separate. Goals and reports must contain references +and pass/fail summaries rather than secrets or raw sensitive output. + +- `MOSAIC_GOAL_MAX_TURNS` — autonomous turn limit, default `40`, accepted range `1..500`. +- `MOSAIC_GOAL_MAX_NO_PROGRESS` — identical no-progress report limit, default `6`, accepted range + `1..100`. ### Sessions diff --git a/packages/mosaic/framework/runtime/pi/goal-extension.ts b/packages/mosaic/framework/runtime/pi/goal-extension.ts new file mode 100644 index 00000000..19d1124e --- /dev/null +++ b/packages/mosaic/framework/runtime/pi/goal-extension.ts @@ -0,0 +1,1088 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; + +const STATE_ENTRY_TYPE = 'mosaic-goal-state'; +const CONTEXT_MESSAGE_TYPE = 'mosaic-goal-context'; +const CONTINUATION_MESSAGE_TYPE = 'mosaic-goal-continuation'; +const GOAL_REPORT_TOOL = 'mosaic_goal_report'; +const STATUS_KEY = 'mosaic-goal'; +const STATE_VERSION = 1 as const; +const MAX_STATEMENT_LENGTH = 8_000; +const MAX_SUMMARY_LENGTH = 2_000; +const MAX_EVIDENCE_ITEMS = 20; +const MAX_EVIDENCE_LENGTH = 1_000; +const MAX_NEXT_STEP_LENGTH = 2_000; +const DEFAULT_MAX_TURNS = 40; +const DEFAULT_MAX_NO_PROGRESS = 6; +const REQUIRED_VERIFICATION_PASSES = 2; +const DEFERRED_CONTINUATION_MS = 10; +const REDACTED_SECRET = '[REDACTED-SECRET]'; +const PRIVATE_KEY_PATTERN = + /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END(?: [A-Z0-9]+)* PRIVATE KEY(?: BLOCK)?-----|$)/g; +const CREDENTIAL_URL_PATTERN = /\b([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^\s/:]+:[^\s/@]+@/g; +const AUTHORIZATION_PATTERN = + /(\b(?:authorization|proxy-authorization)\s*[:=]\s*)(bearer|basic)\s+[^\s,;]+/gi; +const STANDALONE_AUTH_PATTERN = /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{16,}/gi; +const JWT_PATTERN = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g; +const KNOWN_SECRET_PATTERN = + /\b(?:AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{20,}|sk-(?:ant-(?:api\d{2}-)?|proj-)?[A-Za-z0-9_-]{20,}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|hf_[A-Za-z0-9]{20,})\b/g; +const SENSITIVE_ASSIGNMENT_PATTERN = + /((?:["']?(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|auth(?:orization)?[_-]?token|client[_-]?secret|password|passwd|secret(?:[_-]?access[_-]?key)?|private[_-]?key|database[_-]?url|token|cookie|set[_-]?cookie)["']?)\s*)((?:=|:)\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/gi; + +const GOAL_PHASES = [ + 'active', + 'verifying', + 'paused', + 'blocked', + 'achieved', + 'cancelled', + 'exhausted', +] as const; +const REPORT_STATUSES = ['continue', 'achieved', 'blocked'] as const; +const CHECK_SOURCES = ['command', 'turn', 'compact', 'restore', 'report'] as const; + +type GoalPhase = (typeof GOAL_PHASES)[number]; +type GoalReportStatus = (typeof REPORT_STATUSES)[number]; +type GoalCheckSource = (typeof CHECK_SOURCES)[number]; + +interface GoalReport { + status: GoalReportStatus; + summary: string; + evidence: string[]; + nextStep?: string; + fingerprint: string; + reportedAt: string; +} + +interface GoalState { + version: typeof STATE_VERSION; + goalId: string; + statement: string; + phase: GoalPhase; + startedAt: string; + updatedAt: string; + turnCount: number; + reportCount: number; + verificationPasses: number; + requiredVerificationPasses: number; + noProgressReports: number; + maxTurns: number; + maxNoProgressReports: number; + compactionCount: number; + lastCheckSource: GoalCheckSource; + lastCheckAt: string; + lastCheckOutcome: string; + lastProgressFingerprint?: string; + lastReport?: GoalReport; + stopReason?: string; +} + +interface GoalReportInput { + status: GoalReportStatus; + summary: string; + evidence: string[]; + nextStep?: string; +} + +interface GoalLimits { + maxTurns: number; + maxNoProgressReports: number; +} + +interface GoalCommand { + action: 'set' | 'status' | 'pause' | 'resume' | 'cancel' | 'help'; + value: string; +} + +const GoalReportParameters = { + type: 'object', + properties: { + status: { + type: 'string', + enum: REPORT_STATUSES, + description: + 'continue while work remains, achieved only with completion evidence, or blocked', + }, + summary: { + type: 'string', + minLength: 1, + maxLength: MAX_SUMMARY_LENGTH, + description: 'Concise progress or completion assessment', + }, + evidence: { + type: 'array', + items: { type: 'string', minLength: 1, maxLength: MAX_EVIDENCE_LENGTH }, + maxItems: MAX_EVIDENCE_ITEMS, + description: 'Concrete observations, commands, tests, or artifacts supporting the status', + }, + nextStep: { + type: 'string', + minLength: 1, + maxLength: MAX_NEXT_STEP_LENGTH, + description: 'The next concrete action when work remains', + }, + }, + required: ['status', 'summary', 'evidence'], + additionalProperties: false, +} as const; + +function nowIso(): string { + return new Date().toISOString(); +} + +function shouldRedactSensitiveAssignment(separator: string, rawValue: string): boolean { + if (separator.trim() === '=') return true; + const quoted = + (rawValue.startsWith('"') && rawValue.endsWith('"')) || + (rawValue.startsWith("'") && rawValue.endsWith("'")); + if (quoted || rawValue.includes('://')) return true; + if (/^[a-f0-9]{20,}$/i.test(rawValue)) return true; + return ( + rawValue.length >= 20 && + /[A-Za-z]/.test(rawValue) && + /\d/.test(rawValue) && + /[-_./+=]/.test(rawValue) + ); +} + +function redactSensitiveText(value: string): string { + let redacted = value.replace(PRIVATE_KEY_PATTERN, REDACTED_SECRET); + redacted = redacted.replace( + CREDENTIAL_URL_PATTERN, + (_match: string, prefix: string): string => `${prefix}${REDACTED_SECRET}@`, + ); + redacted = redacted.replace( + AUTHORIZATION_PATTERN, + (_match: string, prefix: string, scheme: string): string => + `${prefix}${scheme} ${REDACTED_SECRET}`, + ); + redacted = redacted.replace( + STANDALONE_AUTH_PATTERN, + (_match: string, scheme: string): string => `${scheme} ${REDACTED_SECRET}`, + ); + redacted = redacted.replace(JWT_PATTERN, REDACTED_SECRET); + redacted = redacted.replace(KNOWN_SECRET_PATTERN, REDACTED_SECRET); + return redacted.replace( + SENSITIVE_ASSIGNMENT_PATTERN, + (_match: string, prefix: string, separator: string, rawValue: string): string => + shouldRedactSensitiveAssignment(separator, rawValue) + ? `${prefix}${separator}${REDACTED_SECRET}` + : _match, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isOneOf( + value: unknown, + allowed: readonly TValue[], +): value is TValue { + return ( + typeof value === 'string' && allowed.some((candidate: TValue): boolean => candidate === value) + ); +} + +function parseBoundedInteger( + value: string | undefined, + fallback: number, + minimum: number, + maximum: number, +): number { + if (value === undefined || !/^\d+$/.test(value.trim())) return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return fallback; + return parsed; +} + +function readGoalLimits(env: NodeJS.ProcessEnv): GoalLimits { + return { + maxTurns: parseBoundedInteger(env['MOSAIC_GOAL_MAX_TURNS'], DEFAULT_MAX_TURNS, 1, 500), + maxNoProgressReports: parseBoundedInteger( + env['MOSAIC_GOAL_MAX_NO_PROGRESS'], + DEFAULT_MAX_NO_PROGRESS, + 1, + 100, + ), + }; +} + +function parseStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value) || value.length > MAX_EVIDENCE_ITEMS) return undefined; + const result: string[] = []; + for (const item of value) { + if (typeof item !== 'string') return undefined; + const normalized = item.trim(); + if (normalized.length === 0 || normalized.length > MAX_EVIDENCE_LENGTH) return undefined; + result.push(normalized); + } + return result; +} + +function parseGoalReport(value: unknown): GoalReport | undefined { + if (!isRecord(value)) return undefined; + if (!isOneOf(value['status'], REPORT_STATUSES)) return undefined; + if ( + typeof value['summary'] !== 'string' || + value['summary'].length === 0 || + value['summary'].length > MAX_SUMMARY_LENGTH + ) { + return undefined; + } + const evidence = parseStringArray(value['evidence']); + if (evidence === undefined) return undefined; + if (typeof value['fingerprint'] !== 'string' || !/^[a-f0-9]{64}$/.test(value['fingerprint'])) { + return undefined; + } + if ( + typeof value['reportedAt'] !== 'string' || + value['reportedAt'].length === 0 || + value['reportedAt'].length > 64 + ) { + return undefined; + } + const nextStepValue = value['nextStep']; + let nextStep: string | undefined; + if (nextStepValue !== undefined) { + if ( + typeof nextStepValue !== 'string' || + nextStepValue.length === 0 || + nextStepValue.length > MAX_NEXT_STEP_LENGTH + ) { + return undefined; + } + nextStep = nextStepValue; + } + return { + status: value['status'], + summary: value['summary'], + evidence, + ...(nextStep === undefined ? {} : { nextStep }), + fingerprint: value['fingerprint'], + reportedAt: value['reportedAt'], + }; +} + +function readIntegerField( + value: Record, + field: string, + minimum: number, + maximum: number = Number.MAX_SAFE_INTEGER, +): number | undefined { + const candidate = value[field]; + if ( + !Number.isSafeInteger(candidate) || + typeof candidate !== 'number' || + candidate < minimum || + candidate > maximum + ) { + return undefined; + } + return candidate; +} + +function parseGoalState(value: unknown): GoalState | undefined { + if (!isRecord(value) || value['version'] !== STATE_VERSION) return undefined; + if ( + typeof value['goalId'] !== 'string' || + value['goalId'].length === 0 || + value['goalId'].length > 128 + ) { + return undefined; + } + if ( + typeof value['statement'] !== 'string' || + value['statement'].length === 0 || + value['statement'].length > MAX_STATEMENT_LENGTH + ) { + return undefined; + } + if (!isOneOf(value['phase'], GOAL_PHASES)) return undefined; + if (!isOneOf(value['lastCheckSource'], CHECK_SOURCES)) return undefined; + if ( + typeof value['startedAt'] !== 'string' || + value['startedAt'].length > 64 || + typeof value['updatedAt'] !== 'string' || + value['updatedAt'].length > 64 + ) { + return undefined; + } + if ( + typeof value['lastCheckAt'] !== 'string' || + value['lastCheckAt'].length > 64 || + typeof value['lastCheckOutcome'] !== 'string' || + value['lastCheckOutcome'].length > 256 + ) { + return undefined; + } + + const turnCount = readIntegerField(value, 'turnCount', 0, 500); + const reportCount = readIntegerField(value, 'reportCount', 0, 100_000); + const verificationPasses = readIntegerField( + value, + 'verificationPasses', + 0, + REQUIRED_VERIFICATION_PASSES, + ); + const requiredVerificationPasses = readIntegerField( + value, + 'requiredVerificationPasses', + REQUIRED_VERIFICATION_PASSES, + REQUIRED_VERIFICATION_PASSES, + ); + const noProgressReports = readIntegerField(value, 'noProgressReports', 0, 100); + const maxTurns = readIntegerField(value, 'maxTurns', 1, 500); + const maxNoProgressReports = readIntegerField(value, 'maxNoProgressReports', 1, 100); + const compactionCount = readIntegerField(value, 'compactionCount', 0, 100_000); + if ( + turnCount === undefined || + reportCount === undefined || + verificationPasses === undefined || + requiredVerificationPasses === undefined || + noProgressReports === undefined || + maxTurns === undefined || + maxNoProgressReports === undefined || + compactionCount === undefined + ) { + return undefined; + } + + const lastReportValue = value['lastReport']; + const lastReport = lastReportValue === undefined ? undefined : parseGoalReport(lastReportValue); + if (lastReportValue !== undefined && lastReport === undefined) return undefined; + const lastProgressFingerprintValue = value['lastProgressFingerprint']; + let lastProgressFingerprint: string | undefined; + if (lastProgressFingerprintValue !== undefined) { + if ( + typeof lastProgressFingerprintValue !== 'string' || + !/^[a-f0-9]{64}$/.test(lastProgressFingerprintValue) + ) { + return undefined; + } + lastProgressFingerprint = lastProgressFingerprintValue; + } + const stopReasonValue = value['stopReason']; + let stopReason: string | undefined; + if (stopReasonValue !== undefined) { + if (typeof stopReasonValue !== 'string' || stopReasonValue.length > MAX_SUMMARY_LENGTH) { + return undefined; + } + stopReason = stopReasonValue; + } + + return { + version: STATE_VERSION, + goalId: value['goalId'], + statement: value['statement'], + phase: value['phase'], + startedAt: value['startedAt'], + updatedAt: value['updatedAt'], + turnCount, + reportCount, + verificationPasses, + requiredVerificationPasses, + noProgressReports, + maxTurns, + maxNoProgressReports, + compactionCount, + lastCheckSource: value['lastCheckSource'], + lastCheckAt: value['lastCheckAt'], + lastCheckOutcome: value['lastCheckOutcome'], + ...(lastProgressFingerprint === undefined ? {} : { lastProgressFingerprint }), + ...(lastReport === undefined ? {} : { lastReport }), + ...(stopReason === undefined ? {} : { stopReason }), + }; +} + +function copyReport(report: GoalReport): GoalReport { + return { + ...report, + evidence: [...report.evidence], + }; +} + +function copyState(state: GoalState): GoalState { + return { + ...state, + ...(state.lastReport === undefined ? {} : { lastReport: copyReport(state.lastReport) }), + }; +} + +function parseGoalReportInput(value: unknown): GoalReportInput { + if (!isRecord(value) || !isOneOf(value['status'], REPORT_STATUSES)) { + throw new Error('Goal report status must be continue, achieved, or blocked.'); + } + if (typeof value['summary'] !== 'string') throw new Error('Goal report summary is required.'); + const summary = value['summary'].trim(); + if (summary.length === 0 || summary.length > MAX_SUMMARY_LENGTH) { + throw new Error(`Goal report summary must be 1-${MAX_SUMMARY_LENGTH} characters.`); + } + const evidence = parseStringArray(value['evidence']); + if (evidence === undefined) throw new Error('Goal report evidence is invalid.'); + if (value['status'] === 'achieved' && evidence.length === 0) { + throw new Error('An achieved goal report requires concrete evidence.'); + } + const rawNextStep = value['nextStep']; + if (rawNextStep !== undefined && typeof rawNextStep !== 'string') { + throw new Error('Goal report nextStep must be text.'); + } + const nextStep = typeof rawNextStep === 'string' ? rawNextStep.trim() : undefined; + if (nextStep !== undefined && (nextStep.length === 0 || nextStep.length > MAX_NEXT_STEP_LENGTH)) { + throw new Error(`Goal report nextStep must be 1-${MAX_NEXT_STEP_LENGTH} characters.`); + } + return redactGoalReportInput({ + status: value['status'], + summary, + evidence, + ...(nextStep === undefined ? {} : { nextStep }), + }); +} + +function reportFingerprint(report: GoalReportInput): string { + const normalized = JSON.stringify({ + summary: report.summary.trim().toLowerCase(), + evidence: report.evidence.map((item: string): string => item.trim().toLowerCase()), + nextStep: report.nextStep?.trim().toLowerCase() ?? '', + }); + return createHash('sha256').update(normalized).digest('hex'); +} + +function redactGoalReportInput(report: GoalReportInput): GoalReportInput { + return { + status: report.status, + summary: redactSensitiveText(report.summary), + evidence: report.evidence.map(redactSensitiveText), + ...(report.nextStep === undefined ? {} : { nextStep: redactSensitiveText(report.nextStep) }), + }; +} + +function redactGoalReport(report: GoalReport): GoalReport { + const redactedInput = redactGoalReportInput(report); + return { + ...redactedInput, + fingerprint: reportFingerprint(redactedInput), + reportedAt: report.reportedAt, + }; +} + +function redactGoalState(state: GoalState): GoalState { + const lastReport = + state.lastReport === undefined ? undefined : redactGoalReport(state.lastReport); + const lastProgressFingerprint = + lastReport !== undefined && state.lastProgressFingerprint === state.lastReport?.fingerprint + ? lastReport.fingerprint + : state.lastProgressFingerprint; + return { + ...state, + statement: redactSensitiveText(state.statement), + ...(lastReport === undefined ? {} : { lastReport }), + ...(lastProgressFingerprint === undefined ? {} : { lastProgressFingerprint }), + ...(state.stopReason === undefined + ? {} + : { stopReason: redactSensitiveText(state.stopReason) }), + }; +} + +function goalStateContainsSensitiveText(state: GoalState): boolean { + const textValues = [state.statement]; + if (state.stopReason !== undefined) textValues.push(state.stopReason); + if (state.lastReport !== undefined) { + textValues.push(state.lastReport.summary, ...state.lastReport.evidence); + if (state.lastReport.nextStep !== undefined) textValues.push(state.lastReport.nextStep); + } + return textValues.some((value: string): boolean => redactSensitiveText(value) !== value); +} + +function isContinuingPhase(phase: GoalPhase): boolean { + return phase === 'active' || phase === 'verifying'; +} + +function canReplaceGoal(state: GoalState | undefined): boolean { + return state === undefined || state.phase === 'achieved' || state.phase === 'cancelled'; +} + +function parseGoalCommand(args: string): GoalCommand { + const trimmed = args.trim(); + if (trimmed.length === 0) return { action: 'help', value: '' }; + const separator = trimmed.indexOf(' '); + const first = (separator === -1 ? trimmed : trimmed.slice(0, separator)).toLowerCase(); + const value = separator === -1 ? '' : trimmed.slice(separator + 1).trim(); + if (first === 'set') return { action: 'set', value }; + if (first === 'status') return { action: 'status', value }; + if (first === 'pause') return { action: 'pause', value }; + if (first === 'resume') return { action: 'resume', value }; + if (first === 'cancel' || first === 'clear') return { action: 'cancel', value }; + if (first === 'help') return { action: 'help', value }; + return { action: 'set', value: trimmed }; +} + +function formatStatus(state: GoalState | undefined): string { + if (state === undefined) return 'No Mosaic goal is set. Use /goal set .'; + const lines = [ + `Goal ${state.goalId}`, + `Phase: ${state.phase}`, + `Turns: ${state.turnCount}/${state.maxTurns}`, + `Verification: ${state.verificationPasses}/${state.requiredVerificationPasses}`, + `No-progress reports: ${state.noProgressReports}/${state.maxNoProgressReports}`, + `Compactions checked: ${state.compactionCount}`, + `Goal: ${state.statement}`, + ]; + if (state.lastReport !== undefined) { + lines.push(`Latest report: ${state.lastReport.status} — ${state.lastReport.summary}`); + if (state.lastReport.evidence.length > 0) { + lines.push( + 'Evidence:', + ...state.lastReport.evidence.map((item: string): string => `- ${item}`), + ); + } + if (state.lastReport.nextStep !== undefined) { + lines.push(`Next step: ${state.lastReport.nextStep}`); + } + } + if (state.stopReason !== undefined) lines.push(`Stopped: ${state.stopReason}`); + return lines.join('\n'); +} + +function buildGoalContract(state: GoalState): string { + const latest = state.lastReport; + const verificationInstruction = + state.phase === 'verifying' + ? 'This is a verification pass. Re-inspect the actual result and rerun relevant checks; do not rely only on the prior claim.' + : 'Continue making concrete progress toward the goal.'; + const lines = [ + '[MOSAIC GOAL LOOP v1]', + `Goal ID: ${state.goalId}`, + `Goal: ${state.statement}`, + `Phase: ${state.phase}`, + `Budget: turn ${state.turnCount}/${state.maxTurns}; repeated no-progress reports ${state.noProgressReports}/${state.maxNoProgressReports}.`, + verificationInstruction, + '', + 'Completion protocol:', + `- Before ending the work cycle, call ${GOAL_REPORT_TOOL} as the only tool call in the final assistant response.`, + '- Use status=continue whenever any requirement remains and provide the next concrete step.', + '- Use status=achieved only when concrete evidence covers the entire stated goal.', + '- Use status=blocked only for a genuine blocker that prevents meaningful progress.', + `- Achievement requires ${state.requiredVerificationPasses} consecutive evidence-bearing reports; the first claim starts a separate verification pass.`, + '- Never include secrets, tokens, credentials, private keys, or raw sensitive output in a report.', + '- Do not ask routine permission to continue. The operator can pause or cancel with /goal.', + ]; + if (latest !== undefined) { + lines.push('', `Previous report: ${latest.status} — ${latest.summary}`); + if (latest.nextStep !== undefined) lines.push(`Previous next step: ${latest.nextStep}`); + } + return lines.join('\n'); +} + +function continuationText(state: GoalState, reason: string): string { + if (state.phase === 'verifying') { + return `Goal ${state.goalId} requires a verification pass after ${reason}. Recheck the complete goal and report fresh evidence with ${GOAL_REPORT_TOOL}.`; + } + return `Goal remains active after ${reason}. Continue from the latest evidence and finish by calling ${GOAL_REPORT_TOOL}.`; +} + +function toolResultIncludesGoalReport(toolResults: unknown): boolean { + if (!Array.isArray(toolResults)) return false; + return toolResults.some( + (result: unknown): boolean => isRecord(result) && result['toolName'] === GOAL_REPORT_TOOL, + ); +} + +function toolResultCount(toolResults: unknown): number { + return Array.isArray(toolResults) ? toolResults.length : 0; +} + +export default function registerGoalExtension(pi: ExtensionAPI): void { + const limits = readGoalLimits(process.env); + let state: GoalState | undefined; + let continuationQueued = false; + let deferredTimer: ReturnType | undefined; + let lifecycleGeneration = 0; + let reportRollbackState: GoalState | undefined; + + function updateStatus(ctx: ExtensionContext): void { + if (state === undefined || state.phase === 'cancelled') { + ctx.ui.setStatus(STATUS_KEY, undefined); + return; + } + ctx.ui.setStatus(STATUS_KEY, `🎯 ${state.phase} ${state.turnCount}/${state.maxTurns}`); + } + + function persist(nextState: GoalState, ctx: ExtensionContext): void { + const redactedState = redactGoalState(nextState); + state = copyState(redactedState); + pi.appendEntry(STATE_ENTRY_TYPE, copyState(redactedState)); + updateStatus(ctx); + } + + function clearDeferredTimer(): void { + if (deferredTimer !== undefined) clearTimeout(deferredTimer); + deferredTimer = undefined; + } + + function queueContinuation( + ctx: ExtensionContext, + reason: string, + trackDuplicate: boolean = true, + ): void { + if (state === undefined || !isContinuingPhase(state.phase)) return; + if (ctx.hasPendingMessages()) return; + if (trackDuplicate && continuationQueued) return; + if (trackDuplicate) continuationQueued = true; + const options = ctx.isIdle() + ? { triggerTurn: true as const } + : { triggerTurn: true as const, deliverAs: 'followUp' as const }; + pi.sendMessage( + { + customType: CONTINUATION_MESSAGE_TYPE, + content: continuationText(state, reason), + display: true, + }, + options, + ); + } + + function scheduleIdleContinuation(ctx: ExtensionContext, reason: string): void { + clearDeferredTimer(); + const scheduledGeneration = lifecycleGeneration; + deferredTimer = setTimeout((): void => { + deferredTimer = undefined; + if (scheduledGeneration !== lifecycleGeneration || !ctx.isIdle()) return; + queueContinuation(ctx, reason); + }, DEFERRED_CONTINUATION_MS); + } + + function restoreState(ctx: ExtensionContext): void { + state = undefined; + let rejectedSensitiveState = false; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type !== 'custom' || entry.customType !== STATE_ENTRY_TYPE) continue; + const restoredState = parseGoalState(entry.data); + if (restoredState === undefined) { + state = undefined; + } else if (goalStateContainsSensitiveText(restoredState)) { + state = undefined; + rejectedSensitiveState = true; + } else if (rejectedSensitiveState) { + state = undefined; + } else { + state = redactGoalState(restoredState); + } + } + continuationQueued = false; + updateStatus(ctx); + if (rejectedSensitiveState) { + ctx.ui.notify( + 'Mosaic goal state was not restored because persisted text matched a credential pattern. Remove the affected Pi session if it may contain a real secret, then set a new goal.', + 'warning', + ); + } + } + + function createGoal(statement: string, ctx: ExtensionContext): void { + if (!canReplaceGoal(state)) { + ctx.ui.notify( + 'A Mosaic goal already exists. Use /goal cancel before replacing it.', + 'warning', + ); + return; + } + if (statement.length === 0 || statement.length > MAX_STATEMENT_LENGTH) { + ctx.ui.notify( + `Usage: /goal set (${MAX_STATEMENT_LENGTH.toLocaleString()} characters maximum).`, + 'warning', + ); + return; + } + const timestamp = nowIso(); + const redactedStatement = redactSensitiveText(statement); + persist( + { + version: STATE_VERSION, + goalId: randomUUID(), + statement: redactedStatement, + phase: 'active', + startedAt: timestamp, + updatedAt: timestamp, + turnCount: 0, + reportCount: 0, + verificationPasses: 0, + requiredVerificationPasses: REQUIRED_VERIFICATION_PASSES, + noProgressReports: 0, + maxTurns: limits.maxTurns, + maxNoProgressReports: limits.maxNoProgressReports, + compactionCount: 0, + lastCheckSource: 'command', + lastCheckAt: timestamp, + lastCheckOutcome: 'set', + }, + ctx, + ); + ctx.ui.notify(`Mosaic goal started: ${redactedStatement}`, 'info'); + if (ctx.isIdle()) queueContinuation(ctx, 'goal start', false); + } + + pi.registerCommand('goal', { + description: 'Set or control a persistent Mosaic goal loop', + handler: async (args, ctx): Promise => { + const command = parseGoalCommand(args); + if (command.action === 'help') { + ctx.ui.notify( + [ + 'Mosaic goal commands:', + '/goal set (or /goal )', + '/goal status', + '/goal pause [reason]', + '/goal resume', + '/goal cancel', + ].join('\n'), + 'info', + ); + return; + } + if (command.action === 'status') { + ctx.ui.notify(formatStatus(state), 'info'); + return; + } + if (command.action === 'set') { + createGoal(command.value, ctx); + return; + } + if (state === undefined) { + ctx.ui.notify('No Mosaic goal is set.', 'warning'); + return; + } + const timestamp = nowIso(); + if (command.action === 'pause') { + if (!isContinuingPhase(state.phase)) { + ctx.ui.notify(`Goal cannot be paused from phase ${state.phase}.`, 'warning'); + return; + } + persist( + { + ...state, + phase: 'paused', + updatedAt: timestamp, + lastCheckSource: 'command', + lastCheckAt: timestamp, + lastCheckOutcome: 'paused', + stopReason: command.value || 'Paused by operator.', + }, + ctx, + ); + clearDeferredTimer(); + continuationQueued = false; + if (!ctx.isIdle()) ctx.abort(); + ctx.ui.notify('Mosaic goal paused.', 'info'); + return; + } + if (command.action === 'cancel') { + persist( + { + ...state, + phase: 'cancelled', + updatedAt: timestamp, + lastCheckSource: 'command', + lastCheckAt: timestamp, + lastCheckOutcome: 'cancelled', + stopReason: 'Cancelled by operator.', + }, + ctx, + ); + clearDeferredTimer(); + continuationQueued = false; + if (!ctx.isIdle()) ctx.abort(); + ctx.ui.notify('Mosaic goal cancelled.', 'info'); + return; + } + if (command.action === 'resume') { + if (state.phase !== 'paused' && state.phase !== 'blocked' && state.phase !== 'exhausted') { + ctx.ui.notify(`Goal cannot be resumed from phase ${state.phase}.`, 'warning'); + return; + } + persist( + { + ...state, + phase: 'active', + updatedAt: timestamp, + turnCount: 0, + verificationPasses: 0, + noProgressReports: 0, + lastProgressFingerprint: undefined, + lastCheckSource: 'command', + lastCheckAt: timestamp, + lastCheckOutcome: 'resumed', + stopReason: undefined, + }, + ctx, + ); + continuationQueued = false; + ctx.ui.notify('Mosaic goal resumed with fresh bounded counters.', 'info'); + if (ctx.isIdle()) queueContinuation(ctx, 'operator resume', false); + } + }, + }); + + pi.registerTool({ + name: GOAL_REPORT_TOOL, + label: 'Mosaic Goal Report', + description: + 'Report structured progress for the active Mosaic /goal loop. Call it as the sole final tool when a work cycle is ready to stop, continue, verify, or block.', + promptSnippet: 'Report evidence-backed status for the active Mosaic goal loop', + promptGuidelines: [ + 'When a Mosaic goal is active, call mosaic_goal_report as the sole tool in the final assistant response for each work cycle.', + 'Use mosaic_goal_report status=achieved only when concrete evidence covers the entire active goal.', + ], + parameters: GoalReportParameters, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (state === undefined || !isContinuingPhase(state.phase)) { + throw new Error( + 'No active Mosaic goal can accept a report. Use /goal set or /goal resume.', + ); + } + const input = parseGoalReportInput(params); + const fingerprint = reportFingerprint(input); + const timestamp = nowIso(); + const report: GoalReport = { + ...input, + fingerprint, + reportedAt: timestamp, + }; + reportRollbackState = copyState(state); + + let nextState: GoalState; + if (input.status === 'continue') { + const noProgressReports = + fingerprint === state.lastProgressFingerprint ? state.noProgressReports + 1 : 1; + const exhausted = noProgressReports >= state.maxNoProgressReports; + nextState = { + ...state, + phase: exhausted ? 'exhausted' : 'active', + updatedAt: timestamp, + reportCount: state.reportCount + 1, + verificationPasses: 0, + noProgressReports, + lastProgressFingerprint: fingerprint, + lastReport: report, + lastCheckSource: 'report', + lastCheckAt: timestamp, + lastCheckOutcome: exhausted ? 'no-progress-limit' : 'continue', + ...(exhausted + ? { + stopReason: `Repeated no-progress report limit reached (${state.maxNoProgressReports}).`, + } + : { stopReason: undefined }), + }; + } else if (input.status === 'achieved') { + const verificationPasses = state.phase === 'verifying' ? state.verificationPasses + 1 : 1; + const achieved = verificationPasses >= state.requiredVerificationPasses; + nextState = { + ...state, + phase: achieved ? 'achieved' : 'verifying', + updatedAt: timestamp, + reportCount: state.reportCount + 1, + verificationPasses, + noProgressReports: 0, + lastProgressFingerprint: fingerprint, + lastReport: report, + lastCheckSource: 'report', + lastCheckAt: timestamp, + lastCheckOutcome: achieved ? 'verified-achieved' : 'provisional-achieved', + stopReason: undefined, + }; + } else { + nextState = { + ...state, + phase: 'blocked', + updatedAt: timestamp, + reportCount: state.reportCount + 1, + verificationPasses: 0, + noProgressReports: 0, + lastProgressFingerprint: fingerprint, + lastReport: report, + lastCheckSource: 'report', + lastCheckAt: timestamp, + lastCheckOutcome: 'blocked', + stopReason: input.summary, + }; + } + + persist(nextState, ctx); + if (nextState.phase === 'achieved') { + ctx.ui.notify(`Mosaic goal verified.\n${formatStatus(nextState)}`, 'info'); + } else if (nextState.phase === 'verifying') { + ctx.ui.notify( + 'Goal achievement is provisional; one verification pass is required.', + 'info', + ); + } else if (nextState.phase === 'blocked' || nextState.phase === 'exhausted') { + ctx.ui.notify(`Mosaic goal stopped in phase ${nextState.phase}.`, 'warning'); + } + + return { + content: [ + { + type: 'text', + text: + nextState.phase === 'achieved' + ? 'Goal verification complete.' + : `Goal report recorded; phase is ${nextState.phase}.`, + }, + ], + details: { state: copyState(nextState), report: copyReport(report) }, + terminate: true, + }; + }, + }); + + pi.on('context', async (event) => { + const messages = event.messages.filter( + (message) => + message.role !== 'custom' || + (message.customType !== CONTEXT_MESSAGE_TYPE && + message.customType !== CONTINUATION_MESSAGE_TYPE), + ); + if (state === undefined || !isContinuingPhase(state.phase)) { + return messages.length === event.messages.length ? undefined : { messages }; + } + messages.push({ + role: 'custom', + customType: CONTEXT_MESSAGE_TYPE, + content: buildGoalContract(state), + display: false, + timestamp: Date.now(), + }); + return { messages }; + }); + + pi.on('turn_end', async (event, ctx) => { + if (state === undefined || state.phase === 'paused' || state.phase === 'cancelled') return; + const hasGoalReport = toolResultIncludesGoalReport(event.toolResults); + if (!isContinuingPhase(state.phase) && !hasGoalReport) return; + + const timestamp = nowIso(); + let nextState = { + ...state, + updatedAt: timestamp, + turnCount: state.turnCount + 1, + lastCheckSource: 'turn' as const, + lastCheckAt: timestamp, + lastCheckOutcome: hasGoalReport ? 'reported' : 'checked-unreported', + }; + + if ( + hasGoalReport && + toolResultCount(event.toolResults) !== 1 && + reportRollbackState !== undefined + ) { + nextState = { + ...reportRollbackState, + phase: 'active', + updatedAt: timestamp, + turnCount: reportRollbackState.turnCount + 1, + verificationPasses: 0, + lastCheckSource: 'turn', + lastCheckAt: timestamp, + lastCheckOutcome: 'mixed-goal-report-rejected', + stopReason: undefined, + }; + ctx.ui.notify( + `${GOAL_REPORT_TOOL} must be the only tool call in its final response; the mixed report was ignored.`, + 'warning', + ); + } + reportRollbackState = undefined; + + if (isContinuingPhase(nextState.phase) && nextState.turnCount >= nextState.maxTurns) { + nextState = { + ...nextState, + phase: 'exhausted', + lastCheckOutcome: 'max-turn-limit', + stopReason: `Maximum autonomous turn limit reached (${nextState.maxTurns}).`, + }; + persist(nextState, ctx); + ctx.ui.notify('Mosaic goal exhausted its autonomous turn limit.', 'warning'); + ctx.abort(); + return; + } + persist(nextState, ctx); + }); + + pi.on('agent_start', async () => { + continuationQueued = false; + clearDeferredTimer(); + }); + + pi.on('agent_settled', async (_event, ctx) => { + if (state === undefined || !isContinuingPhase(state.phase)) return; + queueContinuation(ctx, state.phase === 'verifying' ? 'the provisional claim' : 'agent settle'); + }); + + pi.on('session_compact', async (event, ctx) => { + if (state === undefined) return; + const timestamp = nowIso(); + const wasContinuing = isContinuingPhase(state.phase); + persist( + { + ...state, + phase: wasContinuing ? 'active' : state.phase, + updatedAt: timestamp, + verificationPasses: wasContinuing ? 0 : state.verificationPasses, + compactionCount: state.compactionCount + 1, + lastCheckSource: 'compact', + lastCheckAt: timestamp, + lastCheckOutcome: `checked-${event.reason}`, + ...(wasContinuing ? { stopReason: undefined } : {}), + }, + ctx, + ); + if (wasContinuing) scheduleIdleContinuation(ctx, `${event.reason} compaction`); + }); + + pi.on('session_start', async (_event, ctx) => { + lifecycleGeneration += 1; + clearDeferredTimer(); + restoreState(ctx); + if (state !== undefined && isContinuingPhase(state.phase)) { + const timestamp = nowIso(); + persist( + { + ...state, + updatedAt: timestamp, + lastCheckSource: 'restore', + lastCheckAt: timestamp, + lastCheckOutcome: 'session-start', + }, + ctx, + ); + scheduleIdleContinuation(ctx, 'session restore'); + } + }); + + pi.on('session_tree', async (_event, ctx) => { + lifecycleGeneration += 1; + clearDeferredTimer(); + restoreState(ctx); + if (state !== undefined && isContinuingPhase(state.phase)) { + const timestamp = nowIso(); + persist( + { + ...state, + updatedAt: timestamp, + lastCheckSource: 'restore', + lastCheckAt: timestamp, + lastCheckOutcome: 'tree-navigation', + }, + ctx, + ); + scheduleIdleContinuation(ctx, 'tree navigation'); + } + }); + + pi.on('session_shutdown', async (_event, ctx) => { + lifecycleGeneration += 1; + clearDeferredTimer(); + continuationQueued = false; + ctx.ui.setStatus(STATUS_KEY, undefined); + }); +} diff --git a/packages/mosaic/framework/systemd/user/test-fleet-units.sh b/packages/mosaic/framework/systemd/user/test-fleet-units.sh index 81d958d6..c93d8c35 100755 --- a/packages/mosaic/framework/systemd/user/test-fleet-units.sh +++ b/packages/mosaic/framework/systemd/user/test-fleet-units.sh @@ -66,7 +66,10 @@ if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT MARKER="$TEST_ROOT/loader-marker" LIBRARY="$TEST_ROOT/marker.so" + FIXTURE_READY="$TEST_ROOT/loader-ready" + FIXTURE_FIFO="$TEST_ROOT/loader-block" HOLDER_HOME="$TEST_ROOT/holder-home" + mkfifo "$FIXTURE_FIFO" mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run" chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \ "$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run" @@ -87,7 +90,17 @@ __attribute__((constructor)) static void mark_loader(void) { EOF cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c" MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \ - tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60' + tmux -L "$TEST_SOCKET" new-session -d -s _holder \ + "touch '$FIXTURE_READY'; read _ < '$FIXTURE_FIFO'" + # tmux starts the pane asynchronously. Wait until its contaminated shell has + # loaded the constructor and reached a builtin-only FIFO barrier before + # clearing the marker; otherwise that expected constructor can race with the + # clean holder assertion below and create a false failure. + for _attempt in {1..100}; do + [ -e "$FIXTURE_READY" ] && break + sleep 0.01 + done + [ -e "$FIXTURE_READY" ] || fail "contaminated fixture pane did not become ready" [ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor" server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}') : > "$MARKER" diff --git a/packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh b/packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh index c925f4df..885153da 100755 --- a/packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh +++ b/packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh @@ -26,6 +26,9 @@ chk "F1 fresh: CONSTITUTION/AGENTS/STANDARDS/TOOLS seeded" \ "[ -f '$T1/CONSTITUTION.md' ] && [ -f '$T1/AGENTS.md' ] && [ -f '$T1/STANDARDS.md' ] && [ -f '$T1/TOOLS.md' ]" chk "F1 fresh: AGENTS == shipped default" "cmp -s '$T1/AGENTS.md' '$DEFA/AGENTS.md'" chk "F1 fresh: framework-version stamped 3" "[ \"\$(cat '$T1/.framework-version' 2>/dev/null)\" = 3 ]" +chk "F1 fresh: Pi goal extension deploys under Mosaic runtime" \ + "cmp -s '$T1/runtime/pi/goal-extension.ts' '$FW/runtime/pi/goal-extension.ts'" +chk "F1 fresh: installer creates no nested main Pi config" "[ ! -e '$T1/.pi' ]" # F2 — legacy install with a user-edited AGENTS.md (the sanctioned pre-constitution customization) T2=$(mktemp -d); mkdir -p "$T2/credentials" @@ -89,6 +92,8 @@ chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.en chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'" chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'" chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'" +chk "F6 reseed: Pi goal extension is refreshed from framework source" \ + "cmp -s '$T6/runtime/pi/goal-extension.ts' '$FW/runtime/pi/goal-extension.ts'" rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6" echo diff --git a/packages/mosaic/src/commands/launch.spec.ts b/packages/mosaic/src/commands/launch.spec.ts index d468c9bc..619fc627 100644 --- a/packages/mosaic/src/commands/launch.spec.ts +++ b/packages/mosaic/src/commands/launch.spec.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { buildPiSkillArgs, + discoverPiExtensionArgs, enumerateSkillDirs, piForceSkillNames, registerRuntimeLaunchers, @@ -178,6 +179,52 @@ describe('buildPiSkillArgs', () => { }); }); +describe('discoverPiExtensionArgs', () => { + it('loads the core and goal extensions in deterministic order from Mosaic home', () => { + const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-')); + const runtimeDir = join(root, 'runtime', 'pi'); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync(join(runtimeDir, 'goal-extension.ts'), '// goal\n'); + writeFileSync(join(runtimeDir, 'mosaic-extension.ts'), '// core\n'); + + try { + expect(discoverPiExtensionArgs(root)).toEqual([ + '--extension', + join(runtimeDir, 'mosaic-extension.ts'), + '--extension', + join(runtimeDir, 'goal-extension.ts'), + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('remains backward-compatible when the optional goal extension is absent', () => { + const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-')); + const runtimeDir = join(root, 'runtime', 'pi'); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync(join(runtimeDir, 'mosaic-extension.ts'), '// core\n'); + + try { + expect(discoverPiExtensionArgs(root)).toEqual([ + '--extension', + join(runtimeDir, 'mosaic-extension.ts'), + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('emits no extension arguments when Mosaic runtime assets are absent', () => { + const root = mkdtempSync(join(tmpdir(), 'mosaic-pi-extensions-')); + try { + expect(discoverPiExtensionArgs(root)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('enumerateSkillDirs (real FS)', () => { let root: string; diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 3a0dd998..f1e34dcd 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -715,9 +715,15 @@ export function buildPiSkillArgs( return ['--no-skills', ...forcedSkillArgs]; } -function discoverPiExtension(): string[] { - const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts'); - return existsSync(ext) ? ['--extension', ext] : []; +const PI_EXTENSION_FILES = ['mosaic-extension.ts', 'goal-extension.ts'] as const; + +export function discoverPiExtensionArgs(mosaicHome: string = MOSAIC_HOME): string[] { + const args: string[] = []; + for (const fileName of PI_EXTENSION_FILES) { + const extensionPath = join(mosaicHome, 'runtime', 'pi', fileName); + if (existsSync(extensionPath)) args.push('--extension', extensionPath); + } + return args; } // ─── Launch functions ──────────────────────────────────────────────────────── @@ -792,7 +798,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev const prompt = buildRuntimePrompt('pi'); const cliArgs = ['--append-system-prompt', prompt]; cliArgs.push(...buildPiSkillArgs(args)); - cliArgs.push(...discoverPiExtension()); + cliArgs.push(...discoverPiExtensionArgs()); if (hasMissionNoArgs) { cliArgs.push(missionPrompt); } else { diff --git a/packages/mosaic/src/config/file-adapter.test.ts b/packages/mosaic/src/config/file-adapter.test.ts index ae56ff8b..bcd94fe7 100644 --- a/packages/mosaic/src/config/file-adapter.test.ts +++ b/packages/mosaic/src/config/file-adapter.test.ts @@ -209,6 +209,20 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => { ); }); + it('deploys the Mosaic-owned Pi goal extension only inside the Mosaic runtime tree', async () => { + const sourceRuntime = join(fixture.sourceDir, 'runtime', 'pi'); + mkdirSync(sourceRuntime, { recursive: true }); + writeFileSync(join(sourceRuntime, 'goal-extension.ts'), '// persistent goal extension\n'); + + const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir); + await adapter.syncFramework('fresh'); + + expect( + readFileSync(join(fixture.mosaicHome, 'runtime', 'pi', 'goal-extension.ts'), 'utf-8'), + ).toBe('// persistent goal extension\n'); + expect(existsSync(join(fixture.mosaicHome, '.pi'))).toBe(false); + }); + it('is a no-op for seeding when defaults/ dir does not exist', async () => { rmSync(fixture.defaultsDir, { recursive: true }); diff --git a/packages/mosaic/src/runtime/pi-goal-extension.spec.ts b/packages/mosaic/src/runtime/pi-goal-extension.spec.ts new file mode 100644 index 00000000..8de099bb --- /dev/null +++ b/packages/mosaic/src/runtime/pi-goal-extension.spec.ts @@ -0,0 +1,796 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +interface FakeEntry { + type: string; + customType?: string; + data?: unknown; +} + +interface SentMessage { + message: { + customType: string; + content: string; + display: boolean; + }; + options?: { + triggerTurn?: boolean; + deliverAs?: 'steer' | 'followUp' | 'nextTurn'; + }; +} + +interface FakeContext { + cwd: string; + ui: { + notifications: Array<{ message: string; level?: string }>; + statuses: Map; + notify(message: string, level?: string): void; + setStatus(key: string, value: string | undefined): void; + }; + sessionManager: { + getBranch(): FakeEntry[]; + }; + isIdle(): boolean; + hasPendingMessages(): boolean; + abort(): void; +} + +type EventHandler = ( + event: Record, + context: FakeContext, +) => unknown | Promise; + +type CommandHandler = (args: string, context: FakeContext) => unknown | Promise; + +interface FakeToolResult { + content: Array<{ type: string; text: string }>; + details?: unknown; + terminate?: boolean; +} + +interface FakeTool { + name: string; + execute( + toolCallId: string, + params: Record, + signal: AbortSignal | undefined, + onUpdate: undefined, + context: FakeContext, + ): Promise; +} + +interface GoalExtensionFactory { + (api: FakePiApi): void; +} + +interface GoalExtensionModule { + default: GoalExtensionFactory; +} + +interface FakePiApi { + on(event: string, handler: EventHandler): void; + registerCommand(name: string, options: { description: string; handler: CommandHandler }): void; + registerTool(tool: FakeTool): void; + appendEntry(customType: string, data?: unknown): void; + sendMessage(message: SentMessage['message'], options?: SentMessage['options']): void; +} + +function isGoalExtensionModule(value: unknown): value is GoalExtensionModule { + if (typeof value !== 'object' || value === null) return false; + return typeof Reflect.get(value, 'default') === 'function'; +} + +const goalExtensionUrl = new URL('../../framework/runtime/pi/goal-extension.ts', import.meta.url) + .href; +const importedGoalExtension: unknown = await import(goalExtensionUrl); +if (!isGoalExtensionModule(importedGoalExtension)) { + throw new Error('Pi goal extension must export a default registration function'); +} +const registerGoalExtension = importedGoalExtension.default; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +class FakePi { + readonly handlers = new Map(); + readonly commands = new Map(); + readonly tools = new Map(); + readonly entries: FakeEntry[] = []; + readonly sentMessages: SentMessage[] = []; + readonly notifications: Array<{ message: string; level?: string }> = []; + readonly statuses = new Map(); + branch: FakeEntry[] = []; + idle = true; + pending = false; + abortCount = 0; + + readonly context: FakeContext = { + cwd: '/tmp/project', + ui: { + notifications: this.notifications, + statuses: this.statuses, + notify: (message: string, level?: string): void => { + this.notifications.push({ message, level }); + }, + setStatus: (key: string, value: string | undefined): void => { + this.statuses.set(key, value); + }, + }, + sessionManager: { + getBranch: (): FakeEntry[] => [...this.branch], + }, + isIdle: (): boolean => this.idle, + hasPendingMessages: (): boolean => this.pending, + abort: (): void => { + this.abortCount += 1; + }, + }; + + readonly api: FakePiApi = { + on: (event: string, handler: EventHandler): void => { + this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]); + }, + registerCommand: ( + name: string, + options: { description: string; handler: CommandHandler }, + ): void => { + this.commands.set(name, options.handler); + }, + registerTool: (tool: FakeTool): void => { + this.tools.set(tool.name, tool); + }, + appendEntry: (customType: string, data?: unknown): void => { + const entry: FakeEntry = { type: 'custom', customType, data }; + this.entries.push(entry); + this.branch.push(entry); + }, + sendMessage: (message: SentMessage['message'], options?: SentMessage['options']): void => { + this.sentMessages.push({ message, options }); + }, + }; + + constructor(initialBranch: FakeEntry[] = []) { + this.branch = [...initialBranch]; + registerGoalExtension(this.api); + } + + async emit(event: string, value: Record = {}): Promise { + const results: unknown[] = []; + for (const handler of this.handlers.get(event) ?? []) { + results.push(await handler(value, this.context)); + } + return results; + } + + async goal(args: string): Promise { + const handler = this.commands.get('goal'); + if (handler === undefined) throw new Error('/goal was not registered'); + await handler(args, this.context); + } + + async report(params: Record): Promise { + const tool = this.tools.get('mosaic_goal_report'); + if (tool === undefined) throw new Error('mosaic_goal_report was not registered'); + return await tool.execute('goal-report-1', params, undefined, undefined, this.context); + } +} + +function latestGoalStateData(pi: FakePi): Record { + for (let index = pi.entries.length - 1; index >= 0; index -= 1) { + const entry = pi.entries[index]; + if (entry?.customType === 'mosaic-goal-state' && isRecord(entry.data)) return entry.data; + } + throw new Error('No persisted Mosaic goal state found'); +} + +function stateField(pi: FakePi, field: string): unknown { + return latestGoalStateData(pi)[field]; +} + +function activeGoalStatementFromContext(result: unknown): string { + if (!isRecord(result)) throw new Error('Context handler did not return an object'); + const messages = result['messages']; + if (!Array.isArray(messages)) throw new Error('Context result did not include messages'); + const goalMessage = messages.find( + (message: unknown): boolean => + isRecord(message) && message['customType'] === 'mosaic-goal-context', + ); + if (!isRecord(goalMessage) || typeof goalMessage['content'] !== 'string') { + throw new Error('Goal context message was not injected'); + } + return goalMessage['content']; +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +describe('Mosaic Pi goal extension commands', () => { + it('shows help and handles controls safely when no goal exists', async () => { + const pi = new FakePi(); + + await pi.goal(''); + expect(pi.notifications.at(-1)?.message).toContain('/goal set'); + await pi.goal('status'); + expect(pi.notifications.at(-1)?.message).toContain('No Mosaic goal is set'); + + for (const command of ['pause', 'resume', 'cancel']) { + await pi.goal(command); + expect(pi.notifications.at(-1)?.level).toBe('warning'); + } + expect(pi.entries).toHaveLength(0); + expect(pi.sentMessages).toHaveLength(0); + }); + + it('sets, reports, pauses, resumes, and cancels a bounded goal', async () => { + const pi = new FakePi(); + + await pi.goal('set Deliver the local goal extension with tests'); + expect(stateField(pi, 'phase')).toBe('active'); + expect(stateField(pi, 'statement')).toBe('Deliver the local goal extension with tests'); + expect(pi.sentMessages).toHaveLength(1); + expect(pi.sentMessages[0]?.options?.triggerTurn).toBe(true); + + await pi.goal('status'); + expect(pi.notifications.at(-1)?.message).toContain('Deliver the local goal extension'); + expect(pi.notifications.at(-1)?.message).toContain('active'); + + await pi.goal('pause'); + expect(stateField(pi, 'phase')).toBe('paused'); + pi.sentMessages.length = 0; + await pi.emit('agent_settled'); + expect(pi.sentMessages).toHaveLength(0); + + await pi.goal('resume'); + expect(stateField(pi, 'phase')).toBe('active'); + expect(pi.sentMessages).toHaveLength(1); + + await pi.goal('cancel'); + expect(stateField(pi, 'phase')).toBe('cancelled'); + pi.sentMessages.length = 0; + await pi.emit('agent_settled'); + expect(pi.sentMessages).toHaveLength(0); + }); + + it('accepts /goal shorthand but refuses to replace an active goal', async () => { + const pi = new FakePi(); + + await pi.goal('First goal'); + const firstGoalId = stateField(pi, 'goalId'); + await pi.goal('set Second goal'); + + expect(stateField(pi, 'goalId')).toBe(firstGoalId); + expect(stateField(pi, 'statement')).toBe('First goal'); + expect(pi.notifications.at(-1)?.level).toBe('warning'); + expect(pi.notifications.at(-1)?.message).toContain('/goal cancel'); + }); + + it('rejects invalid phase transitions, aborts busy work, and supports clear as cancel', async () => { + const pi = new FakePi(); + await pi.goal('set Preserve transition safety'); + + await pi.goal('resume'); + expect(pi.notifications.at(-1)?.message).toContain('cannot be resumed'); + pi.idle = false; + await pi.goal('pause maintenance window'); + expect(stateField(pi, 'stopReason')).toBe('maintenance window'); + expect(pi.abortCount).toBe(1); + await pi.goal('pause'); + expect(pi.notifications.at(-1)?.message).toContain('cannot be paused'); + await pi.goal('clear'); + expect(stateField(pi, 'phase')).toBe('cancelled'); + expect(pi.abortCount).toBe(2); + }); + + it('rejects empty and oversized goal statements without starting a run', async () => { + const pi = new FakePi(); + + await pi.goal('set'); + await pi.goal(`set ${'x'.repeat(8_001)}`); + + expect(pi.entries).toHaveLength(0); + expect(pi.sentMessages).toHaveLength(0); + expect(pi.notifications.at(-1)?.level).toBe('warning'); + }); +}); + +describe('Mosaic Pi goal lifecycle', () => { + it('injects one fresh active contract before every model context', async () => { + const pi = new FakePi(); + await pi.goal('set Keep the agent oriented'); + + const existingGoalContext = { + role: 'custom', + customType: 'mosaic-goal-context', + content: 'stale', + }; + const existingContinuation = { + role: 'custom', + customType: 'mosaic-goal-continuation', + content: 'stale continuation', + }; + const first = await pi.emit('context', { + messages: [existingGoalContext, existingContinuation], + }); + const second = await pi.emit('context', { messages: [] }); + + expect(activeGoalStatementFromContext(first[0])).toContain('Keep the agent oriented'); + expect(activeGoalStatementFromContext(first[0])).toContain('mosaic_goal_report'); + expect(activeGoalStatementFromContext(first[0])).not.toContain('stale'); + if (!isRecord(first[0]) || !Array.isArray(first[0]['messages'])) { + throw new Error('Expected filtered context messages'); + } + expect(first[0]['messages']).toHaveLength(1); + expect(activeGoalStatementFromContext(second[0])).toContain('Keep the agent oriented'); + + await pi.goal('cancel'); + expect( + await pi.emit('context', { + messages: [existingGoalContext, existingContinuation], + }), + ).toEqual([{ messages: [] }]); + }); + + it('lets an existing busy run adopt the goal and waits behind a pending message', async () => { + const busy = new FakePi(); + busy.idle = false; + await busy.goal('set Join the current run safely'); + expect(busy.sentMessages).toHaveLength(0); + + busy.pending = true; + await busy.emit('agent_settled'); + expect(busy.sentMessages).toHaveLength(0); + busy.pending = false; + busy.idle = true; + await busy.emit('agent_settled'); + expect(busy.sentMessages).toHaveLength(1); + }); + + it('records every turn and continues once when an active run settles', async () => { + const pi = new FakePi(); + await pi.goal('set Finish all acceptance criteria'); + pi.sentMessages.length = 0; + + await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] }); + expect(stateField(pi, 'turnCount')).toBe(1); + expect(stateField(pi, 'lastCheckSource')).toBe('turn'); + + await pi.emit('agent_settled'); + await pi.emit('agent_settled'); + expect(pi.sentMessages).toHaveLength(1); + expect(pi.sentMessages[0]?.message.content).toContain('Goal remains active'); + + await pi.emit('agent_start'); + await pi.emit('agent_settled'); + expect(pi.sentMessages).toHaveLength(2); + }); + + it('requires two consecutive evidence-bearing achievement reports', async () => { + const pi = new FakePi(); + await pi.goal('set Prove the feature works'); + pi.sentMessages.length = 0; + + const first = await pi.report({ + status: 'achieved', + summary: 'Focused tests pass', + evidence: ['pnpm test: 12 passed'], + }); + expect(first.terminate).toBe(true); + expect(stateField(pi, 'phase')).toBe('verifying'); + expect(stateField(pi, 'verificationPasses')).toBe(1); + + await pi.emit('agent_settled'); + expect(pi.sentMessages).toHaveLength(1); + expect(pi.sentMessages[0]?.message.content).toContain('verification pass'); + + await pi.emit('agent_start'); + const second = await pi.report({ + status: 'achieved', + summary: 'Independent recheck confirms completion', + evidence: ['rerun: 12 passed', 'framework path verified'], + }); + expect(second.terminate).toBe(true); + expect(stateField(pi, 'phase')).toBe('achieved'); + expect(stateField(pi, 'verificationPasses')).toBe(2); + + pi.sentMessages.length = 0; + await pi.emit('agent_settled'); + expect(pi.sentMessages).toHaveLength(0); + }); + + it('rejects malformed progress reports and reports submitted without an active goal', async () => { + const noGoal = new FakePi(); + await expect( + noGoal.report({ status: 'continue', summary: 'work', evidence: [] }), + ).rejects.toThrow(/No active Mosaic goal/); + + const pi = new FakePi(); + await pi.goal('set Validate report boundaries'); + const invalidReports: Record[] = [ + {}, + { status: 'invalid', summary: 'work', evidence: [] }, + { status: 'continue', evidence: [] }, + { status: 'continue', summary: ' ', evidence: [] }, + { status: 'continue', summary: 'x'.repeat(2_001), evidence: [] }, + { status: 'continue', summary: 'work', evidence: 'not-an-array' }, + { status: 'continue', summary: 'work', evidence: Array.from({ length: 21 }, () => 'x') }, + { status: 'continue', summary: 'work', evidence: [4] }, + { status: 'continue', summary: 'work', evidence: [''] }, + { status: 'continue', summary: 'work', evidence: ['x'.repeat(1_001)] }, + { status: 'continue', summary: 'work', evidence: [], nextStep: 4 }, + { status: 'continue', summary: 'work', evidence: [], nextStep: ' ' }, + { status: 'continue', summary: 'work', evidence: [], nextStep: 'x'.repeat(2_001) }, + ]; + for (const report of invalidReports) { + await expect(pi.report(report)).rejects.toThrow(); + } + expect(stateField(pi, 'phase')).toBe('active'); + }); + + it('rejects an achievement claim without evidence', async () => { + const pi = new FakePi(); + await pi.goal('set Require evidence'); + + await expect( + pi.report({ status: 'achieved', summary: 'Trust me', evidence: [] }), + ).rejects.toThrow(/evidence/i); + expect(stateField(pi, 'phase')).toBe('active'); + }); + + it('redacts credential-shaped goal and report text before persistence or display', async () => { + const githubToken = `ghp_${'a'.repeat(32)}`; + const anthropicKey = `sk-ant-api03-${'b'.repeat(40)}`; + const bearerToken = 'header.payload.signature-canary'; + const databaseUrl = 'postgresql://mosaic:password-canary@db.example.invalid/mosaic'; + const password = 'password-canary'; + const pi = new FakePi(); + + await pi.goal(`set Rotate ${githubToken} without retaining it`); + const context = await pi.emit('context', { messages: [] }); + expect(activeGoalStatementFromContext(context[0])).not.toContain(githubToken); + + const result = await pi.report({ + status: 'achieved', + summary: `Validated ${anthropicKey}`, + evidence: [`Authorization: Bearer ${bearerToken}`, `DATABASE_URL=${databaseUrl}`], + nextStep: `password=${password}`, + }); + await pi.goal('status'); + + const persisted = JSON.stringify(latestGoalStateData(pi)); + const displayed = pi.notifications.at(-1)?.message ?? ''; + const toolOutput = JSON.stringify(result); + for (const secret of [githubToken, anthropicKey, bearerToken, databaseUrl, password]) { + expect(persisted).not.toContain(secret); + expect(displayed).not.toContain(secret); + expect(toolOutput).not.toContain(secret); + } + expect(persisted).toContain('[REDACTED-SECRET]'); + }); + + it('preserves ordinary typed fields that resemble sensitive assignment names', async () => { + const typedFields = 'token: string, password: boolean, secret: false'; + const pi = new FakePi(); + + await pi.goal(`set Preserve TypeScript fields: ${typedFields}`); + await pi.report({ + status: 'continue', + summary: `Schema still contains ${typedFields}`, + evidence: [`interface Config { ${typedFields} }`], + nextStep: `Keep ${typedFields} unchanged`, + }); + + expect(stateField(pi, 'statement')).toContain(typedFields); + expect(JSON.stringify(stateField(pi, 'lastReport'))).toContain(typedFields); + expect(JSON.stringify(latestGoalStateData(pi))).not.toContain('[REDACTED-SECRET]'); + }); + + it('stops autonomous continuation when the max-turn limit is reached', async () => { + vi.stubEnv('MOSAIC_GOAL_MAX_TURNS', '2'); + const pi = new FakePi(); + await pi.goal('set Bound this run'); + pi.sentMessages.length = 0; + + await pi.emit('turn_end', { turnIndex: 0, message: {}, toolResults: [] }); + await pi.emit('turn_end', { turnIndex: 1, message: {}, toolResults: [] }); + + expect(stateField(pi, 'phase')).toBe('exhausted'); + expect(pi.abortCount).toBe(1); + await pi.emit('agent_settled'); + expect(pi.sentMessages).toHaveLength(0); + }); + + it('resets the no-progress sequence when a continuation report changes', async () => { + const pi = new FakePi(); + await pi.goal('set Track changing progress'); + + await pi.report({ + status: 'continue', + summary: 'First checkpoint', + evidence: ['file A changed'], + nextStep: 'Run focused tests', + }); + await pi.report({ + status: 'continue', + summary: 'Second checkpoint', + evidence: ['focused tests passed'], + nextStep: 'Review the diff', + }); + + expect(stateField(pi, 'phase')).toBe('active'); + expect(stateField(pi, 'noProgressReports')).toBe(1); + await pi.goal('status'); + expect(pi.notifications.at(-1)?.message).toContain('Next step: Review the diff'); + expect(pi.notifications.at(-1)?.message).toContain('focused tests passed'); + }); + + it('stops after a bounded number of identical no-progress reports', async () => { + vi.stubEnv('MOSAIC_GOAL_MAX_NO_PROGRESS', '2'); + const pi = new FakePi(); + await pi.goal('set Detect stalled work'); + + const report = { + status: 'continue', + summary: 'No change yet', + evidence: ['same observation'], + nextStep: 'Try again', + }; + await pi.report(report); + await pi.report(report); + + expect(stateField(pi, 'phase')).toBe('exhausted'); + expect(stateField(pi, 'noProgressReports')).toBe(2); + pi.sentMessages.length = 0; + await pi.emit('agent_settled'); + expect(pi.sentMessages).toHaveLength(0); + }); + + it('rejects a goal report mixed with another tool result in the same turn', async () => { + const pi = new FakePi(); + await pi.goal('set Require a sole final report'); + await pi.report({ + status: 'achieved', + summary: 'Premature mixed claim', + evidence: ['one observation'], + }); + + await pi.emit('turn_end', { + turnIndex: 0, + message: {}, + toolResults: [{ toolName: 'mosaic_goal_report' }, { toolName: 'read' }], + }); + + expect(stateField(pi, 'phase')).toBe('active'); + expect(stateField(pi, 'verificationPasses')).toBe(0); + expect(stateField(pi, 'lastCheckOutcome')).toBe('mixed-goal-report-rejected'); + expect(pi.notifications.at(-1)?.level).toBe('warning'); + }); + + it('marks blocked reports terminal until the operator resumes', async () => { + const pi = new FakePi(); + await pi.goal('set Stop on a real blocker'); + + await pi.report({ + status: 'blocked', + summary: 'Missing required access', + evidence: ['provider returned 403'], + }); + expect(stateField(pi, 'phase')).toBe('blocked'); + + pi.sentMessages.length = 0; + await pi.emit('agent_settled'); + expect(pi.sentMessages).toHaveLength(0); + + await pi.goal('resume'); + expect(stateField(pi, 'phase')).toBe('active'); + expect(stateField(pi, 'turnCount')).toBe(0); + expect(pi.sentMessages).toHaveLength(1); + }); +}); + +describe('Mosaic Pi goal compaction and restoration', () => { + it('resets provisional verification and defers manual-compaction continuation until idle', async () => { + vi.useFakeTimers(); + const pi = new FakePi(); + await pi.goal('set Survive compaction'); + await pi.report({ + status: 'achieved', + summary: 'Initial claim', + evidence: ['focused test passed'], + }); + expect(stateField(pi, 'phase')).toBe('verifying'); + pi.sentMessages.length = 0; + + await pi.emit('session_compact', { reason: 'manual', willRetry: false }); + expect(stateField(pi, 'phase')).toBe('active'); + expect(stateField(pi, 'verificationPasses')).toBe(0); + expect(stateField(pi, 'compactionCount')).toBe(1); + expect(stateField(pi, 'lastCheckSource')).toBe('compact'); + expect(pi.sentMessages).toHaveLength(0); + + await vi.runAllTimersAsync(); + expect(pi.sentMessages).toHaveLength(1); + expect(pi.sentMessages[0]?.message.content).toContain('compaction'); + }); + + it('does not re-enter an active automatic compaction and relies on the settled backstop', async () => { + vi.useFakeTimers(); + const pi = new FakePi(); + await pi.goal('set Avoid compaction races'); + pi.sentMessages.length = 0; + pi.idle = false; + + await pi.emit('session_compact', { reason: 'threshold', willRetry: false }); + await vi.runAllTimersAsync(); + expect(pi.sentMessages).toHaveLength(0); + + pi.idle = true; + await pi.emit('agent_settled'); + expect(pi.sentMessages).toHaveLength(1); + }); + + it('restores branch-specific state on session start and tree navigation', async () => { + vi.useFakeTimers(); + const source = new FakePi(); + await source.goal('set Restore this exact branch goal'); + const activeState = latestGoalStateData(source); + + const restored = new FakePi([ + { type: 'custom', customType: 'mosaic-goal-state', data: activeState }, + ]); + await restored.emit('session_start', { reason: 'resume' }); + expect(restored.statuses.get('mosaic-goal')).toContain('active'); + const context = await restored.emit('context', { messages: [] }); + expect(activeGoalStatementFromContext(context[0])).toContain('Restore this exact branch goal'); + + await restored.goal('pause'); + const pausedState = latestGoalStateData(restored); + restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: pausedState }]; + await restored.emit('session_tree', {}); + await vi.runAllTimersAsync(); + expect(stateField(restored, 'phase')).toBe('paused'); + expect(restored.sentMessages).toHaveLength(0); + }); + + it('restores only fully valid persisted states and ignores malformed entries', async () => { + vi.useFakeTimers(); + const source = new FakePi(); + await source.goal('set Validate persisted branch state'); + await source.report({ + status: 'continue', + summary: 'Valid report', + evidence: ['valid evidence'], + nextStep: 'Continue validation', + }); + const valid = latestGoalStateData(source); + + const validRestore = new FakePi([ + { type: 'custom', customType: 'mosaic-goal-state', data: valid }, + ]); + await validRestore.emit('session_start', { reason: 'resume' }); + expect(stateField(validRestore, 'statement')).toBe('Validate persisted branch state'); + await validRestore.emit('session_shutdown', { reason: 'reload' }); + + const validReport = latestGoalStateData(source)['lastReport']; + if (!isRecord(validReport)) throw new Error('Expected a valid persisted report fixture'); + const integerFields = [ + 'turnCount', + 'reportCount', + 'verificationPasses', + 'requiredVerificationPasses', + 'noProgressReports', + 'maxTurns', + 'maxNoProgressReports', + 'compactionCount', + ]; + const corruptions: Array<(state: Record) => unknown> = [ + (): unknown => null, + (state): unknown => ({ ...state, version: 99 }), + (state): unknown => ({ ...state, goalId: '' }), + (state): unknown => ({ ...state, statement: '' }), + (state): unknown => ({ ...state, statement: 'x'.repeat(8_001) }), + (state): unknown => ({ ...state, phase: 'unknown' }), + (state): unknown => ({ ...state, lastCheckSource: 'unknown' }), + (state): unknown => ({ ...state, startedAt: 4 }), + (state): unknown => ({ ...state, lastCheckAt: 4 }), + (state): unknown => ({ ...state, lastReport: null }), + (state): unknown => ({ ...state, lastProgressFingerprint: 4 }), + (state): unknown => ({ ...state, stopReason: 4 }), + ...integerFields.map((field: string) => (state: Record): unknown => ({ + ...state, + [field]: -1, + })), + ]; + + corruptions.push( + (state: Record): unknown => ({ ...state, maxTurns: 501 }), + (state: Record): unknown => ({ ...state, maxNoProgressReports: 101 }), + (state: Record): unknown => ({ ...state, requiredVerificationPasses: 3 }), + ); + const reportCorruptions: Array> = [ + { ...validReport, status: 'bad' }, + { ...validReport, summary: '' }, + { ...validReport, evidence: 'bad' }, + { ...validReport, evidence: [4] }, + { ...validReport, fingerprint: '' }, + { ...validReport, reportedAt: '' }, + { ...validReport, nextStep: 4 }, + ]; + for (const corruptReport of reportCorruptions) { + corruptions.push((state: Record): unknown => ({ + ...state, + lastReport: corruptReport, + })); + } + + for (const corrupt of corruptions) { + const candidate = corrupt(structuredClone(valid)); + const restored = new FakePi([ + { type: 'custom', customType: 'mosaic-goal-state', data: candidate }, + ]); + await restored.emit('session_start', { reason: 'resume' }); + expect(restored.statuses.get('mosaic-goal')).toBeUndefined(); + expect(await restored.emit('context', { messages: [] })).toEqual([undefined]); + } + + const failClosed = new FakePi([ + { type: 'custom', customType: 'mosaic-goal-state', data: valid }, + { type: 'custom', customType: 'mosaic-goal-state', data: { ...valid, version: 99 } }, + ]); + await failClosed.emit('session_start', { reason: 'resume' }); + expect(failClosed.statuses.get('mosaic-goal')).toBeUndefined(); + }); + + it('fails closed instead of reusing credential-bearing legacy branch state', async () => { + const source = new FakePi(); + await source.goal('set Build a valid restore fixture'); + const cleanState = latestGoalStateData(source); + const legacyState = structuredClone(cleanState); + legacyState['statement'] = `Legacy secret ghp_${'z'.repeat(32)}`; + + const restored = new FakePi([ + { type: 'custom', customType: 'mosaic-goal-state', data: legacyState }, + { type: 'custom', customType: 'mosaic-goal-state', data: cleanState }, + ]); + await restored.emit('session_start', { reason: 'resume' }); + + expect(restored.statuses.get('mosaic-goal')).toBeUndefined(); + expect(await restored.emit('context', { messages: [] })).toEqual([undefined]); + expect(restored.notifications.at(-1)?.level).toBe('warning'); + expect(restored.notifications.at(-1)?.message).toContain('was not restored'); + expect(restored.entries).toHaveLength(0); + }); + + it('schedules an active tree-restored goal and preserves terminal state through compaction', async () => { + vi.useFakeTimers(); + const source = new FakePi(); + await source.goal('set Restore active tree work'); + const active = latestGoalStateData(source); + + const restored = new FakePi(); + restored.branch = [{ type: 'custom', customType: 'mosaic-goal-state', data: active }]; + await restored.emit('session_tree', {}); + await vi.runAllTimersAsync(); + expect(restored.sentMessages).toHaveLength(1); + expect(restored.sentMessages[0]?.message.content).toContain('tree navigation'); + + await restored.goal('cancel'); + await restored.emit('session_compact', { reason: 'manual', willRetry: false }); + expect(stateField(restored, 'phase')).toBe('cancelled'); + expect(stateField(restored, 'compactionCount')).toBe(1); + }); + + it('cancels deferred continuation when the session shuts down', async () => { + vi.useFakeTimers(); + const pi = new FakePi(); + await pi.goal('set Do not leak a stale timer'); + pi.sentMessages.length = 0; + + await pi.emit('session_compact', { reason: 'manual', willRetry: false }); + await pi.emit('session_shutdown', { reason: 'reload' }); + await vi.runAllTimersAsync(); + + expect(pi.sentMessages).toHaveLength(0); + }); +});