feat(pi): add persistent Mosaic /goal controller #1152
@@ -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.
|
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 <statement>` 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
|
### TUI & Gateway
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+122
@@ -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)
|
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
||||||
|
|
||||||
### Problem and objective
|
### Problem and objective
|
||||||
|
|||||||
@@ -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 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.
|
- [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 management
|
||||||
|
|
||||||
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
3. [Provider Configuration](#provider-configuration)
|
3. [Provider Configuration](#provider-configuration)
|
||||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||||
5. [Environment Variables Reference](#environment-variables-reference)
|
5. [Environment Variables Reference](#environment-variables-reference)
|
||||||
6. [Local Fleet Canary](./fleet-local-canary.md)
|
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_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
||||||
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
| `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
|
### Providers
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -374,3 +385,29 @@ Session cleanup is scoped to one session identifier and only removes that sessio
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
||||||
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
| `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 <reason>` 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.
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||||
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
||||||
8. [API Endpoint Reference](#api-endpoint-reference)
|
8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension)
|
||||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
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
|
canonical root directly. Codex still relies on the existing full skill-sync
|
||||||
linker and needs separate parity analysis before this lifecycle API is extended.
|
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
|
## API Endpoint Reference
|
||||||
|
|
||||||
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
4. [Tasks](#tasks)
|
4. [Tasks](#tasks)
|
||||||
5. [Settings](#settings)
|
5. [Settings](#settings)
|
||||||
6. [CLI Usage](#cli-usage)
|
6. [CLI Usage](#cli-usage)
|
||||||
7. [Sub-package Commands](#sub-package-commands)
|
7. [Pi Persistent Goals](#pi-persistent-goals)
|
||||||
8. [Telemetry](#telemetry)
|
8. [Sub-package Commands](#sub-package-commands)
|
||||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
9. [Telemetry](#telemetry)
|
||||||
|
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -307,6 +308,57 @@ mosaic prdy
|
|||||||
mosaic quality-rails
|
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
|
### Claude Code Skill Registration
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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`
|
1. Injects the full runtime contract via `--append-system-prompt`
|
||||||
2. Loads Mosaic skills via `--skill` flags
|
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
|
4. Detects active missions and injects initial prompts
|
||||||
|
|
||||||
## Capabilities vs Other Runtimes
|
## 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 thinking levels replace sequential-thinking MCP
|
||||||
- Native skill discovery compatible with Mosaic SKILL.md format
|
- Native skill discovery compatible with Mosaic SKILL.md format
|
||||||
- Native extension system for lifecycle hooks (TypeScript, not bash shims)
|
- 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
|
- Native session persistence and resume
|
||||||
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
|
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,14 @@ The launcher:
|
|||||||
1. Verifies `~/.config/mosaic` exists
|
1. Verifies `~/.config/mosaic` exists
|
||||||
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
||||||
3. Injects `AGENTS.md` into the runtime
|
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 <statement>` 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`.
|
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
|
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
||||||
│ ├── codex/ ← instructions.md, RUNTIME.md
|
│ ├── codex/ ← instructions.md, RUNTIME.md
|
||||||
│ ├── opencode/ ← AGENTS.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
|
│ └── mcp/ ← MCP server configs
|
||||||
├── skills/ ← Universal skills (synced from mosaic/agent-skills)
|
├── skills/ ← Universal skills (synced from mosaic/agent-skills)
|
||||||
├── skills-local/ ← Local cross-runtime 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 |
|
| 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 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 codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
|
||||||
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
||||||
|
|||||||
@@ -51,12 +51,26 @@ Skills are discovered from:
|
|||||||
|
|
||||||
### Extensions
|
### 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
|
1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates,
|
||||||
- Active mission detection and context injection
|
and fleet heartbeat reporting.
|
||||||
- Memory routing to `~/.config/mosaic/memory/`
|
2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction
|
||||||
- MACP queue status reporting
|
checks.
|
||||||
|
|
||||||
|
The goal extension is deployed by Mosaic and MUST NOT be copied into `~/.pi/agent/extensions/`.
|
||||||
|
Use `/goal set <statement>` (or `/goal <statement>`) 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
|
### Sessions
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
||||||
MARKER="$TEST_ROOT/loader-marker"
|
MARKER="$TEST_ROOT/loader-marker"
|
||||||
LIBRARY="$TEST_ROOT/marker.so"
|
LIBRARY="$TEST_ROOT/marker.so"
|
||||||
|
FIXTURE_READY="$TEST_ROOT/loader-ready"
|
||||||
|
FIXTURE_FIFO="$TEST_ROOT/loader-block"
|
||||||
HOLDER_HOME="$TEST_ROOT/holder-home"
|
HOLDER_HOME="$TEST_ROOT/holder-home"
|
||||||
|
mkfifo "$FIXTURE_FIFO"
|
||||||
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||||
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
||||||
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||||
@@ -87,7 +90,17 @@ __attribute__((constructor)) static void mark_loader(void) {
|
|||||||
EOF
|
EOF
|
||||||
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
||||||
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
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"
|
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
||||||
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
||||||
: > "$MARKER"
|
: > "$MARKER"
|
||||||
|
|||||||
@@ -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' ]"
|
"[ -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: 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: 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)
|
# F2 — legacy install with a user-edited AGENTS.md (the sanctioned pre-constitution customization)
|
||||||
T2=$(mktemp -d); mkdir -p "$T2/credentials"
|
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: 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 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: 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"
|
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
buildPiSkillArgs,
|
buildPiSkillArgs,
|
||||||
|
discoverPiExtensionArgs,
|
||||||
enumerateSkillDirs,
|
enumerateSkillDirs,
|
||||||
piForceSkillNames,
|
piForceSkillNames,
|
||||||
registerRuntimeLaunchers,
|
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)', () => {
|
describe('enumerateSkillDirs (real FS)', () => {
|
||||||
let root: string;
|
let root: string;
|
||||||
|
|
||||||
|
|||||||
@@ -715,9 +715,15 @@ export function buildPiSkillArgs(
|
|||||||
return ['--no-skills', ...forcedSkillArgs];
|
return ['--no-skills', ...forcedSkillArgs];
|
||||||
}
|
}
|
||||||
|
|
||||||
function discoverPiExtension(): string[] {
|
const PI_EXTENSION_FILES = ['mosaic-extension.ts', 'goal-extension.ts'] as const;
|
||||||
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
|
|
||||||
return existsSync(ext) ? ['--extension', ext] : [];
|
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 ────────────────────────────────────────────────────────
|
// ─── Launch functions ────────────────────────────────────────────────────────
|
||||||
@@ -792,7 +798,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
const prompt = buildRuntimePrompt('pi');
|
const prompt = buildRuntimePrompt('pi');
|
||||||
const cliArgs = ['--append-system-prompt', prompt];
|
const cliArgs = ['--append-system-prompt', prompt];
|
||||||
cliArgs.push(...buildPiSkillArgs(args));
|
cliArgs.push(...buildPiSkillArgs(args));
|
||||||
cliArgs.push(...discoverPiExtension());
|
cliArgs.push(...discoverPiExtensionArgs());
|
||||||
if (hasMissionNoArgs) {
|
if (hasMissionNoArgs) {
|
||||||
cliArgs.push(missionPrompt);
|
cliArgs.push(missionPrompt);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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 () => {
|
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||||
rmSync(fixture.defaultsDir, { recursive: true });
|
rmSync(fixture.defaultsDir, { recursive: true });
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string | undefined>;
|
||||||
|
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<string, unknown>,
|
||||||
|
context: FakeContext,
|
||||||
|
) => unknown | Promise<unknown>;
|
||||||
|
|
||||||
|
type CommandHandler = (args: string, context: FakeContext) => unknown | Promise<unknown>;
|
||||||
|
|
||||||
|
interface FakeToolResult {
|
||||||
|
content: Array<{ type: string; text: string }>;
|
||||||
|
details?: unknown;
|
||||||
|
terminate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeTool {
|
||||||
|
name: string;
|
||||||
|
execute(
|
||||||
|
toolCallId: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
onUpdate: undefined,
|
||||||
|
context: FakeContext,
|
||||||
|
): Promise<FakeToolResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakePi {
|
||||||
|
readonly handlers = new Map<string, EventHandler[]>();
|
||||||
|
readonly commands = new Map<string, CommandHandler>();
|
||||||
|
readonly tools = new Map<string, FakeTool>();
|
||||||
|
readonly entries: FakeEntry[] = [];
|
||||||
|
readonly sentMessages: SentMessage[] = [];
|
||||||
|
readonly notifications: Array<{ message: string; level?: string }> = [];
|
||||||
|
readonly statuses = new Map<string, string | undefined>();
|
||||||
|
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<string, unknown> = {}): Promise<unknown[]> {
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown>): Promise<FakeToolResult> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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 <statement> 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<string, unknown>[] = [
|
||||||
|
{},
|
||||||
|
{ 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:[email protected]/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<string, unknown>) => 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<string, unknown>): unknown => ({
|
||||||
|
...state,
|
||||||
|
[field]: -1,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
corruptions.push(
|
||||||
|
(state: Record<string, unknown>): unknown => ({ ...state, maxTurns: 501 }),
|
||||||
|
(state: Record<string, unknown>): unknown => ({ ...state, maxNoProgressReports: 101 }),
|
||||||
|
(state: Record<string, unknown>): unknown => ({ ...state, requiredVerificationPasses: 3 }),
|
||||||
|
);
|
||||||
|
const reportCorruptions: Array<Record<string, unknown>> = [
|
||||||
|
{ ...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<string, unknown>): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user