@@ -0,0 +1,6 @@
|
||||
extensions/
|
||||
extensions.installed.sha256
|
||||
.extensions-*
|
||||
state/
|
||||
evidence/
|
||||
native-test-*.log
|
||||
@@ -0,0 +1,34 @@
|
||||
# Native goal development copy
|
||||
|
||||
From this repository, start a fresh native Pi session:
|
||||
|
||||
```sh
|
||||
bash scripts/goal-dev.sh
|
||||
```
|
||||
|
||||
Canonical source lives under `extensions/`. The launcher first runs `scripts/sync-dev-extensions.sh`, which installs verified ordinary-file copies under `.pi/extensions/`, then loads only the generated goal extension. Global extensions remain unloaded. The launcher keeps your usual native Pi provider authentication; it copies no credentials. Goal state and new conversation files live under `.pi/state/`, which is ignored by Git. Each process gets a fresh incarnation; `/reload` and `/new` in the same process retain its goal. Restarting Pi does not adopt an earlier process's active goal.
|
||||
|
||||
Plain `pi` also discovers `.pi/extensions/goal/index.ts` after project trust, but may load global extensions too. Use the launcher to avoid duplicate `/goal` registrations. This is a local development test, not a sandbox or the managed Mosaic runtime. Docker and `~/.mosaic` are unchanged.
|
||||
|
||||
## Try it
|
||||
|
||||
1. Set `/goal <a long goal with acceptance criteria>`. This starts work immediately.
|
||||
2. Look below the editor for `Goal: Active`. The old above-editor goal widget is gone.
|
||||
3. Run bare `/goal`, then press `Alt+G`. Both show the entire stored goal and its status. Tab remains autocomplete.
|
||||
4. Use `/goal stop` and `/goal resume`. Expect Paused and Active, or Waiting if an untimed wait remains recorded.
|
||||
5. A blocked `goal_report` displays Blocked. A satisfied report displays Complete and retains the full goal for recall without continuing work.
|
||||
6. `/goal clear` removes the retained goal. Try `NO_COLOR=1 bash scripts/goal-dev.sh` to check text-only labels.
|
||||
|
||||
Use terminal scrollback for recall longer than the screen. At narrow widths Pi may truncate its footer status row; bare `/goal` and Alt+G remain available.
|
||||
|
||||
## Checks
|
||||
|
||||
```sh
|
||||
node --test extensions/goal/test/*.test.ts
|
||||
bash scripts/test-extension-package.sh
|
||||
python3 scripts/test-goal-native.py
|
||||
```
|
||||
|
||||
Contract tests use ordinary read-only fixture copies in `test/fixtures/skills-local/`, not live brain files. The executive-update fixture SHA-256 matches the parser's pinned contract, `bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319`.
|
||||
|
||||
`SOURCE-SNAPSHOT.json` records the original external-source baseline, not the edited candidate. No symlinks are used. Never edit `.pi/extensions/`; the sync script refuses to overwrite installation drift. Make changes under `extensions/`, run the checks, and relaunch. To disable the test, stop its Pi process and remove `.pi/extensions/`. Keep `.pi/state/` only if you need local test state.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"snapshotVersion": 1,
|
||||
"copiedAt": "2026-09-06T04:58:22Z",
|
||||
"source": "~/.mosaic/fleet/extensions",
|
||||
"goalTreeSha256": "8853f2b72dde3e87c4573648b9a931c1c75da87ccde995c3224e6d2e707a75f0",
|
||||
"mosaicCoreLibTreeSha256": "d1194dce31209e5773c6cc5ce571cbca3c39b29d943a79dea06665e05d29f319",
|
||||
"symlinks": false,
|
||||
"autoDiscoveredExtensions": ["goal"],
|
||||
"purpose": "Issue #54 native Pi NG development copy; never loaded by Docker"
|
||||
}
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compatibility entrypoint for the accepted native test command.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
exec scripts/goal-dev.sh "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
# Replacement repository source layout
|
||||
|
||||
Tracking issue: [#55](https://git.mosaicstack.dev/mosaicstack/stack-v2/issues/55)
|
||||
|
||||
## Authority and scope
|
||||
|
||||
Jason requested canonical codebase storage for extensions, skills, and other components, with phased replacement of mosaicstack/stack. Source organization does not authorize deployment, credential migration, commit, push, or changes to the live fleet. The accepted goal extension is the first migration candidate. This record is an investigation and recommendation, not an implemented move.
|
||||
|
||||
## Measured legacy structure
|
||||
|
||||
Read through the Gitea contents API on 2026-09-06 at 06:14 UTC. Requests returned HTTP 200.
|
||||
|
||||
- `apps/` contains appservice, gateway, and web.
|
||||
- `packages/` contains shared domains including agent, auth, brain, comms, config, coord, db, forge, log, memory, queue, storage, and types.
|
||||
- `plugins/` contains discord, macp, mosaic-framework, and telegram. These names alone do not establish that these plugins are Pi extensions.
|
||||
- Root also has `skills/`, `tools/`, `scripts/`, `profiles/`, `infra/`, `docker/`, and documentation.
|
||||
- Root package manifest is private, declares pnpm 10.6.2, and delegates build, lint, typecheck, and tests to Turbo.
|
||||
- Workspace membership is explicit: apps/*, packages/*, plugins/*, and tools/matrix-presence-harness. Skills are not declared as JavaScript workspace packages.
|
||||
- Turbo builds dependency packages before dependents and records dist/** as build output. Tests and typechecking depend on dependency builds.
|
||||
|
||||
Inspected manifest blobs:
|
||||
|
||||
- package.json: `5910f98225226274141a6da4c81098fa122f3f46`
|
||||
- pnpm-workspace.yaml: `abb7a60e203c6d44dcf8dc9bf7c99cb3cb72cbd8`
|
||||
- turbo.json: `cf8ef998ee52391cc581c16c865f6d0c0066b24f`
|
||||
|
||||
The recursive `main` tree response identified commit/tree `7102ccb93e007d98faab3994ea552d03f522ad2c` but was truncated at 1,000 entries. Follow-up contents reads supplied complete targeted directories. This is a targeted structural and packaging inspection, not a complete legacy inventory.
|
||||
|
||||
## Recommendation for stack-v2
|
||||
|
||||
Adopt the monorepo principle now, not the entire legacy directory tree or build system.
|
||||
|
||||
- `extensions/goal/` becomes canonical goal source and tests.
|
||||
- `extensions/mosaic-core/lib/` initially holds its existing supporting dependencies. This does not activate Mosaic Core as a separate extension.
|
||||
- Existing `skills/`, `contracts/`, `adapters/`, `roles/`, `scripts/`, and `docs/` retain their distinct responsibilities.
|
||||
- `.pi/extensions/` becomes a generated native-test installation. Never edit it as canonical source. Populate it by deterministic ordinary-file copying, not symlinks.
|
||||
- `.pi/state/` remains ignored runtime state and must never enter an extension artifact.
|
||||
- Introduce apps/ or packages/ when an actual service or shared package requires them. Do not create empty placeholder packages or rename existing components merely to resemble the old repository.
|
||||
- Package explicit source inventories and dependency versions. Record artifact hashes and fail rather than overwrite unknown local edits in the development installation.
|
||||
|
||||
The current private npm manifest and pinned Pi dependency do not need to become pnpm/Turbo merely to store one extension. Decide workspace tooling when independently built JavaScript packages justify it. Native Pi 0.85.1 testing versus the container's 0.84.4 pin is an unresolved compatibility gate before managed-runtime promotion.
|
||||
|
||||
## Measured legacy packaging and installation
|
||||
|
||||
Targeted Gitea reads returned HTTP 200 for the installer, release verifier, publish pipeline, publishing helper, and representative manifests.
|
||||
|
||||
- Shared packages such as `@mosaicstack/agent` build TypeScript into `dist/`, publish only `dist`, and use explicit workspace dependencies.
|
||||
- The `@mosaicstack/mosaic` package publishes both `dist` and `framework`. This is the legacy bundle for CLI plus framework resources.
|
||||
- `plugins/mosaic-framework` declares an OpenClaw extension in its own package manifest. It is not a Pi extension package.
|
||||
- The main installer separates framework files under `~/.config/mosaic` from the globally installed npm CLI. Its development mode builds the monorepo, packs local tarballs, and installs those tarballs without registry writes.
|
||||
- The publish pipeline installs from a frozen lockfile, verifies that CI's commit equals checkout HEAD, runs one terminal release verifier, builds, and only then publishes. Registry, authentication, network, and unknown publication failures fail the job. The `next` lane snapshots and restores modified manifests.
|
||||
- A separate npmjs helper mutates manifests temporarily and restores them. It tolerates publish failures broadly, so it is not a model for stack-v2's fail-closed publication path.
|
||||
|
||||
The useful pattern is source, build output, installation, and publication as separate states tied to explicit manifests and immutable identity. The volume and complexity of the old framework package should not be copied into the first stack-v2 extension increment.
|
||||
|
||||
## Phases
|
||||
|
||||
1. Source ownership: move accepted goal source, dependencies, and tests into canonical directories; preserve source provenance and user acceptance evidence.
|
||||
2. Native development packaging: deterministic install into .pi, drift checks, source/package tests, explicit runtime version compatibility, and repeat native acceptance if behavior changes.
|
||||
3. Managed runtime integration: approved extension allowlist, image packaging, policy compatibility, isolation, and rollback checks. Docker remains deferred until authorized.
|
||||
4. Component-by-component legacy replacement: inventory dependencies, contracts, state ownership, acceptance criteria, and rollback for each component before migration. No wholesale adoption of operational state.
|
||||
|
||||
## Implemented first increment
|
||||
|
||||
The accepted goal extension now follows the recommended ownership boundary.
|
||||
|
||||
- Canonical source and tests are under `extensions/goal/`.
|
||||
- Imported support modules are under `extensions/mosaic-core/lib/`; no second entrypoint exists.
|
||||
- `scripts/sync-dev-extensions.sh` stages and verifies ordinary-file copies in `.pi/extensions/`. It records the installed manifest, rejects source symlinks or extra entrypoints, detects installation drift, and refuses to overwrite it.
|
||||
- `scripts/goal-dev.sh` synchronizes before launching isolated native Pi. `.pi/goal-dev.sh` remains a compatibility wrapper for the accepted test command.
|
||||
- `scripts/test-extension-package.sh` runs 18 controls. It covers initial installation, full-tree equality, extra destination entries, source and destination symlinks, local drift, canonical updates, nested entrypoints, active and stale locks, and interruption rollback/recovery.
|
||||
- `.pi/extensions/`, state, generated manifests, and local evidence are ignored. They are installations or runtime evidence, not canonical source.
|
||||
|
||||
Verification after the move passed: 67 goal tests, all 18 development-package controls, and native Pi PTY checks at 45 and 120 columns for Waiting, Paused, Blocked, Complete, bare `/goal`, Alt+G, and NO_COLOR. A separate read-only reviewer first requested stronger full-tree, nested-entrypoint, interruption, and lock controls. After correction, the reviewer returned APPROVE and identified each prior finding as closed. The exact canonical-source manifest is `.pi/evidence/canonical-source.sha256`, whose SHA-256 is `c2096c00fb505a53482ff6a94a0f293dbc1ca2058d7a1996961601af80c29946`.
|
||||
|
||||
No package-manager change, npm publication, Docker inclusion, live-fleet installation, commit, or push is part of this increment. Unrelated CURRENT.md work remains untouched.
|
||||
|
||||
## Replacement method
|
||||
|
||||
Use the repository as a component monorepo with explicit boundaries, not as one undifferentiated package. Keep declarative resources in their existing top-level directories. Add executable packages or apps only when they have independent build and dependency needs. Every migrated component gets canonical source, tests, a deterministic development installation, an explicit release inventory, exact runtime compatibility, independent review, and user acceptance before the old implementation can be retired.
|
||||
|
||||
For Pi resources, the Pi package format supports explicit `pi.extensions` and `pi.skills` paths. Do not publish the entire private repository by convention. When external distribution becomes necessary, create a bounded package manifest or staged artifact that names only approved resources and declares Pi APIs as peer dependencies. The current sync installation is deliberately narrower and precedes that release decision.
|
||||
|
||||
## Coordination request
|
||||
|
||||
`MS55-DW-1` asks darkwing, the current owner of CURRENT.md and issue #53 phase-2 planning, to reconcile the next foundation goal before Jason sets it. Requested return: darkwing's intended technical-mapping scope, paths this session must avoid, and a recommended division of work that incorporates #55 without overlapping writers. Destination is the default tmux session `darkwing`; follow-up owner is this assistant. `tools/tmux/agent-send.sh` returned rc=2 at 2026-09-06 06:55 UTC: it could not confirm submission after three attempts and reported that the message may be undelivered. Delivery is unknown, so this session will not resend without reconciliation. No source or shared-plan work starts from this request.
|
||||
|
||||
## Reconciliation finding
|
||||
|
||||
CURRENT.md says issue #53 phase 2 is accepted and awaits Jason's separate authorization for technical mapping. ROADMAP.md also records an earlier owner decision: the succession target uses `packages/*`, while broad restructuring waits for M20 because moving current bash entrypoints sooner would create two migrations. The #55 `extensions/` location is therefore a valid canonical source improvement now, but it cannot silently become the final post-M20 package layout. Jason's latest direction does not explicitly revoke the earlier sequencing decision.
|
||||
|
||||
The next safe step is a coordination and baseline goal, not another code move. It should reconcile the `extensions/` staging location with the `packages/*` target, assign non-overlapping writers for the current dirty tree, and define the exact committed revision that a later as-built/planned map will cite. Archify mapping requires a commit SHA and will not treat the current dirty working tree as evidence.
|
||||
|
||||
Darkwing replied directly to `MS55-DW-1` at 2026-09-06. The reply supports an evidence-backed current-versus-planned component/package map with #55 as input and no runtime implementation. Agreed ownership: darkwing owns requirement-to-code mapping and CURRENT integration; Dewey owns source, package, installation boundaries, and phased inventory in this #55 plan or a separately agreed file. Darkwing will avoid `extensions/**`, `.pi/**`, #54/#55 plans, and the four extension development scripts. Dewey will avoid CURRENT.md, the three foundation/phase-2/schema plans, and `foundation-v1-candidate/**`. Shared logs remain append-only. Both sides await Jason's explicit mapping authorization. A non-author must review the resulting candidate. The acknowledgement send returned rc=2 after three unconfirmed attempts; delivery is unknown and it was not resent.
|
||||
@@ -0,0 +1,135 @@
|
||||
# NG goal footer development plan
|
||||
|
||||
Issue: [#54](https://git.mosaicstack.dev/mosaicstack/stack-v2/issues/54)
|
||||
|
||||
## Outcome
|
||||
|
||||
Develop a repository-local copy of the Mosaic `/goal` extension for native Pi launches from this repository. Goal state appears in Pi's built-in footer as `Goal: <State>`. Bare `/goal` and a keyboard shortcut show the full stored goal text without truncation.
|
||||
|
||||
This is NG development. It does not change or reload the live extension under `~/.mosaic`, and it does not add the extension to the Docker launch path.
|
||||
|
||||
## Baseline and ownership
|
||||
|
||||
- Repository baseline at intake: `69d1bb3` with unrelated, preserved phase-2 planning changes in the working tree.
|
||||
- Source snapshot: `~/.mosaic/fleet/extensions/goal/` plus the imported `mosaic-core/lib/` modules, copied as ordinary files.
|
||||
- Initial development location: `<cwd>/.pi/extensions/`; canonical source moved to `extensions/` after acceptance.
|
||||
- Author: this native coding session, `01a07506-f3c7-76ff-a725-b3c5e2086f31`. The operator directed completion after the setup-only checkpoint. The worker declaration remains undispatched.
|
||||
- Independent reviewer: a non-authoring reviewer seat on the exact candidate.
|
||||
- Product acceptance owner: Jason.
|
||||
|
||||
The source snapshot is not evidence that the external extension is safe to change. All changes remain inside this repository; nothing writes through to the external source.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. Native Pi discovers the project extension through `.pi/extensions/*/index.ts` after project trust is granted.
|
||||
2. No goal widget appears above the editor.
|
||||
3. Pi's built-in footer displays a textual state. Color reinforces the text but never carries the meaning alone.
|
||||
4. State presentation uses Pi theme roles:
|
||||
- `Active`: `accent`
|
||||
- `Waiting`: `warning`
|
||||
- `Paused`: `warning`
|
||||
- `Blocked`: `error`
|
||||
- `Complete`: `success`
|
||||
- `None`, if shown: `muted`
|
||||
5. Bare `/goal` displays the complete stored goal text. It may format or wrap the text, but it must not shorten it.
|
||||
6. A non-conflicting shortcut displays the same complete status. Tab remains Pi autocomplete because Pi's public status API does not expose focusable footer items.
|
||||
7. Waiting, blocked, and ordinary pause are distinguishable. A completed goal remains recallable without reactivating its continuation loop.
|
||||
8. Existing continuation, report, wait, abort, persistence, and incarnation-fencing behavior stays intact.
|
||||
9. Extension failures must not alter the live `~/.mosaic` files or operator goal state used by other agents.
|
||||
|
||||
## State design
|
||||
|
||||
The current source has `active`, `paused`, `blocked`, and `none` in its type, but blocked reports currently become paused with a `blocked:` reason. Satisfaction clears the state immediately. The implementation must add an explicit display projection rather than infer every label in the renderer.
|
||||
|
||||
Preserve active-loop semantics. Store a bounded terminal outcome record for recall after completion, including the full goal text and terminal state. Do not treat `Complete` as an active goal. Existing version-1 state files must load without losing their active or paused goal.
|
||||
|
||||
Bare `/goal` must use the full state text. Short text remains acceptable in transient notifications that are not the recall command.
|
||||
|
||||
## Interaction design
|
||||
|
||||
Use `ctx.ui.setStatus("goal", ...)` so the goal indicator composes with Pi's default footer. Do not replace the entire footer. Remove the project copy's `setWidget` registration and clear any stale goal widget during startup and reload.
|
||||
|
||||
Register a direct recall shortcut. `Alt+G` is the initial candidate, subject to a native terminal conflict check. The shortcut invokes the same full-status formatter as bare `/goal`; it does not insert text into the editor or start a model turn.
|
||||
|
||||
## Tasks
|
||||
|
||||
| ID | Owner | Work | Exit evidence |
|
||||
|---|---|---|---|
|
||||
| NG-GF-1 | Conductor | Copy the goal directory and imported mosaic-core library into `.pi/extensions/` with no symlinks; record source and destination hashes. Do not copy `mosaic-core/index.ts`, because Pi would auto-discover and execute that separate policy extension. | File inventory, no-symlink check, matching snapshot hashes, and one discovered goal extension. |
|
||||
| NG-GF-2 | Author worker | Implement display-state projection, terminal outcome recall, colored footer status, full `/goal` output, shortcut, and focused tests. | Clean patch plus focused test and type-check output. |
|
||||
| NG-GF-3 | Independent reviewer | Review the exact candidate for state migration, loop regressions, extension isolation, keyboard behavior, and hostile state inputs. | Written verdict tied to the candidate hash. |
|
||||
| NG-GF-4 | Conductor | Run repository suites and extension tests; verify no path resolves into `~/.mosaic`. | Green command output and path/hash checks. |
|
||||
| NG-GF-5 | Jason | Exercise native Pi at narrow and wide widths, active/waiting/paused/blocked/complete states, bare `/goal`, shortcut, and color-disabled mode. | Explicit user acceptance or findings. |
|
||||
|
||||
Worker declarations are `tasks/ng-goal-footer-implementation.json` and `tasks/ng-goal-footer-review.json`. They are not dispatched until the conductor provides an isolated exact candidate workspace. The current dirty repository is not silently adopted as a worker workspace.
|
||||
|
||||
## Verification
|
||||
|
||||
Automated checks:
|
||||
|
||||
- strict task validation;
|
||||
- extension unit and runtime tests;
|
||||
- state migration fixtures for old version-1 records;
|
||||
- footer text and theme-role assertions for every state;
|
||||
- full-text equality for `/goal` and shortcut recall;
|
||||
- no `setWidget` call for goal state;
|
||||
- no symlinks or source paths resolving into `~/.mosaic`;
|
||||
- existing repository suites;
|
||||
- `git diff --check` and prose checks.
|
||||
|
||||
Native TUI checks:
|
||||
|
||||
- normal and narrow terminal widths;
|
||||
- keyboard-only recall;
|
||||
- project reload and session replacement;
|
||||
- default theme and color-disabled output;
|
||||
- stale widget removal;
|
||||
- complete and blocked states remain textually identifiable.
|
||||
|
||||
The footer uses native terminal theme colors. Browser-specific WCAG contrast measurement is not applicable, but status meaning must remain available without color and in `NO_COLOR` mode.
|
||||
|
||||
## Security and rollback
|
||||
|
||||
Project-local extensions execute with the native Pi process's host permissions. The operator must explicitly trust this project. This test does not prove the managed execution, command recording, credential, or isolation requirements in #53.
|
||||
|
||||
Development-install rollback removes generated `.pi/extensions/` plus NG-only test state. Source rollback reverts the separately scoped canonical `extensions/` change; it never edits the external `~/.mosaic` extension.
|
||||
|
||||
## Setup checkpoint
|
||||
|
||||
NG-GF-1 completed on 2026-09-06. The copied goal tree hash is `8853f2b72dde3e87c4573648b9a931c1c75da87ccde995c3224e6d2e707a75f0`; the copied mosaic-core library hash is `d1194dce31209e5773c6cc5ce571cbca3c39b29d943a79dea06665e05d29f319`. `.pi/SOURCE-SNAPSHOT.json` records the snapshot. A no-symlink check passed, and only `.pi/extensions/goal/index.ts` is auto-discoverable.
|
||||
|
||||
An isolated native Pi RPC launch with `--approve`, a temporary `PI_CODING_AGENT_DIR`, no session, and no tools reported exactly one project-scoped `/goal` command from this repository's `.pi/extensions/goal/index.ts`. No extension error appeared.
|
||||
|
||||
The five self-contained goal suites pass 55 tests. The first broad copied-source run also exposed two repository-context tests that expect `skills-local/` files from the source brain. They remain failing in this repository and are not counted as green. Copying all of mosaic-core initially also exposed that its separate `index.ts` would be auto-discovered and that its tests require source-brain role manifests. Setup was corrected before the discovery smoke test: only the imported mosaic-core library remains. No live extension file was changed.
|
||||
|
||||
## Delivery gates
|
||||
|
||||
Implementation is not done at setup. It proceeds through author checks, independent exact-candidate review, repository checks, and Jason's native TUI acceptance. Docker promotion, release packaging, and edits to `~/.mosaic` are excluded from #54.
|
||||
|
||||
## Implementation checkpoint, 2026-09-06
|
||||
|
||||
The local implementation is available for hands-on testing. Start `bash .pi/goal-dev.sh` from this repository. It loads only the local goal extension, preserves native provider authentication, and puts goal state and conversation files under `.pi/state/`. It neither copies credentials nor loads the live fleet extension. Plain native Pi also discovers the project copy after trust, but the launcher prevents global extension collisions.
|
||||
|
||||
- NG-GF-2 implemented by the current native session. Footer status replaces widget content; stale widgets are cleared. Bare `/goal` and Alt+G share full-text recall. Complete retains full goal text and bounded evidence without an active continuation loop. Blocked has its own state and supports explicit resume. Old version-1 state remains readable.
|
||||
- NG-GF-3 has an independent read-only native reviewer APPROVE. The reviewer inspected the entrypoint, display, state, store, and launcher but did not rerun tests. The first review attempt timed out without output; a second process with a ready configured provider returned the verdict. No worker container was dispatched.
|
||||
- NG-GF-4 author checks passed: all 67 goal tests and repository suites config 24, task 90, release 14, conductor 17, auth 15. Native Pi 0.85.1 PTY checks passed at 45 and 120 columns for paused, blocked, complete, waiting, bare recall, shortcut recall, and one color-disabled case. Native discovery found exactly one local goal command. No symlinks. Live extension hashes still match the source snapshot.
|
||||
- The source-brain test failures are resolved with ordinary local contract fixtures. The executive-update fixture matches its pinned SHA-256. Tests no longer need live brain reads.
|
||||
- Static TypeScript checking was not run because no compiler is installed. Native loading and Node TypeScript execution passed, but they are not a substitute for static checking.
|
||||
- NG-GF-5 remains open for Jason. Instructions and expected results are in `.pi/README.md`. No user acceptance, commit, push, Docker integration, or release completion is claimed.
|
||||
|
||||
Evidence is in `.pi/evidence/README.md` and its logs. The reviewed candidate file manifest hashes to `10e949db54149c6bed945fab086ad7a91461683ee5661c920f9ee6bddcf04aae`. No reviewed source file changed between review and manifest recording.
|
||||
|
||||
Goal state: waiting for manual user test. Next action: Jason launches `bash .pi/goal-dev.sh`, exercises full recall and the footer, and returns acceptance or findings. Issue #54 stays open. This checkpoint does not advance or change issue #53 or CURRENT.md. No automatic wake is registered.
|
||||
|
||||
|
||||
## User acceptance, 2026-09-06 06:05 UTC
|
||||
|
||||
Jason replied "It works" to the native test handoff. NG-GF-5 is accepted for this local development test. This supersedes the manual user-test wait above; it does not establish separate measurements of every test case.
|
||||
|
||||
No further feature work is requested. Static type checking remains unperformed. Commit, push, Docker integration, and live fleet changes are not authorized by this acceptance. Issue #54 remains open for repository delivery disposition; CURRENT.md and unrelated work remain unchanged.
|
||||
|
||||
## Canonical source migration, 2026-09-06
|
||||
|
||||
At Jason's direction, the accepted implementation moved from development-only source ownership to the repository's component structure. Canonical files now live under `extensions/goal/` and `extensions/mosaic-core/lib/`. `.pi/extensions/` is generated and ignored.
|
||||
|
||||
`scripts/sync-dev-extensions.sh` performs staged ordinary-file installation with content verification and refuses local drift, symlinks, or an extra extension entrypoint. `scripts/goal-dev.sh` runs this sync before native Pi; `.pi/goal-dev.sh` remains a compatibility wrapper. The native test moved to `scripts/test-goal-native.py`. This source-only relocation does not change accepted behavior, package the extension into Docker, publish npm, or touch the live fleet.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Extensions
|
||||
|
||||
This directory holds canonical Pi extension source owned by stack-v2.
|
||||
|
||||
- `goal/` is the first accepted extension.
|
||||
- `mosaic-core/lib/` contains the supporting modules imported by goal. It has no extension entrypoint and does not activate Mosaic Core on its own.
|
||||
|
||||
Develop here, not under `.pi/extensions/`. Run:
|
||||
|
||||
```sh
|
||||
node --test extensions/goal/test/*.test.ts
|
||||
bash scripts/test-extension-package.sh
|
||||
scripts/sync-dev-extensions.sh --check
|
||||
python3 scripts/test-goal-native.py
|
||||
```
|
||||
|
||||
`scripts/sync-dev-extensions.sh` creates the native development installation under `.pi/extensions/` with ordinary files. It records installed hashes and refuses to overwrite local drift. `scripts/goal-dev.sh` synchronizes first, then loads only the generated goal entrypoint.
|
||||
|
||||
This is development packaging, not a published npm package or managed-runtime release. The Containerfile and runtime adapter still exclude these extensions. A later release increment must define its explicit artifact inventory, supported Pi version, image integration, policy gate, and rollback before activation.
|
||||
@@ -0,0 +1,129 @@
|
||||
# goal — pi extension
|
||||
|
||||
Operator-set goal with a forced check<->proceed loop. `/goal <text>` activates a goal; the
|
||||
extension keeps the agent working and reporting until it declares the goal `satisfied` via the
|
||||
`goal_report` tool, then retains a recallable completed outcome and returns the session to normal operation.
|
||||
|
||||
Requirements record: `docs/PRD.md` in mosaic-brain (issue #52). Design locked with Jason
|
||||
2026-08-28 (Q1-Q8).
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/goal full current or completed goal, state, and counters
|
||||
/goal <text> set (or replace, with a notify) the active goal
|
||||
/goal --max N <text> set with a non-default run limit (default 25)
|
||||
/goal stop pause the loop, goal retained
|
||||
/goal resume continue a paused goal (kicks a check turn)
|
||||
/goal clear remove the goal entirely
|
||||
```
|
||||
|
||||
`GOAL_MAX_CHECKS` env overrides the default cap for new goals (invalid values fall back to 25).
|
||||
|
||||
## Loop mechanics
|
||||
|
||||
1. `before_agent_start` appends the active goal and reporting instructions to the system
|
||||
prompt every turn, so the goal survives context growth and compaction.
|
||||
2. When the agent settles without the goal being satisfied, the extension injects a check
|
||||
prompt (`sendUserMessage`), forcing the next turn: the check<->proceed loop. Before reporting,
|
||||
follow the continuation loop in `skills-local/ms-proactive-agent/SKILL.md`.
|
||||
3. The agent reports via `goal_report`, the loop's only exit:
|
||||
- `satisfied` + evidence: completed outcome retained for recall, normal operation
|
||||
(`evidence`, `reason`, and `in_progress` structured progress use the `ms-executive-update`
|
||||
format — `skills-local/ms-executive-update/SKILL.md` — so the operator reads one shape everywhere)
|
||||
- `blocked` + reason: loop paused, reason surfaced to the operator
|
||||
- `in_progress`: evidence plus a structured progress record naming the nearest gate,
|
||||
owner, last live measurement, and next action
|
||||
4. Successful non-`goal_report` tool results create one observable work event. Measurement,
|
||||
action, and delegation reports require that event. Non-tool work requires a concrete artifact.
|
||||
Waiting requires an approved watch id or concrete next-check condition.
|
||||
5. Empty, malformed, unsupported, or duplicate `in_progress` reports do not reset the check
|
||||
counter. They increment a persisted no-progress counter and pause after three reports by
|
||||
default (`GOAL_MAX_NO_PROGRESS_REPORTS`). Repeated valid wait reports remain active without
|
||||
claiming new progress or triggering a false-positive pause.
|
||||
6. Esc (run abort) and run errors pause the loop instead of re-injecting; `/goal resume`
|
||||
continues. Operator-typed messages always flow through unchanged.
|
||||
7. No-report cap: N consecutive check turns without an accepted report auto-pause the loop and
|
||||
notify. Default 25 (`GOAL_MAX_CHECKS`), per-goal `--max N`.
|
||||
|
||||
The loop needs a persistent session (TUI or RPC). In print mode (`pi -p`) the goal directive
|
||||
and seat-durable state still apply, but check turns are not forced — print mode exits when the
|
||||
prompt pipeline completes, and a forced turn there races session teardown (measured).
|
||||
|
||||
## State
|
||||
|
||||
Incarnation-durable state survives `/new`, `/resume`, and reloads within one process. This
|
||||
repository's native development installation stores it under `.pi/state/goal/`. The identity is
|
||||
the process-launch incarnation, never `PI_SESSION_ID`. Unknown-owner legacy `goal-state.json`
|
||||
files are quarantined rather than inherited. One active goal exists per incarnation; setting a
|
||||
new goal replaces that incarnation's goal.
|
||||
|
||||
State stores counters, bounded routing fields, and a SHA-256 evidence fingerprint. It does not
|
||||
persist free-form progress evidence.
|
||||
|
||||
## Development installation
|
||||
|
||||
This directory is canonical source. Do not edit `.pi/extensions/`; generate that native-test
|
||||
installation with `scripts/sync-dev-extensions.sh`. The sync copies `goal/` and its
|
||||
`mosaic-core/lib/` dependency as ordinary files, verifies content hashes, and refuses to
|
||||
overwrite installation drift. It also enforces that `goal/index.ts` is the only extension
|
||||
entrypoint in this bounded package.
|
||||
|
||||
Start the isolated native test with `bash scripts/goal-dev.sh`. It runs the sync and passes the
|
||||
generated entrypoint explicitly to Pi while disabling ambient extension discovery. This does
|
||||
not install or reload the live fleet extension and does not add the extension to Docker.
|
||||
|
||||
Directory layout: `index.ts` for wiring, `lib/` for logic, and `test/` for hermetic checks.
|
||||
Pi supplies the extension API, TypeBox, and pi-ai imports at runtime.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
node --test extensions/goal/test/*.test.ts
|
||||
bash scripts/test-extension-package.sh
|
||||
```
|
||||
|
||||
The suite includes pure state, persistence, incarnation fencing, exact report-only loop,
|
||||
duplicate evidence, legitimate wait, headless extension-runtime, and enforcement-neutralization
|
||||
red controls.
|
||||
|
||||
## Bounded waits (operator opt-in)
|
||||
|
||||
Use `/goal --wait-timeout 60 <goal text>` to suspend automatic checks during a
|
||||
reported wait. The flag accepts 10–86400 seconds and combines with `--max N`.
|
||||
Omitting it preserves the previous wait-loop behavior. Existing running sessions
|
||||
must `/reload` to load this patch; do not reload unrelated seats for a trial.
|
||||
|
||||
A valid `goal_report` with `status: "in_progress"`, `progress.kind: "wait"`, an
|
||||
owner, and a watch id or concrete `nextCheck` persists the deadline and yields.
|
||||
There are no model heartbeats during that wait. Repeated wait reports preserve
|
||||
its original deadline. A substantive progress report clears the wait. The
|
||||
extension neither evaluates arbitrary shell conditions nor sends messages to
|
||||
other agents: a relevant incoming message can start an ordinary turn, or the
|
||||
one-shot deadline asks the agent to reconcile the dependency once.
|
||||
|
||||
There is **one automatic deadline wake per goal or explicit `/goal resume`**.
|
||||
Reporting another wait after that wake pauses the goal, including when its
|
||||
wording changes. This intentionally limits the initial rollout; multiple
|
||||
successive timed dependencies require explicit resumption. Deadline expiry
|
||||
never supplies approval or permits the agent to invent missing input.
|
||||
|
||||
The timer belongs to this Pi process. Reload within the same incarnation
|
||||
restores the original deadline; shutdown cancels timers. A new process has a
|
||||
new fenced goal identity and does not inherit another incarnation's goal.
|
||||
This patch is not a process supervisor. Print mode records a manual wait and
|
||||
never schedules a wake. No external systemd timer is installed.
|
||||
|
||||
If the runtime is busy at expiry, readiness is checked in code every second
|
||||
for at most 60 seconds; this also covers manual compaction with no settle event.
|
||||
If still busy, the goal pauses with a UI notice. A dispatched deadline wake
|
||||
must appear in `before_agent_start` within 30 seconds or the goal pauses without
|
||||
resending. The matching event confirms turn start, not successful task execution.
|
||||
Stop, clear, replacement, satisfaction, and blocking cancel owned timers.
|
||||
Late reports cannot settle a paused goal. Reports also refuse if the goal changes
|
||||
while an asynchronous policy check is running. State snapshots use atomic file
|
||||
replacement; failed saves stop continuation instead of claiming a saved result.
|
||||
|
||||
The tests exercise unchanged waits, deadline dispatch and observation, unresolved
|
||||
pause, compaction, reload, cancellation, print mode, malformed persisted waits,
|
||||
and actual filesystem write failure. This is separate from live seat acceptance.
|
||||
@@ -0,0 +1,504 @@
|
||||
// pi /goal extension — operator-set goal with a forced check<->proceed loop.
|
||||
//
|
||||
// Source of truth for requirements: docs/PRD.md (mosaic-brain, issue #52).
|
||||
// Design locked with Jason 2026-08-28 (Q1-Q8): verb set, agent-self-declared
|
||||
// satisfaction via goal_report, abort pauses, seat-durable state, runaway cap.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { goalDisplay, goalDetails } from "./lib/display.ts";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
clearGoal,
|
||||
completeGoal,
|
||||
blockGoal,
|
||||
pauseGoal,
|
||||
recordCheckInjected,
|
||||
recordInProgressReport,
|
||||
recordReport,
|
||||
recordWorkEvent,
|
||||
resumeGoal,
|
||||
setGoal,
|
||||
validateReport,
|
||||
type GoalState,
|
||||
type ProgressDetails,
|
||||
} from "./lib/state.ts";
|
||||
import { decideSettle } from "./lib/settle.ts";
|
||||
import { parseGoalCommand } from "./lib/parse.ts";
|
||||
import { loadState, saveState, resolveStatePath } from "./lib/store.ts";
|
||||
import { goalPolicyDenialCode, validateAttestedGoalReport } from "./lib/executive-update.ts";
|
||||
import { incarnationIdentity } from "../mosaic-core/lib/incarnation.ts";
|
||||
import { goalPolicyPublication, recordGoalPolicyDenial, registerGoalPolicyConsumer } from "../mosaic-core/lib/goal-policy.ts";
|
||||
|
||||
const WIDGET_ID = "goal";
|
||||
|
||||
export default function goalExtension(pi: ExtensionAPI) {
|
||||
// Register before role-specific adapters and Mosaic Core publish the one
|
||||
// trusted version-4 policy attestation for this process incarnation.
|
||||
const incarnationId = incarnationIdentity();
|
||||
registerGoalPolicyConsumer(incarnationId);
|
||||
|
||||
// NG-7 incarnation fencing (Mercer F1): state is scoped to THIS process
|
||||
// launch incarnation — a sibling incarnation of the same seat (marcie-2)
|
||||
// resolves its own fenced file and can never inherit, replace, pause, or
|
||||
// settle this incarnation's active focus. Legacy unfenced state is
|
||||
// quarantined without adoption (store.ts).
|
||||
// NG development state belongs to this project copy, never the live fleet agent home.
|
||||
const statePath = resolveStatePath(fileURLToPath(new URL("../../state/goal/", import.meta.url)));
|
||||
let state: GoalState = loadState(statePath);
|
||||
// Set on session_shutdown: after teardown, any ctx/runner use throws.
|
||||
let dead = false;
|
||||
let waitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let armedDeadline: string | undefined;
|
||||
let deadlinePending = false;
|
||||
|
||||
function cancelWaitTimer(): void {
|
||||
if (waitTimer !== undefined) clearTimeout(waitTimer);
|
||||
waitTimer = undefined;
|
||||
armedDeadline = undefined;
|
||||
deadlinePending = false;
|
||||
}
|
||||
|
||||
// A deadline is a single reconciliation wake, never an approval or a heartbeat.
|
||||
function wakeDeadline(ctx: ExtensionContext): void {
|
||||
if (dead || state.status !== "active" || !state.waitTimeoutSeconds ||
|
||||
!state.activeWait || state.activeWait.wakeSent) return;
|
||||
state = { ...state, waitWakeUsed: true,
|
||||
activeWait: { ...state.activeWait, wakeSent: true, wakeRequestId: randomUUID(), wakeDispatchedAt: Date.now(), wakeObserved: false } };
|
||||
if (!persist(ctx)) return;
|
||||
beginCheck(ctx);
|
||||
}
|
||||
|
||||
function syncWaitTimer(ctx: ExtensionContext): void {
|
||||
const wait = state.activeWait;
|
||||
if (dead || ctx.mode === "print" || state.status !== "active" || !state.waitTimeoutSeconds ||
|
||||
!wait || !Number.isFinite(wait.deadlineAt) || (wait.wakeSent && wait.wakeObserved)) {
|
||||
cancelWaitTimer();
|
||||
return;
|
||||
}
|
||||
const deadline = wait.wakeSent ? wait.wakeDispatchedAt! + 30000 : wait.deadlineAt!;
|
||||
const key = `${wait.wakeSent ? wait.wakeRequestId : "wait"}:${deadline}`;
|
||||
if (armedDeadline === key) return;
|
||||
cancelWaitTimer();
|
||||
armedDeadline = key;
|
||||
const tick = () => {
|
||||
waitTimer = undefined;
|
||||
if (dead || state.status !== "active" || armedDeadline !== key) return;
|
||||
if (wait.wakeSent) {
|
||||
pauseWith(ctx, "deadline wake was not observed within 30s", "wake delivery unconfirmed; paused — /goal resume after checking the session");
|
||||
return;
|
||||
}
|
||||
if (!ctx.isIdle()) {
|
||||
// Compaction can make a session busy without ever emitting agent_settled.
|
||||
// Poll runtime readiness in code only, for at most 60s; never call the model.
|
||||
if (Date.now() >= deadline + 60000) {
|
||||
pauseWith(ctx, "session remained busy after wait deadline", "deadline could not start work; paused — /goal resume when ready");
|
||||
return;
|
||||
}
|
||||
deadlinePending = true;
|
||||
waitTimer = setTimeout(tick, 1000);
|
||||
waitTimer.unref?.();
|
||||
return;
|
||||
}
|
||||
wakeDeadline(ctx);
|
||||
};
|
||||
waitTimer = setTimeout(tick, Math.max(1, deadline - Date.now()));
|
||||
waitTimer.unref?.();
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
function safeNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error"): void {
|
||||
try {
|
||||
ctx.ui.notify(message, level);
|
||||
} catch {
|
||||
// stale ctx or no UI — never break a turn over a notification
|
||||
}
|
||||
}
|
||||
|
||||
function persist(ctx: ExtensionContext): boolean {
|
||||
try {
|
||||
saveState(state, statePath);
|
||||
syncWaitTimer(ctx);
|
||||
return true;
|
||||
} catch (err) {
|
||||
state = pauseGoal(state, "state save failed; continuation stopped");
|
||||
cancelWaitTimer();
|
||||
try {
|
||||
safeNotify(ctx, `goal: state save failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} catch {
|
||||
// no UI — nothing else to do
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderWidget(ctx: ExtensionContext): void {
|
||||
if (!ctx.hasUI) return;
|
||||
try {
|
||||
// Clear a stale widget after upgrading/reloading; never register widget content.
|
||||
ctx.ui.setWidget(WIDGET_ID, undefined);
|
||||
const display = goalDisplay(state);
|
||||
const label = `Goal: ${display.label} (/goal, Alt+G)`;
|
||||
ctx.ui.setStatus("goal", process.env.NO_COLOR !== undefined
|
||||
? label : ctx.ui.theme.fg(display.color, label));
|
||||
} catch {
|
||||
// Rendering is best-effort; never break the session over it.
|
||||
}
|
||||
}
|
||||
|
||||
function notifyStatus(ctx: ExtensionContext): void {
|
||||
safeNotify(ctx, goalDetails(state), "info");
|
||||
}
|
||||
|
||||
function checkPrompt(): string {
|
||||
const deadline = state.waitTimeoutSeconds && state.activeWait?.wakeSent
|
||||
? " Wait deadline reached. Reconcile the dependency once. A deadline is not approval. If still unresolved and no independent work remains, report blocked; do not re-arm or poll."
|
||||
: "";
|
||||
const next = state.lastNextAction ? ` Execute the recorded next action first: ${state.lastNextAction}.` : "";
|
||||
return (
|
||||
`[goal check ${state.checks}/${state.maxChecks}; no-progress ${state.noProgressReports}/${state.maxNoProgressReports}]` +
|
||||
`${state.activeWait?.wakeRequestId ? `[goal wake ${state.activeWait.wakeRequestId}] ` : ""}${deadline}${next} Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. ` +
|
||||
`Report with goal_report exactly once. Use "satisfied" with evidence when fully met or "blocked" with the reason when no meaningful next step exists. For "in_progress", do not submit ` +
|
||||
`status alone: provide structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked) ` +
|
||||
`with evidence plus progress.kind, nearest gate, owner, last live measurement, and next action. ` +
|
||||
`Measurement/action/delegation requires a successful work tool event; non-tool work ` +
|
||||
`requires an artifact; waiting requires an approved watch id or concrete next-check condition. ` +
|
||||
`Empty, malformed, or duplicate reports increment the no-progress pause counter.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Inject one check prompt: immediately when idle, queued behind a running agent otherwise. */
|
||||
function beginCheck(ctx: ExtensionContext): void {
|
||||
if (dead || state.status !== "active") return;
|
||||
// The loop needs a persistent session. Print mode exits after the prompt
|
||||
// pipeline completes; a forced turn there races teardown (measured). The
|
||||
// goal directive (before_agent_start) still applies in print mode.
|
||||
if (ctx.mode === "print") return;
|
||||
state = recordCheckInjected(state);
|
||||
if (!persist(ctx)) return;
|
||||
renderWidget(ctx);
|
||||
const msg = checkPrompt();
|
||||
if (ctx.isIdle()) {
|
||||
try {
|
||||
pi.sendUserMessage(msg);
|
||||
return;
|
||||
} catch {
|
||||
// fall through to the queued form
|
||||
}
|
||||
}
|
||||
try {
|
||||
pi.sendUserMessage(msg, { deliverAs: "followUp" });
|
||||
} catch (err) {
|
||||
pauseWith(ctx, "check injection failed", "check injection failed — /goal resume after resolving delivery");
|
||||
safeNotify(ctx, `goal: check injection failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop reason of the most recent assistant message, or undefined when none. */
|
||||
function lastAssistantStopReason(ctx: ExtensionContext): string | undefined {
|
||||
try {
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
for (let i = branch.length - 1; i >= 0; i--) {
|
||||
const entry = branch[i];
|
||||
if (entry?.type === "message") {
|
||||
const msg = entry.message;
|
||||
if (msg?.role === "assistant") return msg.stopReason;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// session introspection is best-effort
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pauseWith(ctx: ExtensionContext, reason: string, notice: string): void {
|
||||
state = pauseGoal(state, reason);
|
||||
persist(ctx);
|
||||
renderWidget(ctx);
|
||||
safeNotify(ctx, `goal: ${notice}`, "warning");
|
||||
}
|
||||
|
||||
// ---------- /goal command ----------
|
||||
|
||||
pi.registerCommand("goal", {
|
||||
description: "Set or manage the session goal: <text> | stop | clear | resume | --max N | --wait-timeout S",
|
||||
handler: async (args, ctx) => {
|
||||
const cmd = parseGoalCommand(args ?? "");
|
||||
switch (cmd.kind) {
|
||||
case "error":
|
||||
safeNotify(ctx, `goal: ${cmd.message}`, "error");
|
||||
return;
|
||||
case "status":
|
||||
notifyStatus(ctx);
|
||||
return;
|
||||
case "stop":
|
||||
if (state.status !== "active") {
|
||||
safeNotify(ctx, "goal: nothing active to stop", "warning");
|
||||
return;
|
||||
}
|
||||
pauseWith(ctx, "stopped by operator", "paused — /goal resume to continue");
|
||||
return;
|
||||
case "clear":
|
||||
if (state.status === "none" && !state.lastOutcome) {
|
||||
safeNotify(ctx, "goal: no goal set", "warning");
|
||||
return;
|
||||
}
|
||||
state = clearGoal(state);
|
||||
if (!persist(ctx)) return;
|
||||
renderWidget(ctx);
|
||||
safeNotify(ctx, "goal: cleared — normal operation", "info");
|
||||
return;
|
||||
case "resume":
|
||||
if (state.status !== "paused" && state.status !== "blocked") {
|
||||
safeNotify(ctx, "goal: nothing paused to resume", "warning");
|
||||
return;
|
||||
}
|
||||
state = resumeGoal(state);
|
||||
if (!persist(ctx)) return;
|
||||
renderWidget(ctx);
|
||||
safeNotify(ctx, "goal: resumed", "info");
|
||||
beginCheck(ctx);
|
||||
return;
|
||||
case "set": {
|
||||
const replacing = state.status !== "none";
|
||||
state = setGoal(state, cmd.text, cmd.max, cmd.waitTimeoutSeconds);
|
||||
if (!persist(ctx)) return;
|
||||
renderWidget(ctx);
|
||||
safeNotify(
|
||||
ctx,
|
||||
`${replacing ? "goal: replaced" : "goal: set"} — ${truncate(cmd.text, 60)} (cap ${state.maxChecks})`,
|
||||
"info",
|
||||
);
|
||||
beginCheck(ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerShortcut("alt+g", {
|
||||
description: "Show the full NG goal text and status",
|
||||
handler: async (ctx) => { notifyStatus(ctx); },
|
||||
});
|
||||
|
||||
// ---------- goal_report tool (the loop's only exit) ----------
|
||||
|
||||
pi.registerTool({
|
||||
name: "goal_report",
|
||||
label: "Goal Report",
|
||||
description:
|
||||
"Report progress on the active operator goal. Required at every goal check-in. " +
|
||||
"Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. " +
|
||||
'Use "satisfied" with evidence, "blocked" with the reason, or "in_progress" with ' +
|
||||
"mechanically backed structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked) or an explicit waiting state. Status-only and duplicate " +
|
||||
"in_progress reports count toward an automatic no-progress pause.",
|
||||
promptSnippet: "Report goal status with evidence and mechanically backed progress",
|
||||
parameters: Type.Object({
|
||||
status: StringEnum(["satisfied", "blocked", "in_progress"] as const),
|
||||
evidence: Type.Optional(
|
||||
Type.String({
|
||||
description: "Completion evidence, blocker reason, or concise evidence for this work cycle",
|
||||
}),
|
||||
),
|
||||
progress: Type.Optional(
|
||||
Type.Object({
|
||||
kind: StringEnum(["measurement", "action", "delegation", "wait", "non_tool"] as const),
|
||||
gate: Type.String({ description: "Nearest incomplete gate" }),
|
||||
owner: Type.String({ description: "Current gate or external-wait owner" }),
|
||||
lastMeasurement: Type.String({ description: "Most recent live measurement, including what and when" }),
|
||||
nextAction: Type.String({ description: "Next concrete action or check" }),
|
||||
artifact: Type.Optional(Type.String({ description: "Required artifact locator for non_tool work" })),
|
||||
watchId: Type.Optional(Type.String({ description: "Approved active watch identifier" })),
|
||||
nextCheck: Type.Optional(Type.String({ description: "Concrete condition that ends or rechecks a wait" })),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
if (state.status === "none") {
|
||||
return {
|
||||
content: [{ type: "text", text: "No goal is active. Nothing to report." }],
|
||||
details: { goalStatus: "none" },
|
||||
};
|
||||
}
|
||||
if (state.status !== "active") {
|
||||
throw new Error("Goal is paused; only /goal resume or a new operator goal permits reports");
|
||||
}
|
||||
const reportingState = state;
|
||||
// A v4 policy parses and resolves the untrimmed payload before any
|
||||
// existing validation, state transition, persistence, or UI operation.
|
||||
const policy = goalPolicyPublication(incarnationId);
|
||||
if (policy) {
|
||||
const attested = await validateAttestedGoalReport(params.evidence, policy, params.status);
|
||||
if (!attested.ok) {
|
||||
const denial = goalPolicyDenialCode(attested.reason);
|
||||
recordGoalPolicyDenial(incarnationId, denial);
|
||||
throw new Error(`goal_report rejected by attested policy: ${denial}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (state !== reportingState || state.status !== "active") {
|
||||
throw new Error("Goal changed while validating report; report was not applied");
|
||||
}
|
||||
const evidence = (params.evidence ?? "").trim();
|
||||
const reportError = validateReport(params.status, evidence);
|
||||
if (reportError) {
|
||||
throw new Error(reportError);
|
||||
}
|
||||
|
||||
if (params.status === "satisfied") {
|
||||
state = completeGoal(state, evidence);
|
||||
if (!persist(ctx)) {
|
||||
state = pauseGoal(reportingState, "state save failed; completion not recorded");
|
||||
throw new Error("Goal completion could not be saved; continuation stopped");
|
||||
}
|
||||
renderWidget(ctx);
|
||||
safeNotify(ctx,
|
||||
`goal: satisfied and cleared${evidence ? ` — ${truncate(evidence, 80)}` : ""}`,
|
||||
"info",
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Goal recorded as SATISFIED. The goal is cleared; normal operation resumes. Do not call goal_report again unless the operator sets a new goal.",
|
||||
},
|
||||
],
|
||||
details: { goalStatus: "none" },
|
||||
};
|
||||
}
|
||||
|
||||
if (params.status === "blocked") {
|
||||
const reason = evidence || "no reason given";
|
||||
state = blockGoal(recordReport(state), truncate(reason, 120));
|
||||
if (!persist(ctx)) throw new Error("Goal blocker could not be saved; continuation stopped");
|
||||
renderWidget(ctx);
|
||||
safeNotify(ctx, `goal: BLOCKED — ${truncate(reason, 100)} — /goal resume after resolving`, "warning");
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Goal recorded as BLOCKED. The loop is paused and the operator has been notified. Do not call goal_report again until the operator resumes or sets a new goal.",
|
||||
},
|
||||
],
|
||||
details: { goalStatus: "blocked" },
|
||||
};
|
||||
}
|
||||
|
||||
const outcome = recordInProgressReport(state, {
|
||||
evidence,
|
||||
progress: params.progress as ProgressDetails | undefined,
|
||||
});
|
||||
state = outcome.state;
|
||||
if (!persist(ctx)) throw new Error("Goal state could not be saved; continuation stopped");
|
||||
renderWidget(ctx);
|
||||
|
||||
if (state.status === "paused") {
|
||||
safeNotify(ctx, `${state.pausedReason} — /goal resume after resolving`, "warning");
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Goal paused: ${state.pausedReason} (${outcome.reason}). The operator has been notified. Do not continue this goal until the operator resumes it.`,
|
||||
},
|
||||
],
|
||||
details: { goalStatus: "paused", classification: outcome.classification },
|
||||
};
|
||||
}
|
||||
|
||||
const text =
|
||||
outcome.classification === "progress"
|
||||
? `Recorded substantive progress (${outcome.reason}). Continue with the recorded next action.`
|
||||
: outcome.classification === "waiting"
|
||||
? (state.waitTimeoutSeconds && ctx.mode !== "print"
|
||||
? `Waiting recorded. Automatic goal checks are suspended until a relevant incoming message or the deadline at ${new Date(state.activeWait!.deadlineAt!).toISOString()}. The extension owns this single deadline wake; do not add timers or report unchanged status. Checkpoint and yield now.`
|
||||
: (ctx.mode === "print" ? "Waiting recorded. Print mode cannot schedule a wake; resume this goal in a persistent session." : `Recorded explicit waiting state (${outcome.reason}). Keep the approved watch or next-check condition active.`))
|
||||
: `No new progress recorded (${outcome.reason}). No-progress count is ${state.noProgressReports}/${state.maxNoProgressReports}; take the next safe action before reporting again.`;
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { goalStatus: "active", classification: outcome.classification },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------- loop wiring ----------
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (state.status !== "active") {
|
||||
if (event.prompt.startsWith("[goal check ")) {
|
||||
return { systemPrompt: `${event.systemPrompt}\nThis queued goal check is obsolete: the goal is paused or cleared. Do not execute it or call goal_report.` };
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.activeWait?.wakeRequestId && event.prompt.includes(`[goal wake ${state.activeWait.wakeRequestId}]`)) {
|
||||
state = { ...state, activeWait: { ...state.activeWait, wakeObserved: true } };
|
||||
if (!persist(ctx)) return { systemPrompt: `${event.systemPrompt}\nGoal state save failed. Stop this goal and report the recording failure.` };
|
||||
}
|
||||
const waitingDirective = state.waitTimeoutSeconds && ctx.mode !== "print"
|
||||
? " Bounded waits enabled: report in_progress with progress.kind=wait and a concrete nextCheck when no ready work remains. The extension suspends checks and supplies one deadline wake per goal/resume. Do not create a duplicate timer, sleep loop, or heartbeat. On unrelated incoming messages, keep the wait; on a relevant event verify the dependency. After the deadline wake, an unresolved wait pauses."
|
||||
: "";
|
||||
const directive =
|
||||
`[goal] ACTIVE OPERATOR GOAL: ${state.text}\n` +
|
||||
`Work toward this goal. Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. ` +
|
||||
`At every check-in you MUST call goal_report exactly once. Use ` +
|
||||
`"satisfied" with evidence when fully met or "blocked" with the reason when no meaningful next step ` +
|
||||
`exists. An "in_progress" report MUST follow a concrete action and use structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked), or ` +
|
||||
`declare an explicit approved wait/non-tool artifact. Status-only and duplicate reports are no progress. ` +
|
||||
`Do not end a turn without reporting.${waitingDirective}`;
|
||||
return { systemPrompt: `${event.systemPrompt}\n\n${directive}` };
|
||||
});
|
||||
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
if (state.status !== "active" || event.isError || event.toolName === "goal_report") return;
|
||||
state = recordWorkEvent(state, event.toolName);
|
||||
persist(ctx);
|
||||
renderWidget(ctx);
|
||||
});
|
||||
|
||||
pi.on("agent_settled", async (_event, ctx) => {
|
||||
if (state.status !== "active") return;
|
||||
|
||||
// Abort/error pause (Q3i) and runaway cap (Q4) live in lib/settle.ts (pure, tested).
|
||||
const decision = decideSettle(state, lastAssistantStopReason(ctx));
|
||||
if (decision.action === "pause") {
|
||||
pauseWith(ctx, decision.reason, `${decision.reason} — /goal resume to continue`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.action === "wait") {
|
||||
if (deadlinePending) wakeDeadline(ctx);
|
||||
else syncWaitTimer(ctx);
|
||||
return;
|
||||
}
|
||||
beginCheck(ctx);
|
||||
});
|
||||
|
||||
// Teardown guard: after session replacement/reload/quit, stop touching ctx.
|
||||
pi.on("session_shutdown", async () => {
|
||||
dead = true;
|
||||
cancelWaitTimer();
|
||||
});
|
||||
|
||||
// Seat-durable state (Q7b): reload from disk on every session start/reload/resume/new
|
||||
// so a fresh session in the same seat picks up the active goal.
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
dead = false;
|
||||
state = loadState(statePath);
|
||||
syncWaitTimer(ctx);
|
||||
renderWidget(ctx);
|
||||
if (state.status === "active") {
|
||||
safeNotify(ctx, `goal: active — ${truncate(state.text, 60)}`, "info");
|
||||
// Durability (Q7b): a fresh session with an active goal re-engages the
|
||||
// loop immediately — no prior turn exists to emit agent_settled.
|
||||
if (!(state.waitTimeoutSeconds && state.activeWait && !state.activeWait.wakeSent)) beginCheck(ctx);
|
||||
} else if (state.status === "paused" || state.status === "blocked") {
|
||||
safeNotify(ctx, "goal: paused — /goal resume to continue", "info");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { GoalState } from "./state.ts";
|
||||
|
||||
export type GoalDisplay = {
|
||||
label: "Active" | "Waiting" | "Paused" | "Blocked" | "Complete" | "None";
|
||||
color: "accent" | "warning" | "error" | "success" | "muted";
|
||||
};
|
||||
|
||||
export function goalDisplay(state: GoalState): GoalDisplay {
|
||||
if (state.status === "none") return state.lastOutcome
|
||||
? { label: "Complete", color: "success" }
|
||||
: { label: "None", color: "muted" };
|
||||
if (state.status === "blocked" ||
|
||||
(state.status === "paused" && state.pausedReason?.startsWith("blocked:"))) {
|
||||
return { label: "Blocked", color: "error" };
|
||||
}
|
||||
if (state.status === "paused") return { label: "Paused", color: "warning" };
|
||||
if (state.activeWait && !state.activeWait.wakeSent) return { label: "Waiting", color: "warning" };
|
||||
return { label: "Active", color: "accent" };
|
||||
}
|
||||
|
||||
/** Full recall is deliberately separate from short transient notifications. */
|
||||
export function goalDetails(state: GoalState): string {
|
||||
const display = goalDisplay(state);
|
||||
if (display.label === "None") return "Goal: None\nUse /goal <text> to set a goal.";
|
||||
const completed = state.status === "none" ? state.lastOutcome : undefined;
|
||||
const lines = [`Goal: ${display.label}`, "", completed?.text ?? state.text, ""];
|
||||
if (completed) {
|
||||
lines.push(`Completed: ${completed.at}`, `Evidence: ${completed.evidence}`);
|
||||
} else {
|
||||
if (state.pausedReason) lines.push(`Reason: ${state.pausedReason}`);
|
||||
if (state.status === "active" && state.activeWait) {
|
||||
lines.push(`Wait owner: ${state.activeWait.owner}`);
|
||||
if (state.activeWait.nextCheck) lines.push(`Next check: ${state.activeWait.nextCheck}`);
|
||||
if (state.activeWait.watchId) lines.push(`Watch: ${state.activeWait.watchId}`);
|
||||
if (state.activeWait.deadlineAt !== undefined) {
|
||||
const deadline = new Date(state.activeWait.deadlineAt);
|
||||
lines.push(`Deadline: ${Number.isFinite(deadline.getTime()) ? deadline.toISOString() : "invalid"}`);
|
||||
}
|
||||
}
|
||||
lines.push(`Checks: ${state.checks}/${state.maxChecks}`, `No-progress reports: ${state.noProgressReports}/${state.maxNoProgressReports}`);
|
||||
}
|
||||
lines.push("", "/goal stop | /goal resume | /goal clear");
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// lib/executive-update.ts — parser for the cited ms-executive-update Machine contract.
|
||||
//
|
||||
// The contract bytes are pinned and verified by Mosaic Core before this parser
|
||||
// is reachable for a version-4 role. This module accepts no alternate format,
|
||||
// heading, whitespace form, or identifier spelling.
|
||||
|
||||
import {
|
||||
GOAL_REPORT_CONTRACT_BLOB,
|
||||
GOAL_REPORT_CONTRACT_PATH,
|
||||
GOAL_REPORT_CONTRACT_SECTION,
|
||||
GOAL_REPORT_CONTRACT_SHA256,
|
||||
GOAL_REPORT_FORMAT,
|
||||
type GoalItemResolutionV1,
|
||||
type GoalPolicyPublication,
|
||||
type GoalTrackedItem,
|
||||
validateGoalItemResolution,
|
||||
} from "../../mosaic-core/lib/goal-policy.ts";
|
||||
|
||||
export {
|
||||
GOAL_REPORT_FORMAT,
|
||||
GOAL_REPORT_CONTRACT_PATH,
|
||||
GOAL_REPORT_CONTRACT_SECTION,
|
||||
GOAL_REPORT_CONTRACT_BLOB,
|
||||
GOAL_REPORT_CONTRACT_SHA256,
|
||||
};
|
||||
|
||||
export type ExecutiveUpdateSection = GoalTrackedItem["section"];
|
||||
|
||||
export type ExecutiveUpdateParseResult =
|
||||
| Readonly<{ ok: true; kind: "update" | "no-change"; items: readonly GoalTrackedItem[] }>
|
||||
| Readonly<{ ok: false; reason: string }>;
|
||||
|
||||
const SECTIONS: readonly ExecutiveUpdateSection[] = ["Just Completed", "Next Step", "Blocked"];
|
||||
const CONTROL_RE = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/;
|
||||
const TASK_RE = /^T[0-9]+$/;
|
||||
const PR_RE = /^#[0-9]+$/;
|
||||
const ROW_RE = /^[A-Z]{1,4}\.?[0-9]+(?:\.[0-9]+)*$/;
|
||||
const MARKER_RE = /^[A-Z0-9]+(?:-[A-Z0-9]+)*-Q[0-9]+$/;
|
||||
const SHA_RE = /^[0-9a-f]{40}$/;
|
||||
const PATH_RE = /^`[^`\s]+`$/;
|
||||
|
||||
function invalid(reason: string): ExecutiveUpdateParseResult {
|
||||
return Object.freeze({ ok: false, reason });
|
||||
}
|
||||
|
||||
function valid(items: GoalTrackedItem[], kind: "update" | "no-change" = "update"): ExecutiveUpdateParseResult {
|
||||
return Object.freeze({ ok: true, kind, items: Object.freeze(items) });
|
||||
}
|
||||
|
||||
function isContractItemToken(token: string): boolean {
|
||||
if (TASK_RE.test(token) || PR_RE.test(token) || ROW_RE.test(token) || MARKER_RE.test(token) || SHA_RE.test(token)) return true;
|
||||
if (!PATH_RE.test(token)) return false;
|
||||
const path = token.slice(1, -1);
|
||||
return !path.startsWith("/") && !path.startsWith("./") && !path.startsWith("../") && !/(?:^|\/)\.\.(?:\/|$)/.test(path);
|
||||
}
|
||||
|
||||
function hasValidBlockedSuffix(text: string): boolean {
|
||||
return text.endsWith("\u2014 nothing from you")
|
||||
|| /\u2014 Decision needed: \(1\) .+ \(2\) .+; recommend [1-9][0-9]*$/.test(text);
|
||||
}
|
||||
|
||||
/** The contract's distinct single-line update form. */
|
||||
function parseNoChange(raw: string): ExecutiveUpdateParseResult | undefined {
|
||||
const match = /^No change since ([^;\n]+); still waiting on (.+)\.$/.exec(raw);
|
||||
if (!match) return undefined;
|
||||
const [, since, waiting] = match;
|
||||
if (!isContractItemToken(since) || !isContractItemToken(waiting)) return invalid("No change: invalid item identifier");
|
||||
// The trusted resolver's public input is deliberately the existing closed
|
||||
// GoalTrackedItem shape. Both references are waiting-state identifiers; the
|
||||
// no-change kind below supplies the stricter unchanged-state rule.
|
||||
return valid([
|
||||
Object.freeze({ token: since, section: "Next Step" }),
|
||||
Object.freeze({ token: waiting, section: "Next Step" }),
|
||||
], "no-change");
|
||||
}
|
||||
|
||||
function parseSection(section: ExecutiveUpdateSection, body: string): ExecutiveUpdateParseResult | GoalTrackedItem[] {
|
||||
if (body === "* none\n") return [];
|
||||
if (!body.endsWith("\n")) return invalid(`${section}: final bullet must end with LF`);
|
||||
const lines = body.slice(0, -1).split("\n");
|
||||
if (lines.length < 1 || lines.length > 5) return invalid(`${section}: requires one to five bullets or * none`);
|
||||
const items: GoalTrackedItem[] = [];
|
||||
for (const line of lines) {
|
||||
if (line === "" || /[ \t]$/.test(line)) return invalid(`${section}: blank or trailing-whitespace line`);
|
||||
const match = /^\* ([^:\n]+): ([^\n]+)$/.exec(line);
|
||||
if (!match) return invalid(`${section}: invalid bullet grammar`);
|
||||
const [, token, text] = match;
|
||||
if (!isContractItemToken(token)) return invalid(`${section}: invalid item identifier`);
|
||||
if (text.startsWith("-")) return invalid(`${section}: bullet text may not start with -`);
|
||||
if (section === "Blocked" && !hasValidBlockedSuffix(text)) return invalid("Blocked: required terminal disposition missing");
|
||||
items.push(Object.freeze({ token, section }));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Parse the exact three-section Machine contract before any Goal state mutation. */
|
||||
export function parseExecutiveUpdate(raw: unknown): ExecutiveUpdateParseResult {
|
||||
if (typeof raw !== "string") return invalid("payload must be a string");
|
||||
if (raw.length === 0) return invalid("payload is empty");
|
||||
if (raw !== raw.normalize("NFC")) return invalid("payload is not NFC-normalized");
|
||||
if (CONTROL_RE.test(raw)) return invalid("payload contains control characters or non-LF line endings");
|
||||
if (/(^|\n)[^\n]*[ \t](?=\n|$)/.test(raw)) return invalid("payload contains trailing whitespace");
|
||||
|
||||
const noChange = parseNoChange(raw);
|
||||
if (noChange) return noChange;
|
||||
|
||||
const first = "Just Completed:\n\n";
|
||||
const second = "\nNext Step:\n\n";
|
||||
const third = "\nBlocked:\n\n";
|
||||
if (!raw.startsWith(first)) return invalid("first heading must be Just Completed");
|
||||
const nextIndex = raw.indexOf(second, first.length);
|
||||
if (nextIndex < 0 || raw.indexOf(second, nextIndex + 1) !== -1) return invalid("Next Step heading must appear exactly once after Just Completed");
|
||||
const blockedIndex = raw.indexOf(third, nextIndex + second.length);
|
||||
if (blockedIndex < 0 || raw.indexOf(third, blockedIndex + 1) !== -1) return invalid("Blocked heading must appear exactly once after Next Step");
|
||||
if (raw.includes("\nJust Completed:\n", first.length) || raw.includes("\nNext Step:\n", 0) && raw.indexOf(second) !== nextIndex) return invalid("heading duplication or order violation");
|
||||
|
||||
const bodies = [
|
||||
raw.slice(first.length, nextIndex),
|
||||
raw.slice(nextIndex + second.length, blockedIndex),
|
||||
raw.slice(blockedIndex + third.length),
|
||||
];
|
||||
const parsed: GoalTrackedItem[] = [];
|
||||
for (let index = 0; index < SECTIONS.length; index++) {
|
||||
const sectionItems = parseSection(SECTIONS[index], bodies[index]);
|
||||
if (!Array.isArray(sectionItems)) return sectionItems;
|
||||
parsed.push(...sectionItems);
|
||||
}
|
||||
return valid(parsed);
|
||||
}
|
||||
|
||||
export type AttestedGoalReportResult =
|
||||
| Readonly<{ ok: true; items: readonly GoalTrackedItem[] }>
|
||||
| Readonly<{ ok: false; reason: string }>;
|
||||
|
||||
function resolutionError(item: GoalTrackedItem, result: GoalItemResolutionV1): AttestedGoalReportResult | undefined {
|
||||
if (result.outcome !== "resolved") return Object.freeze({ ok: false, reason: `resolver-${result.outcome}` });
|
||||
if (item.section === "Just Completed" && !result.changedSincePreviousAcceptedReport) {
|
||||
return Object.freeze({ ok: false, reason: "unchanged-completion" });
|
||||
}
|
||||
if (item.section === "Just Completed" && result.completionEvidenceId === null) {
|
||||
return Object.freeze({ ok: false, reason: "completion-evidence-missing" });
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type AttestedGoalReportStatus = "satisfied" | "blocked" | "in_progress";
|
||||
|
||||
/** Resolve each parser-produced item through the attested, read-only resolver. */
|
||||
export async function validateAttestedGoalReport(
|
||||
raw: unknown,
|
||||
publication: GoalPolicyPublication,
|
||||
status: AttestedGoalReportStatus,
|
||||
): Promise<AttestedGoalReportResult> {
|
||||
const parsed = parseExecutiveUpdate(raw);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (parsed.kind === "no-change" && status !== "in_progress") {
|
||||
return Object.freeze({ ok: false, reason: "no-change-status" });
|
||||
}
|
||||
for (const item of parsed.items) {
|
||||
let resolution: unknown;
|
||||
try {
|
||||
resolution = await publication.resolver(item);
|
||||
} catch {
|
||||
return Object.freeze({ ok: false, reason: "resolver-unavailable" });
|
||||
}
|
||||
if (!validateGoalItemResolution(resolution)) {
|
||||
return Object.freeze({ ok: false, reason: "resolver-malformed" });
|
||||
}
|
||||
if (parsed.kind === "no-change") {
|
||||
if (resolution.outcome !== "resolved") return Object.freeze({ ok: false, reason: `resolver-${resolution.outcome}` });
|
||||
if (resolution.changedSincePreviousAcceptedReport) return Object.freeze({ ok: false, reason: "no-change-state-changed" });
|
||||
continue;
|
||||
}
|
||||
const error = resolutionError(item, resolution);
|
||||
if (error) return error;
|
||||
}
|
||||
return Object.freeze({ ok: true, items: parsed.items });
|
||||
}
|
||||
|
||||
/** Stable code for Core's payload-free denial journal. */
|
||||
export function goalPolicyDenialCode(reason: string): string {
|
||||
return ["resolver-zero", "resolver-multiple", "resolver-unavailable", "resolver-stale", "resolver-malformed", "unchanged-completion", "completion-evidence-missing", "no-change-status", "no-change-state-changed"].includes(reason)
|
||||
? reason
|
||||
: "parser-rejected";
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Pure /goal command parsing. No I/O, no pi imports.
|
||||
//
|
||||
// Verbs (Q1, locked with Jason 2026-08-28):
|
||||
// bare -> status
|
||||
// stop -> pause the loop, goal retained
|
||||
// clear -> remove the goal entirely
|
||||
// resume -> continue a paused goal
|
||||
// <text> -> set (or replace) the active goal
|
||||
// --max N / --max=N run-limit option on set (Q4); remainder is the goal text
|
||||
//
|
||||
// Only the exact words stop/clear/resume are verbs; anything else is goal text.
|
||||
|
||||
export type GoalCommand =
|
||||
| { kind: "status" }
|
||||
| { kind: "stop" }
|
||||
| { kind: "clear" }
|
||||
| { kind: "resume" }
|
||||
| { kind: "set"; text: string; max?: number; waitTimeoutSeconds?: number }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export function parseGoalCommand(raw: string): GoalCommand {
|
||||
const input = (raw ?? "").trim();
|
||||
if (input === "") return { kind: "status" };
|
||||
|
||||
const lower = input.toLowerCase();
|
||||
if (lower === "stop") return { kind: "stop" };
|
||||
if (lower === "clear") return { kind: "clear" };
|
||||
if (lower === "resume") return { kind: "resume" };
|
||||
|
||||
let max: number | undefined;
|
||||
let text = input;
|
||||
|
||||
let waitTimeoutSeconds: number | undefined;
|
||||
const waitOption = text.match(/(?:^|\s)--wait-timeout(?:=|\s+)([^\s]+)(?=\s|$)/);
|
||||
if (waitOption) {
|
||||
const n = Number(waitOption[1]);
|
||||
if (!Number.isInteger(n) || n < 10 || n > 86400 || String(n) !== waitOption[1]) {
|
||||
return { kind: "error", message: "--wait-timeout must be an integer from 10 to 86400 seconds" };
|
||||
}
|
||||
waitTimeoutSeconds = n;
|
||||
text = text.replace(waitOption[0], " ").trim();
|
||||
}
|
||||
if (/(?:^|\s)--wait-timeout(?:=|\s|$)/.test(text)) {
|
||||
return { kind: "error", message: "--wait-timeout requires one value from 10 to 86400 seconds" };
|
||||
}
|
||||
|
||||
const inline = text.match(/(?:^|\s)--max=([^\s]+)(?:\s|$)/);
|
||||
if (inline) {
|
||||
const parsed = parseMax(inline[1]);
|
||||
if (typeof parsed === "string") return { kind: "error", message: parsed };
|
||||
max = parsed;
|
||||
text = text.replace(inline[0], " ").trim();
|
||||
} else {
|
||||
const spaced = text.match(/(?:^|\s)--max\s+([^\s]+)(?:\s|$)/);
|
||||
if (spaced) {
|
||||
const parsed = parseMax(spaced[1]);
|
||||
if (typeof parsed === "string") return { kind: "error", message: parsed };
|
||||
max = parsed;
|
||||
text = text.replace(spaced[0], " ").trim();
|
||||
} else if (/(?:^|\s)--max(?:\s|$)/.test(text)) {
|
||||
return { kind: "error", message: '--max requires a positive integer (e.g. "--max 40")' };
|
||||
}
|
||||
}
|
||||
|
||||
text = text.trim();
|
||||
if (text === "") {
|
||||
return { kind: "error", message: "goal text required (verbs: stop, clear, resume)" };
|
||||
}
|
||||
return { kind: "set", text, max, ...(waitTimeoutSeconds === undefined ? {} : { waitTimeoutSeconds }) };
|
||||
}
|
||||
|
||||
function parseMax(raw: string): number | string {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isInteger(n) || n < 1 || String(n) !== raw) {
|
||||
return `--max must be a positive integer, got "${raw}"`;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Pure settle decision for the goal loop — extracted so the abort/error/cap
|
||||
// paths (AC4, AC5) are hermetically testable without a live agent run.
|
||||
|
||||
import { checkLimitReached, type GoalState } from "./state.ts";
|
||||
|
||||
export type SettleDecision = { action: "inject" } | { action: "wait" } | { action: "pause"; reason: string };
|
||||
|
||||
/**
|
||||
* Decide what the loop does when the agent settles while a goal is active.
|
||||
* stopReason comes from the last assistant message ("stop", "toolUse",
|
||||
* "aborted", "error", ...).
|
||||
*/
|
||||
export function decideSettle(state: GoalState, stopReason: string | undefined): SettleDecision {
|
||||
if (stopReason === "aborted") {
|
||||
return { action: "pause", reason: "paused: run aborted (Esc)" };
|
||||
}
|
||||
if (stopReason === "error") {
|
||||
return { action: "pause", reason: "paused: run error" };
|
||||
}
|
||||
if (state.waitTimeoutSeconds && state.activeWait && !state.activeWait.wakeSent) {
|
||||
return { action: "wait" };
|
||||
}
|
||||
if (checkLimitReached(state)) {
|
||||
return {
|
||||
action: "pause",
|
||||
reason: `paused: cap of ${state.maxChecks} checks reached without a report`,
|
||||
};
|
||||
}
|
||||
return { action: "inject" };
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// Pure goal state machine for the pi /goal extension.
|
||||
// No I/O, no pi imports — hermetically testable.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export type GoalStatus = "active" | "paused" | "blocked" | "none";
|
||||
export type GoalTerminalStatus = "complete";
|
||||
export type ProgressKind = "measurement" | "action" | "delegation" | "wait" | "non_tool";
|
||||
|
||||
export interface GoalTerminalOutcome {
|
||||
text: string;
|
||||
status: GoalTerminalStatus;
|
||||
evidence: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface ProgressDetails {
|
||||
kind: ProgressKind;
|
||||
/** Nearest incomplete acceptance or delivery gate. */
|
||||
gate: string;
|
||||
/** Current owner of that gate or external wait. */
|
||||
owner: string;
|
||||
/** Most recent live measurement, including what and when. */
|
||||
lastMeasurement: string;
|
||||
/** Next concrete action or check. */
|
||||
nextAction: string;
|
||||
/** Concrete artifact produced by legitimate non-tool work. */
|
||||
artifact?: string;
|
||||
/** Approved agent-watch identifier for a waiting cycle. */
|
||||
watchId?: string;
|
||||
/** Concrete condition that ends or rechecks a waiting cycle. */
|
||||
nextCheck?: string;
|
||||
}
|
||||
|
||||
export interface InProgressReport {
|
||||
evidence: string;
|
||||
progress?: ProgressDetails;
|
||||
}
|
||||
|
||||
export type InProgressClassification = "progress" | "waiting" | "no_progress";
|
||||
|
||||
export interface InProgressOutcome {
|
||||
state: GoalState;
|
||||
classification: InProgressClassification;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface GoalState {
|
||||
version: 1;
|
||||
text: string;
|
||||
status: GoalStatus;
|
||||
/** Check prompts injected since the last accepted progress or waiting report. */
|
||||
checks: number;
|
||||
/** Cap on consecutive checks without an accepted report (Q4: default 25, per-goal --max). */
|
||||
maxChecks: number;
|
||||
/** Consecutive empty, malformed, unsupported, or duplicate in_progress reports. */
|
||||
noProgressReports: number;
|
||||
/** Bounded report-only loop threshold. */
|
||||
maxNoProgressReports: number;
|
||||
/** A successful non-goal_report tool result is available to back one progress report. */
|
||||
workEventSinceReport: boolean;
|
||||
lastActivityTool?: string;
|
||||
lastActivityAt?: string;
|
||||
/** Digest only: do not persist free-form evidence in the state file. */
|
||||
lastProgressFingerprint?: string;
|
||||
/** Bounded next action shown in the next forced-check prompt. */
|
||||
lastNextAction?: string;
|
||||
/** Explicit wait state. Repeated reports keep it active but do not claim new progress. */
|
||||
activeWait?: {
|
||||
owner: string;
|
||||
watchId?: string;
|
||||
nextCheck?: string;
|
||||
deadlineAt?: number;
|
||||
/** Dispatch receipt; wakeObserved confirms before_agent_start, not task completion. */
|
||||
wakeSent?: boolean;
|
||||
wakeRequestId?: string;
|
||||
wakeDispatchedAt?: number;
|
||||
wakeObserved?: boolean;
|
||||
};
|
||||
/** Operator opt-in: suspend waits, with one deadline wake per goal/resume. */
|
||||
waitTimeoutSeconds?: number;
|
||||
waitWakeUsed?: boolean;
|
||||
setAt: string;
|
||||
/** Why the goal is paused or blocked: operator stop, abort, error, cap, no-progress, or blocker reason. */
|
||||
pausedReason?: string;
|
||||
/** Most recent completed goal, retained for status recall without keeping its loop active. */
|
||||
lastOutcome?: GoalTerminalOutcome;
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_CHECKS = 25;
|
||||
export const DEFAULT_MAX_NO_PROGRESS_REPORTS = 3;
|
||||
|
||||
/** Default cap for new goals; GOAL_MAX_CHECKS env overrides (invalid values fall back). */
|
||||
export function envDefaultMaxChecks(env: NodeJS.ProcessEnv = process.env): number {
|
||||
return positiveInteger(env.GOAL_MAX_CHECKS, DEFAULT_MAX_CHECKS);
|
||||
}
|
||||
|
||||
/** Report-only loop cap; intentionally separate from the no-report check cap. */
|
||||
export function envDefaultMaxNoProgressReports(env: NodeJS.ProcessEnv = process.env): number {
|
||||
return positiveInteger(env.GOAL_MAX_NO_PROGRESS_REPORTS, DEFAULT_MAX_NO_PROGRESS_REPORTS);
|
||||
}
|
||||
|
||||
function positiveInteger(raw: string | undefined, fallback: number): number {
|
||||
if (!raw) return fallback;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(n) && n >= 1 ? n : fallback;
|
||||
}
|
||||
|
||||
export function initialState(
|
||||
maxChecks: number = envDefaultMaxChecks(),
|
||||
maxNoProgressReports: number = envDefaultMaxNoProgressReports(),
|
||||
): GoalState {
|
||||
return {
|
||||
version: 1,
|
||||
text: "",
|
||||
status: "none",
|
||||
checks: 0,
|
||||
maxChecks,
|
||||
noProgressReports: 0,
|
||||
maxNoProgressReports,
|
||||
workEventSinceReport: false,
|
||||
setAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function setGoal(state: GoalState, text: string, maxChecks?: number, waitTimeoutSeconds?: number): GoalState {
|
||||
return {
|
||||
...initialState(maxChecks ?? envDefaultMaxChecks(), envDefaultMaxNoProgressReports()),
|
||||
text,
|
||||
...(waitTimeoutSeconds === undefined ? {} : { waitTimeoutSeconds, waitWakeUsed: false }),
|
||||
status: "active",
|
||||
setAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function pauseGoal(state: GoalState, reason: string): GoalState {
|
||||
if (state.status !== "active") return state;
|
||||
return { ...state, status: "paused", pausedReason: reason };
|
||||
}
|
||||
|
||||
export function blockGoal(state: GoalState, reason: string): GoalState {
|
||||
if (state.status !== "active") return state;
|
||||
return { ...state, status: "blocked", pausedReason: reason };
|
||||
}
|
||||
|
||||
export function resumeGoal(state: GoalState): GoalState {
|
||||
if (state.status !== "paused" && state.status !== "blocked") return state;
|
||||
return {
|
||||
...state,
|
||||
status: "active",
|
||||
checks: 0,
|
||||
noProgressReports: 0,
|
||||
workEventSinceReport: false,
|
||||
pausedReason: undefined,
|
||||
activeWait: state.waitTimeoutSeconds ? undefined : state.activeWait,
|
||||
waitWakeUsed: state.waitTimeoutSeconds ? false : state.waitWakeUsed,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearGoal(state: GoalState): GoalState {
|
||||
return { ...initialState(state.maxChecks, state.maxNoProgressReports) };
|
||||
}
|
||||
|
||||
export function completeGoal(state: GoalState, evidence: string, at: string = new Date().toISOString()): GoalState {
|
||||
const cleared = initialState(state.maxChecks, state.maxNoProgressReports);
|
||||
return {
|
||||
...cleared,
|
||||
lastOutcome: {
|
||||
text: state.text,
|
||||
status: "complete",
|
||||
evidence: bounded(evidence, 1000),
|
||||
at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Terminal reports retain the original reset behavior. */
|
||||
export function recordReport(state: GoalState): GoalState {
|
||||
return {
|
||||
...state,
|
||||
checks: 0,
|
||||
noProgressReports: 0,
|
||||
workEventSinceReport: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Record one successful tool result that may back exactly one progress report. */
|
||||
export function recordWorkEvent(state: GoalState, toolName: string, at: string = new Date().toISOString()): GoalState {
|
||||
if (state.status !== "active") return state;
|
||||
return {
|
||||
...state,
|
||||
workEventSinceReport: true,
|
||||
lastActivityTool: bounded(toolName, 80),
|
||||
lastActivityAt: at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an in_progress report mechanically.
|
||||
*
|
||||
* Tool-backed progress requires a successful work event. Non-tool work requires
|
||||
* a concrete artifact. Waits require an owner plus a watch id or next-check
|
||||
* condition. Duplicate evidence is never progress. Empty/malformed/unsupported
|
||||
* reports increment a persisted counter and pause at the bound.
|
||||
*/
|
||||
export function recordInProgressReport(state: GoalState, report: InProgressReport, now: number = Date.now()): InProgressOutcome {
|
||||
if (state.status !== "active") {
|
||||
return { state, classification: "no_progress", reason: "goal is not active" };
|
||||
}
|
||||
|
||||
const evidence = report.evidence.trim();
|
||||
const progress = normalizeProgress(report.progress);
|
||||
if (evidence === "" || !progress) {
|
||||
return recordNoProgress(state, evidence === "" ? "empty evidence" : "missing structured progress");
|
||||
}
|
||||
const missing = requiredProgressField(progress);
|
||||
if (missing) return recordNoProgress(state, `missing ${missing}`);
|
||||
|
||||
const fingerprint = progressFingerprint(evidence, progress);
|
||||
if (progress.kind === "wait") {
|
||||
if (!progress.watchId && !progress.nextCheck) {
|
||||
return recordNoProgress(state, "wait requires an approved watch id or concrete next-check condition");
|
||||
}
|
||||
if (state.waitTimeoutSeconds && state.waitWakeUsed) {
|
||||
return {
|
||||
state: pauseGoal(state, "wait deadline wake exhausted; resolve the dependency before /goal resume"),
|
||||
classification: "no_progress",
|
||||
reason: "one automatic deadline wake per goal/resume has already been used",
|
||||
};
|
||||
}
|
||||
const sameWait = state.lastProgressFingerprint === fingerprint;
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
checks: 0,
|
||||
workEventSinceReport: false,
|
||||
lastProgressFingerprint: fingerprint,
|
||||
lastNextAction: bounded(progress.nextAction, 240),
|
||||
activeWait: {
|
||||
...(state.waitTimeoutSeconds ? {
|
||||
deadlineAt: state.activeWait?.deadlineAt ?? now + state.waitTimeoutSeconds * 1000,
|
||||
wakeSent: false,
|
||||
} : {}),
|
||||
owner: bounded(progress.owner, 120),
|
||||
watchId: progress.watchId ? bounded(progress.watchId, 120) : undefined,
|
||||
nextCheck: progress.nextCheck ? bounded(progress.nextCheck, 240) : undefined,
|
||||
},
|
||||
},
|
||||
classification: "waiting",
|
||||
reason: sameWait ? "approved wait remains active" : "approved wait recorded",
|
||||
};
|
||||
}
|
||||
|
||||
if (progress.kind === "non_tool") {
|
||||
if (!progress.artifact) return recordNoProgress(state, "non-tool work requires a concrete artifact");
|
||||
} else if (!state.workEventSinceReport) {
|
||||
return recordNoProgress(state, `${progress.kind} lacks a successful work event`);
|
||||
}
|
||||
|
||||
if (state.lastProgressFingerprint === fingerprint) {
|
||||
return recordNoProgress(state, "duplicate evidence");
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
checks: 0,
|
||||
noProgressReports: 0,
|
||||
workEventSinceReport: false,
|
||||
lastProgressFingerprint: fingerprint,
|
||||
lastNextAction: bounded(progress.nextAction, 240),
|
||||
activeWait: undefined,
|
||||
},
|
||||
classification: "progress",
|
||||
reason: `${progress.kind} recorded`,
|
||||
};
|
||||
}
|
||||
|
||||
function recordNoProgress(state: GoalState, reason: string): InProgressOutcome {
|
||||
const nextNoProgress = state.noProgressReports + 1; // NG8_COUNT_GATE
|
||||
let next: GoalState = {
|
||||
...state,
|
||||
noProgressReports: nextNoProgress,
|
||||
workEventSinceReport: false,
|
||||
};
|
||||
if (nextNoProgress >= state.maxNoProgressReports) { // NG8_PAUSE_GATE
|
||||
next = pauseGoal(next, `paused: no substantive progress after ${nextNoProgress} in_progress reports`);
|
||||
}
|
||||
return { state: next, classification: "no_progress", reason };
|
||||
}
|
||||
|
||||
function normalizeProgress(progress: ProgressDetails | undefined): ProgressDetails | undefined {
|
||||
if (!progress) return undefined;
|
||||
return {
|
||||
kind: progress.kind,
|
||||
gate: progress.gate?.trim() ?? "",
|
||||
owner: progress.owner?.trim() ?? "",
|
||||
lastMeasurement: progress.lastMeasurement?.trim() ?? "",
|
||||
nextAction: progress.nextAction?.trim() ?? "",
|
||||
artifact: progress.artifact?.trim() || undefined,
|
||||
watchId: progress.watchId?.trim() || undefined,
|
||||
nextCheck: progress.nextCheck?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function requiredProgressField(progress: ProgressDetails): string | undefined {
|
||||
if (!progress.gate) return "nearest gate";
|
||||
if (!progress.owner) return "gate owner";
|
||||
if (!progress.lastMeasurement) return "last live measurement";
|
||||
if (!progress.nextAction) return "next action";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function progressFingerprint(evidence: string, progress: ProgressDetails): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify({ evidence: evidence.trim(), progress }))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function bounded(value: string, max: number): string {
|
||||
return value.length <= max ? value : value.slice(0, max);
|
||||
}
|
||||
|
||||
/** True when the check cap is exhausted and the loop must auto-pause instead of injecting. */
|
||||
export function checkLimitReached(state: GoalState): boolean {
|
||||
return state.checks >= state.maxChecks;
|
||||
}
|
||||
|
||||
/** Count one injected check prompt. Callers guard with checkLimitReached first. */
|
||||
export function recordCheckInjected(state: GoalState): GoalState {
|
||||
return { ...state, checks: state.checks + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* FR3: satisfied requires evidence, blocked requires a reason.
|
||||
* in_progress is intentionally accepted here so empty/malformed reports reach
|
||||
* the no-progress counter instead of being rejected before enforcement.
|
||||
*/
|
||||
export function validateReport(status: string, evidence: string | undefined): string | null {
|
||||
const e = (evidence ?? "").trim();
|
||||
if (status === "satisfied" && e === "") {
|
||||
return 'goal_report status "satisfied" requires non-empty evidence describing how the goal is met';
|
||||
}
|
||||
if (status === "blocked" && e === "") {
|
||||
return 'goal_report status "blocked" requires a non-empty reason';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Seat-durable goal state persistence (Q7b: survives /new, /resume, restarts).
|
||||
// The state file lives beside the pi agent home: $PI_CODING_AGENT_DIR/goal-state.json
|
||||
// for fleet seats, ~/.pi/agent/goal-state.json for operator sessions.
|
||||
//
|
||||
// NG-7 INCARNATION FENCING (Mercer F1, board T129): the seat-level file was
|
||||
// ONE shared mutable object — two incarnations of a seat (marcie + marcie-2)
|
||||
// inherited and rewrote each other's goal state on session_start. State is
|
||||
// now FENCED per process-launch incarnation identity (mosaic-core
|
||||
// lib/incarnation.ts — the same identity the R6 journal keys on; NOT
|
||||
// PI_SESSION_ID, which is session identity):
|
||||
//
|
||||
// <agentDir>/goal-state.<incarnationId>.json
|
||||
//
|
||||
// Migration (NG-7 remediation F4): an existing unfenced goal-state.json has
|
||||
// an UNKNOWN OWNER — for the live canary case it is legacy AND ACTIVE (a
|
||||
// previous incarnation's focus), and velma B1 forbids a fresh incarnation
|
||||
// inheriting another incarnation's active focus. So the legacy file is
|
||||
// QUARANTINED, never claimed: atomically renamed aside with its bytes
|
||||
// preserved (goal-state.legacy.json, then .legacy.1.json, .legacy.2.json…),
|
||||
// and the resolving incarnation starts FRESH. Nobody inherits an active
|
||||
// focus; nobody loses the evidence.
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { initialState, type GoalState } from "./state.ts";
|
||||
import { incarnationIdentity } from "../../mosaic-core/lib/incarnation.ts";
|
||||
|
||||
export const LEGACY_STATE_FILENAME = "goal-state.json";
|
||||
|
||||
/** Pi agent home for the current process (seat dir for fleet seats, ~/.pi/agent otherwise). */
|
||||
export function agentStateDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const dir = env.PI_CODING_AGENT_DIR;
|
||||
if (dir && dir.trim() !== "") return dir;
|
||||
return join(homedir(), ".pi", "agent");
|
||||
}
|
||||
|
||||
export function stateFilePath(dir: string = agentStateDir()): string {
|
||||
return join(dir, LEGACY_STATE_FILENAME);
|
||||
}
|
||||
|
||||
/** The incarnation-fenced state file for a given agent dir + incarnation. */
|
||||
export function fencedStateFilePath(dir: string, incarnationId: string): string {
|
||||
return join(dir, `goal-state.${incarnationId}.json`);
|
||||
}
|
||||
|
||||
export interface ResolveIO {
|
||||
existsSync(path: string): boolean;
|
||||
renameSync(from: string, to: string): void;
|
||||
}
|
||||
|
||||
function defaultResolveIO(): ResolveIO {
|
||||
return { existsSync, renameSync };
|
||||
}
|
||||
|
||||
/** First free quarantine slot for the unknown-owner legacy file. */
|
||||
export function quarantinePath(dir: string, io: ResolveIO): string {
|
||||
const first = join(dir, "goal-state.legacy.json");
|
||||
if (!io.existsSync(first)) return first;
|
||||
for (let n = 1; ; n++) {
|
||||
const candidate = join(dir, `goal-state.legacy.${n}.json`);
|
||||
if (!io.existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the state path for THIS process incarnation, quarantining any
|
||||
* unknown-owner legacy state (bytes preserved; the resolver starts fresh).
|
||||
* Inject `incarnationId` and `io` for tests; production callers take the
|
||||
* defaults.
|
||||
*/
|
||||
export function resolveStatePath(
|
||||
dir: string = agentStateDir(),
|
||||
opts: { incarnationId?: string; io?: ResolveIO } = {},
|
||||
): string {
|
||||
const io = opts.io ?? defaultResolveIO();
|
||||
const incarnationId = opts.incarnationId ?? incarnationIdentity();
|
||||
const fenced = fencedStateFilePath(dir, incarnationId);
|
||||
if (io.existsSync(fenced)) return fenced;
|
||||
const legacy = join(dir, LEGACY_STATE_FILENAME);
|
||||
if (io.existsSync(legacy)) {
|
||||
try {
|
||||
io.renameSync(legacy, quarantinePath(dir, io)); // atomic, bytes preserved
|
||||
} catch {
|
||||
// raced by a sibling incarnation or fs trouble: start fresh rather
|
||||
// than ever reading a file another incarnation may still rewrite
|
||||
return fenced;
|
||||
}
|
||||
}
|
||||
return fenced;
|
||||
}
|
||||
|
||||
export function loadState(path: string = stateFilePath()): GoalState {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<GoalState>;
|
||||
if (parsed?.version !== 1 || typeof parsed.text !== "string" || !parsed.status) {
|
||||
return initialState();
|
||||
}
|
||||
return normalize(parsed as GoalState);
|
||||
} catch {
|
||||
return initialState();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveState(state: GoalState, path: string = stateFilePath()): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const temporary = `${path}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", flag: "wx", mode: 0o600 });
|
||||
renameSync(temporary, path);
|
||||
} finally {
|
||||
if (existsSync(temporary)) unlinkSync(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(state: GoalState): GoalState {
|
||||
const defaults = initialState();
|
||||
const maxChecks =
|
||||
Number.isInteger(state.maxChecks) && state.maxChecks >= 1
|
||||
? state.maxChecks
|
||||
: defaults.maxChecks;
|
||||
const checks = Number.isInteger(state.checks) && state.checks >= 0 ? state.checks : 0;
|
||||
const maxNoProgressReports =
|
||||
Number.isInteger(state.maxNoProgressReports) && state.maxNoProgressReports >= 1
|
||||
? state.maxNoProgressReports
|
||||
: defaults.maxNoProgressReports;
|
||||
const noProgressReports =
|
||||
Number.isInteger(state.noProgressReports) && state.noProgressReports >= 0
|
||||
? state.noProgressReports
|
||||
: 0;
|
||||
const normalized: GoalState = {
|
||||
...defaults,
|
||||
...state,
|
||||
maxChecks,
|
||||
checks,
|
||||
maxNoProgressReports,
|
||||
noProgressReports,
|
||||
workEventSinceReport: state.workEventSinceReport === true,
|
||||
};
|
||||
const outcome = state.lastOutcome;
|
||||
delete normalized.lastOutcome;
|
||||
if (state.status === "none" && outcome?.status === "complete" &&
|
||||
typeof outcome.text === "string" && outcome.text.length > 0 &&
|
||||
typeof outcome.evidence === "string" && outcome.evidence.length > 0 &&
|
||||
typeof outcome.at === "string" && Number.isFinite(Date.parse(outcome.at))) {
|
||||
normalized.lastOutcome = { text: outcome.text, status: "complete", evidence: outcome.evidence.slice(0, 1000), at: outcome.at };
|
||||
}
|
||||
if (!["active", "paused", "blocked", "none"].includes(state.status)) {
|
||||
normalized.status = "paused";
|
||||
normalized.pausedReason = "invalid persisted status; inspect before /goal resume";
|
||||
}
|
||||
if (typeof normalized.pausedReason !== "string") delete normalized.pausedReason;
|
||||
if (state.waitTimeoutSeconds !== undefined) {
|
||||
const timeoutValid = Number.isInteger(state.waitTimeoutSeconds) && state.waitTimeoutSeconds >= 10 && state.waitTimeoutSeconds <= 86400;
|
||||
const wait = state.activeWait;
|
||||
const validTime = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= 8640000000000000;
|
||||
const deadlineValid = !wait || (validTime(wait.deadlineAt) && wait.deadlineAt <= Date.now() + 86400000);
|
||||
const flagsValid = [state.waitWakeUsed, wait?.wakeSent, wait?.wakeObserved].every(value => value === undefined || typeof value === "boolean");
|
||||
const dispatchValid = !wait?.wakeSent || (validTime(wait.wakeDispatchedAt) &&
|
||||
typeof wait.wakeRequestId === "string" && wait.wakeRequestId.length > 0 && state.waitWakeUsed === true);
|
||||
if (!timeoutValid || !deadlineValid || !dispatchValid || !flagsValid) {
|
||||
return { ...normalized, status: state.status === "none" ? "none" : "paused", pausedReason: "invalid persisted wait state; inspect before /goal resume" };
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = process.env.MOSAIC_CONTRACT_ROOT ?? join(HERE, "fixtures");
|
||||
|
||||
const proactive = readFileSync(join(ROOT, "skills-local", "ms-proactive-agent", "SKILL.md"), "utf8");
|
||||
const executive = readFileSync(join(ROOT, "skills-local", "ms-executive-update", "SKILL.md"), "utf8");
|
||||
const honesty = readFileSync(join(ROOT, "skills-local", "ms-honesty", "SKILL.md"), "utf8");
|
||||
|
||||
test("cross-seat waits require communication rather than agent-watch polling", () => {
|
||||
assert.equal(
|
||||
proactive.includes("A row waiting on someone else is not a candidate; it gets a watch"),
|
||||
false,
|
||||
"proactive contract must not contradict ms-agent-watch",
|
||||
);
|
||||
assert.match(proactive, /Use existing message delivery for another agent.s response/);
|
||||
assert.match(proactive, /Do not poll its private\s+files or pane/);
|
||||
assert.match(proactive, /external\s+conditions with no existing wake owner/);
|
||||
});
|
||||
|
||||
test("proactive turns close every communication obligation before reporting", () => {
|
||||
assert.match(proactive, /Close communication obligations/);
|
||||
for (const required of [
|
||||
"persist any required",
|
||||
"exact receipt",
|
||||
"return event",
|
||||
"escalation owner",
|
||||
"A report to the user is not delivery to another agent",
|
||||
]) {
|
||||
assert.equal(proactive.includes(required), true, `missing proactive closeout requirement: ${required}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("executive updates cannot replace direct owner communication", () => {
|
||||
assert.match(executive, /## Communication closeout before reporting/);
|
||||
assert.match(executive, /An executive update does not replace direct communication/);
|
||||
assert.match(executive, /A no-change line does not satisfy an unsent acknowledgement, handoff, review request,\s+blocker, or result/);
|
||||
});
|
||||
|
||||
test("honesty classifies communication state and routes access limits", () => {
|
||||
assert.match(honesty, /## Communication evidence/);
|
||||
assert.match(honesty, /Drafted is not sent\. Sent is not delivered\. Delivered is not acknowledged\. Acknowledged is not completed\./);
|
||||
assert.match(honesty, /A credential refusal is correct behavior, but it is not a terminal communication state/);
|
||||
assert.equal(
|
||||
honesty.includes("actually re-measured the thing you are waiting on, or a watch is armed to do so"),
|
||||
false,
|
||||
"another-seat waits must not inherit the stale remeasure-or-watch binary",
|
||||
);
|
||||
assert.match(
|
||||
honesty,
|
||||
/Another-seat and operator waits use the\s+recorded delivery receipt, required return event, escalation owner, and existing wake\s+path\. They do not require polling or an `agent-watch`\./,
|
||||
);
|
||||
assert.match(honesty, /External-condition waits require\s+a fresh measurement or a permitted watch\./);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { goalDisplay, goalDetails } from "../lib/display.ts";
|
||||
import { initialState, setGoal, pauseGoal, blockGoal, resumeGoal, completeGoal, clearGoal } from "../lib/state.ts";
|
||||
import { saveState, loadState } from "../lib/store.ts";
|
||||
|
||||
const text = "Long goal\n" + "Full acceptance criteria, never truncate. ".repeat(100) + "\nEND-OF-GOAL";
|
||||
|
||||
test("footer maps each lifecycle state to text and a theme role", () => {
|
||||
const active = setGoal(initialState(), text);
|
||||
for (const [state, label, color] of [
|
||||
[initialState(), "None", "muted"],
|
||||
[active, "Active", "accent"],
|
||||
[{ ...active, activeWait: { owner: "reviewer" } }, "Waiting", "warning"],
|
||||
[{ ...active, activeWait: { owner: "reviewer", wakeSent: true } }, "Active", "accent"],
|
||||
[pauseGoal(active, "operator stop"), "Paused", "warning"],
|
||||
[pauseGoal(active, "blocked: legacy reason"), "Blocked", "error"],
|
||||
[blockGoal(active, "missing permission"), "Blocked", "error"],
|
||||
[completeGoal(active, "verified"), "Complete", "success"],
|
||||
] as const) assert.deepEqual(goalDisplay(state), { label, color });
|
||||
});
|
||||
|
||||
test("full multiline goal survives completion, persistence, and read-only recall", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-display-"));
|
||||
try {
|
||||
const active = setGoal(initialState(), text);
|
||||
const complete = completeGoal(active, "verified ".repeat(300));
|
||||
assert.equal(complete.lastOutcome?.text, text);
|
||||
assert.equal(complete.lastOutcome?.evidence.length, 1000);
|
||||
assert.equal(complete.status, "none");
|
||||
assert.deepEqual(resumeGoal(complete), complete);
|
||||
const path = join(dir, "state.json");
|
||||
saveState(complete, path);
|
||||
assert.deepEqual(loadState(path), complete);
|
||||
for (const state of [active, blockGoal(active, "denied"), pauseGoal(active, "stop"), loadState(path)]) {
|
||||
const before = JSON.stringify(state);
|
||||
assert.ok(goalDetails(state).includes(text));
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
}
|
||||
assert.equal(goalDisplay(clearGoal(complete)).label, "None");
|
||||
assert.equal(setGoal(complete, "replacement").lastOutcome, undefined);
|
||||
assert.equal(resumeGoal(blockGoal(active, "denied")).status, "active");
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("old records and malformed outcome fields are safe to recall", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-display-"));
|
||||
const path = join(dir, "state.json");
|
||||
try {
|
||||
const old = pauseGoal(setGoal(initialState(), text), "blocked: legacy");
|
||||
saveState(old, path);
|
||||
assert.equal(goalDisplay(loadState(path)).label, "Blocked");
|
||||
for (const lastOutcome of [null, {}, { status: "complete", text: 123 }, { status: "complete", text, evidence: "yes", at: "invalid" }]) {
|
||||
saveState({ ...initialState(), lastOutcome } as any, path);
|
||||
assert.equal(goalDisplay(loadState(path)).label, "None");
|
||||
assert.doesNotThrow(() => goalDetails(loadState(path)));
|
||||
}
|
||||
saveState({ ...old, pausedReason: {} } as any, path);
|
||||
assert.doesNotThrow(() => goalDetails(loadState(path)));
|
||||
assert.doesNotThrow(() => goalDetails({ ...setGoal(initialState(), text), activeWait: { owner: "test", deadlineAt: 1e100 } }));
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// T165 WP1 parser conformance fixtures derived from the cited Machine contract.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { parseExecutiveUpdate, validateAttestedGoalReport } from "../lib/executive-update.ts";
|
||||
import type { GoalPolicyPublication } from "../../mosaic-core/lib/goal-policy.ts";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const CONTRACT = readFileSync(join(HERE, "fixtures", "skills-local", "ms-executive-update", "SKILL.md"), "utf8");
|
||||
const NO_CHANGE = "No change since T165; still waiting on T165.";
|
||||
|
||||
const UPDATE = [
|
||||
"Just Completed:",
|
||||
"",
|
||||
"* T165: completed contracts",
|
||||
"",
|
||||
"Next Step:",
|
||||
"",
|
||||
"* `fleet/extensions/goal/index.ts`: validate policy",
|
||||
"",
|
||||
"Blocked:",
|
||||
"",
|
||||
"* GOAL-PROACTIVE-LOOP-REV-Q107: review complete \u2014 nothing from you",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
function publication(resolver: GoalPolicyPublication["resolver"]): GoalPolicyPublication {
|
||||
return {
|
||||
attestation: Object.freeze({
|
||||
schemaVersion: 1,
|
||||
role: "plan-ng",
|
||||
roleRevision: 4,
|
||||
manifestSha256: "a".repeat(64),
|
||||
format: "ms-executive-update/v1",
|
||||
contractPath: "skills-local/ms-executive-update/SKILL.md",
|
||||
contractSection: "Machine contract (for `goal_report` payloads and any parser)",
|
||||
contractBlob: "df30c6fbb54b4a65a298c9e51c07f742610d171c",
|
||||
contractSha256: "bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319",
|
||||
enforcement: "pre-state-change-fail-closed",
|
||||
identifierResolution: "consumer-fail-closed",
|
||||
launchGeneration: 1,
|
||||
incarnationId: "inc-goal-parser",
|
||||
}),
|
||||
resolver,
|
||||
};
|
||||
}
|
||||
|
||||
function resolved(options: { changed?: boolean; evidence?: string | null; frozen?: boolean } = {}) {
|
||||
const value = {
|
||||
outcome: "resolved" as const,
|
||||
objectId: "tracked-object",
|
||||
objectSha256: "b".repeat(64),
|
||||
changedSincePreviousAcceptedReport: options.changed ?? true,
|
||||
completionEvidenceId: options.evidence === undefined ? "immutable-evidence" : options.evidence,
|
||||
};
|
||||
return options.frozen === false ? value : Object.freeze(value);
|
||||
}
|
||||
|
||||
test("C7: cited Machine contract remains the parser's one input authority", () => {
|
||||
assert.equal(CONTRACT.includes("## Machine contract (for `goal_report` payloads and any parser)"), true);
|
||||
assert.equal(CONTRACT.includes("update := section(\"Just Completed\")"), true);
|
||||
const parsed = parseExecutiveUpdate(UPDATE);
|
||||
assert.equal(parsed.ok, true, parsed.ok ? "" : parsed.reason);
|
||||
if (parsed.ok) {
|
||||
assert.deepEqual(parsed.items.map((item) => [item.section, item.token]), [
|
||||
["Just Completed", "T165"],
|
||||
["Next Step", "`fleet/extensions/goal/index.ts`"],
|
||||
["Blocked", "GOAL-PROACTIVE-LOOP-REV-Q107"],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("C7: heading, LF, identifier, bullet count, and Blocked disposition violations reject", () => {
|
||||
const tooMany = [
|
||||
"Just Completed:", "", "* none", "", "Next Step:", "",
|
||||
"* T1: one", "* T2: two", "* T3: three", "* T4: four", "* T5: five", "* T6: six",
|
||||
"", "Blocked:", "", "* T1: none \u2014 nothing from you", "",
|
||||
].join("\n");
|
||||
for (const invalid of [
|
||||
UPDATE.replace("Just Completed:", "just completed:"),
|
||||
UPDATE.replace("\n", "\r\n"),
|
||||
UPDATE.replace("T165", "task-165"),
|
||||
UPDATE.replace("\u2014 nothing from you", "waiting"),
|
||||
UPDATE.slice(0, -1),
|
||||
tooMany,
|
||||
]) {
|
||||
assert.equal(parseExecutiveUpdate(invalid).ok, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("C8: zero, multiple, unavailable, stale, malformed, and unproven completions reject", async () => {
|
||||
for (const outcome of ["zero", "multiple", "unavailable", "stale"] as const) {
|
||||
const result = await validateAttestedGoalReport(UPDATE, publication(async () => Object.freeze({ outcome })), "in_progress");
|
||||
assert.equal(result.ok, false, outcome);
|
||||
}
|
||||
assert.equal((await validateAttestedGoalReport(UPDATE, publication(async () => resolved({ frozen: false }) as never), "in_progress")).ok, false);
|
||||
assert.equal((await validateAttestedGoalReport(UPDATE, publication(async () => resolved({ evidence: null })), "in_progress")).ok, false);
|
||||
assert.equal((await validateAttestedGoalReport(UPDATE, publication(async () => resolved({ changed: false })), "in_progress")).ok, false);
|
||||
assert.equal((await validateAttestedGoalReport(UPDATE, publication(async () => resolved()), "in_progress")).ok, true);
|
||||
});
|
||||
|
||||
test("Q118/F5: contract-authorized no-change form is status-aware and requires unchanged tracked state", async () => {
|
||||
const parsed = parseExecutiveUpdate(NO_CHANGE);
|
||||
assert.equal(parsed.ok, true);
|
||||
if (parsed.ok) {
|
||||
assert.equal(parsed.kind, "no-change");
|
||||
assert.deepEqual(parsed.items.map((item) => [item.section, item.token]), [["Next Step", "T165"], ["Next Step", "T165"]]);
|
||||
}
|
||||
|
||||
let resolverCalls = 0;
|
||||
const unchanged = publication(async () => {
|
||||
resolverCalls += 1;
|
||||
return resolved({ changed: false, evidence: null });
|
||||
});
|
||||
assert.equal((await validateAttestedGoalReport(NO_CHANGE, unchanged, "in_progress")).ok, true);
|
||||
assert.equal(resolverCalls, 2, "both no-change references resolve through the attested consumer");
|
||||
|
||||
resolverCalls = 0;
|
||||
const terminal = await validateAttestedGoalReport(NO_CHANGE, unchanged, "satisfied");
|
||||
assert.equal(terminal.ok, false, "no-change is never a terminal report");
|
||||
assert.equal(resolverCalls, 0, "wrong status rejects before resolver or Goal state mutation");
|
||||
|
||||
assert.equal((await validateAttestedGoalReport(NO_CHANGE, publication(async () => resolved({ changed: true })), "in_progress")).ok, false);
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
// Hermetic NG-7 tests: goal-state incarnation fencing (Mercer F1 class).
|
||||
// Run: node --test test/fencing.test.ts
|
||||
//
|
||||
// SAFETY (NG7-SAFETY-V1W2): every arm uses an ISOLATED temp directory —
|
||||
// no live seat state, no Mercer/Marcie/marcie-2 files, no running sessions.
|
||||
// The extension source edit is confined to store resolution (store.ts) and
|
||||
// the one-line index.ts wiring.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
resolveStatePath,
|
||||
fencedStateFilePath,
|
||||
loadState,
|
||||
saveState,
|
||||
stateFilePath,
|
||||
} from "../lib/store.ts";
|
||||
import { setGoal, pauseGoal, initialState } from "../lib/state.ts";
|
||||
|
||||
function fixtureDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "goal-fence-"));
|
||||
}
|
||||
|
||||
function writeLegacy(dir: string, text: string): void {
|
||||
writeFileSync(stateFilePath(dir), JSON.stringify(setGoal(initialState(), text), null, 2) + "\n");
|
||||
}
|
||||
|
||||
// ---- (a) hostile two-incarnation collision: the measured class ---------------
|
||||
|
||||
test("(a) two incarnations of one seat never share or clobber state", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
const a = resolveStatePath(dir, { incarnationId: "inc-marcie" });
|
||||
const b = resolveStatePath(dir, { incarnationId: "inc-marcie-2" });
|
||||
assert.notEqual(a, b, "distinct incarnations must resolve distinct files");
|
||||
|
||||
// incarnation A sets an active goal
|
||||
const stateA = setGoal(initialState(), "ship the canary");
|
||||
saveState(stateA, a);
|
||||
// incarnation B (a second process of the SAME seat) loads ITS state
|
||||
const stateB = loadState(b);
|
||||
assert.equal(stateB.status, "none", "B must not inherit A's active focus");
|
||||
assert.equal(stateB.text, "", "B sees no goal text of A's");
|
||||
// B sets its own goal; A's file is untouched
|
||||
saveState(setGoal(initialState(), "B's own goal"), b);
|
||||
assert.equal(loadState(a).text, "ship the canary", "A's focus survives B's activity");
|
||||
assert.equal(loadState(b).text, "B's own goal");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("(a) B cannot pause or settle A's focus through any store operation", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
const a = resolveStatePath(dir, { incarnationId: "inc-a" });
|
||||
const b = resolveStatePath(dir, { incarnationId: "inc-b" });
|
||||
saveState(setGoal(initialState(), "A active"), a);
|
||||
|
||||
// Every mutation B performs addresses b's path only; A's bytes cannot
|
||||
// change through B's store API — proven by writing from B and re-reading A.
|
||||
saveState(pauseGoal(loadState(b), "blocked:sibling", "cap"), b);
|
||||
const aNow = loadState(a);
|
||||
assert.equal(aNow.status, "active", "A's focus is still active after B's pause");
|
||||
assert.equal(aNow.pausedReason, undefined);
|
||||
|
||||
// And the directory contains exactly the two fenced files.
|
||||
const names = readdirSync(dir).filter((n) => n.startsWith("goal-state"));
|
||||
assert.equal(names.length, 2);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("(a) session_start-style reload stays incarnation-scoped", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
const a = resolveStatePath(dir, { incarnationId: "inc-a" });
|
||||
saveState(setGoal(initialState(), "A goal v1"), a);
|
||||
// simulate: A's session reloads (new/resume/reload all re-load from ITS path)
|
||||
const reloaded = loadState(resolveStatePath(dir, { incarnationId: "inc-a" }));
|
||||
assert.equal(reloaded.text, "A goal v1");
|
||||
// a sibling's state never leaks into A's reload
|
||||
saveState(setGoal(initialState(), "sibling"), resolveStatePath(dir, { incarnationId: "inc-sib" }));
|
||||
assert.equal(loadState(resolveStatePath(dir, { incarnationId: "inc-a" })).text, "A goal v1");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- (b) legacy migration fails safe -------------------------------------------
|
||||
|
||||
test("(b) legacy unfenced state is QUARANTINED, never claimed (NG-7 F4)", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
writeLegacy(dir, "legacy active goal");
|
||||
const a = resolveStatePath(dir, { incarnationId: "inc-first" });
|
||||
// FAIL SAFE: the first incarnation must NOT inherit the unknown owner's
|
||||
// ACTIVE focus — it starts fresh.
|
||||
const state = loadState(a);
|
||||
assert.equal(state.status, "none", "no active focus inherited from legacy");
|
||||
assert.equal(state.text, "");
|
||||
// EVIDENCE PRESERVED: the legacy bytes live on in the quarantine file.
|
||||
const quarantined = JSON.parse(readFileSync(join(dir, "goal-state.legacy.json"), "utf8")) as { text: string };
|
||||
assert.equal(quarantined.text, "legacy active goal");
|
||||
const names = readdirSync(dir);
|
||||
assert.equal(names.includes("goal-state.json"), false, "legacy name vacated");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("(b/F4) ACTIVE legacy + fresh incarnation: no active focus inherited, evidence preserved", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
// the live canary shape: legacy state carrying an ACTIVE focus of a
|
||||
// previous (unknown-owner) incarnation
|
||||
writeLegacy(dir, "mercer's previous focus");
|
||||
for (const inc of ["inc-canary-1", "inc-canary-2"]) {
|
||||
const p = resolveStatePath(dir, { incarnationId: inc });
|
||||
assert.equal(loadState(p).status, "none", `${inc} inherits no active focus`);
|
||||
}
|
||||
// second quarantine slot when another legacy reappears (counter path)
|
||||
writeLegacy(dir, "second legacy");
|
||||
resolveStatePath(dir, { incarnationId: "inc-third" });
|
||||
assert.equal(
|
||||
readFileSync(join(dir, "goal-state.legacy.1.json"), "utf8").includes("second legacy"),
|
||||
true,
|
||||
"counter slot preserves the second legacy",
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("(b/F3) migration race: rename attempted with legacy present, failure -> fresh", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
writeLegacy(dir, "legacy goal");
|
||||
let renameCalls = 0;
|
||||
const path = resolveStatePath(dir, {
|
||||
incarnationId: "inc-raced",
|
||||
io: {
|
||||
// sequenced: fenced absent (first probe), legacy present (second)
|
||||
existsSync: (p: string) => p === stateFilePath(dir),
|
||||
renameSync: () => {
|
||||
renameCalls += 1;
|
||||
throw new Error("EEXIST: sibling quarantined it first");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(path, fencedStateFilePath(dir, "inc-raced"));
|
||||
assert.equal(renameCalls, 1, "the rename branch EXECUTED (not the fenced early-return)");
|
||||
assert.equal(loadState(path).status, "none", "race loses to fresh");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("(b/F2) traversal-shaped launcher claim is rejected at the identity source", async () => {
|
||||
const { incarnationIdentity, resetIncarnationGlobal } = await import("../../mosaic-core/lib/incarnation.ts");
|
||||
const prev = process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
resetIncarnationGlobal();
|
||||
try {
|
||||
for (const hostile of ["../../etc/pwn", "..", "/abs", "a/b", ".hidden", "x".repeat(200)]) {
|
||||
process.env.MOSAIC_LAUNCH_INCARNATION = hostile;
|
||||
const id = incarnationIdentity(() => `minted-for-${hostile.slice(0, 3)}`);
|
||||
assert.notEqual(id, hostile, `claim ${JSON.stringify(hostile)} must not become the identity`);
|
||||
assert.equal(id.startsWith("minted-for-"), true, `falls through to mint for ${JSON.stringify(hostile)}`);
|
||||
resetIncarnationGlobal();
|
||||
}
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
else process.env.MOSAIC_LAUNCH_INCARNATION = prev;
|
||||
resetIncarnationGlobal();
|
||||
}
|
||||
});
|
||||
|
||||
test("(b/F2) hostile claim keeps BOTH consumer paths inside the state root", async () => {
|
||||
const { createJournal } = await import("../../mosaic-core/lib/journal.ts");
|
||||
const { incarnationIdentity, resetIncarnationGlobal } = await import("../../mosaic-core/lib/incarnation.ts");
|
||||
const prev = process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
resetIncarnationGlobal();
|
||||
try {
|
||||
process.env.MOSAIC_LAUNCH_INCARNATION = "../../outside";
|
||||
const id = incarnationIdentity(); // no-arg production form (F1)
|
||||
assert.notEqual(id, "../../outside");
|
||||
const j = createJournal({ incarnationId: id, stateHome: "/xdg/state", io: { mkdirSync() {}, appendFileSync() {} } });
|
||||
assert.equal(j.path.startsWith("/xdg/state/mosaic-core/"), true, "journal contained");
|
||||
assert.equal(j.path.includes(".."), false);
|
||||
const dir = fixtureDir();
|
||||
const gp = resolveStatePath(dir, { incarnationId: id });
|
||||
assert.equal(gp.startsWith(dir), true, "goal path contained");
|
||||
assert.equal(gp.includes(".."), false);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
else process.env.MOSAIC_LAUNCH_INCARNATION = prev;
|
||||
resetIncarnationGlobal();
|
||||
}
|
||||
});
|
||||
|
||||
test("(b) corrupt legacy never blocks fencing", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
writeFileSync(stateFilePath(dir), "{corrupt");
|
||||
const a = resolveStatePath(dir, { incarnationId: "inc-x" });
|
||||
assert.equal(loadState(a).status, "none", "corrupt legacy -> fresh initial state");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- (c) PI_SESSION_ID is never the incarnation --------------------------------
|
||||
|
||||
test("(c) fenced paths key on the process-launch identity, not PI_SESSION_ID", async () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
const prev = process.env.PI_SESSION_ID;
|
||||
process.env.PI_SESSION_ID = "session-should-not-appear";
|
||||
const { incarnationIdentity } = await import("../../mosaic-core/lib/incarnation.ts");
|
||||
const id = incarnationIdentity(() => "minted-check");
|
||||
process.env.PI_SESSION_ID = prev === undefined ? "" : prev;
|
||||
if (prev === undefined) delete process.env.PI_SESSION_ID;
|
||||
assert.notEqual(id, "session-should-not-appear");
|
||||
const p = resolveStatePath(dir, { incarnationId: id });
|
||||
assert.doesNotMatch(p, /session-should-not-appear/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- compatibility ---------------------------------------------------------------
|
||||
|
||||
test("goal_report compatibility: the state machine surface is unchanged", async () => {
|
||||
// The pure state machine (setGoal/pauseGoal/resume/recordReport/validateReport)
|
||||
// is untouched by fencing; the existing goal suite covers it. Here we pin
|
||||
// the compatibility contract fencing must not break: a state saved and
|
||||
// loaded through the fenced path round-trips exactly as the legacy path did.
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
const p = resolveStatePath(dir, { incarnationId: "inc-compat" });
|
||||
const s = setGoal(initialState(), "round trip");
|
||||
saveState(s, p);
|
||||
assert.deepEqual(loadState(p), s);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
skills-local/**/SKILL.md whitespace=-trailing-space
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
name: ms-executive-update
|
||||
description: "Use for every user-bound status update, progress report, and at the end of a user-originated operation."
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# Executive Update
|
||||
|
||||
The operator reads status to decide what to do next, not to relive the work. An update is
|
||||
three short lists derived from the tracking files, not from memory. If an item is not in a
|
||||
tracking file, it is not done — write the file first, then report.
|
||||
|
||||
Applies to every agent reporting upward: a seat to its orchestrator, an orchestrator to the
|
||||
operator, a `/goal` loop reporting through `goal_report`, a sub-agent returning to its parent.
|
||||
|
||||
## When to use
|
||||
|
||||
- Any time you would otherwise narrate progress: after a task lands, after a fleet event
|
||||
(monitor tick, agent message, review verdict, CI result) changes tracked state, at the end
|
||||
of a turn with work in flight, or when asked "status", "update", "where are we".
|
||||
- Every `goal_report` call: the `evidence` (satisfied) or `reason` (blocked / in_progress)
|
||||
payload is an executive update in this format, so the operator sees the same shape from a
|
||||
goal loop as from a conversation.
|
||||
- Do **not** emit a full update when nothing changed. One line with
|
||||
`No change since <anchor>; still waiting on <item>.` is the whole reply.
|
||||
|
||||
## Communication closeout before reporting
|
||||
|
||||
An executive update does not replace direct communication. Before writing the update:
|
||||
|
||||
1. Reply to every inbound actionable message with the action taken, routed owner, or
|
||||
precise blocker.
|
||||
2. Send every new tasking, handoff, review request, blocker, result, correction, and
|
||||
decision request directly to the responsible seat or operator through the required
|
||||
path in `docs/guides/FLEET-COMMS.md`.
|
||||
3. Commit the durable artifact before sending its pointer when the communication needs to
|
||||
survive a pane or session.
|
||||
4. Record the destination, request or task id, exact delivery receipt, required return
|
||||
event, and escalation owner in the tracking file.
|
||||
|
||||
A no-change line does not satisfy an unsent acknowledgement, handoff, review request,
|
||||
blocker, or result. Neither does a `goal_report`, lane edit, board row, or operator-facing
|
||||
reply. Finish communication closeout first, then report the resulting state.
|
||||
|
||||
## Format: exact, no preamble, no epilogue
|
||||
|
||||
``` markdown
|
||||
Just Completed:
|
||||
* <item>: short description
|
||||
|
||||
Next Step:
|
||||
* <item>: short description
|
||||
|
||||
Blocked:
|
||||
* <item>: short description
|
||||
```
|
||||
|
||||
- `<item>` is the tracked identifier: task id (`T158`), ledger row (`E6`), PR (`#1491`),
|
||||
review marker, file, or commit. Never a vague noun ("the fix").
|
||||
- One line per bullet. ≤5 bullets per section; if you have more, the lower ones belong in
|
||||
the ledger, not the update. Keep all three headings; write `* none` when a section is empty.
|
||||
- Paths as clickable links; commits and SHAs short (8 chars).
|
||||
- Only items whose state **changed since your previous update** go under Just Completed.
|
||||
Next Step lists what happens next and who does it. Blocked names the blocker, who
|
||||
unblocks it, and what (if anything) is needed from the reader.
|
||||
|
||||
## Source of truth — mandatory before writing
|
||||
|
||||
Read, in this order, whatever applies to the work in flight; the update is a projection of
|
||||
these files (paths relative to the brain, `$MOSAIC_BRAIN_HOME`, per `docs/STRUCTURE-CANON.md`):
|
||||
|
||||
1. Lane ledger: `fleet/lanes/<lane>/TASKS.md` (open rows, dated entries) and
|
||||
`TASKS-DONE.md` (what moved to done).
|
||||
2. Seat files for every agent involved: `fleet/agents/<seat>/TASKS.md`, `STATE.md`,
|
||||
`work/` artifacts (review verdicts, plans, evidence records).
|
||||
3. Board: `fleet/board/MASTER-TASKS.md` and `fleet/board/taskings/<date>_T<n>_*.md` for
|
||||
task ids, owners, and reassignments.
|
||||
4. Project-level `docs/TASKS.md` when the orchestrator protocol makes it the control plane.
|
||||
5. Live evidence for anything claimed done: `git log` on the target branch/trunk, the PR,
|
||||
CI state, the seat's pane (per `docs/guides/FLEET-COMMS.md`), delivery receipts.
|
||||
|
||||
Rules:
|
||||
|
||||
- **Record, then report.** A state change learned from a pane, a monitor event, or a
|
||||
message is written into the owning tracking file (dated, with ids and SHAs) *before* it
|
||||
appears in the update. An item in the update with no tracking-file row is a defect.
|
||||
- **Evidence for "completed".** A Just Completed bullet cites its evidence: commit, merge
|
||||
SHA, verdict artifact path, receipt. "Delivered" is not "done"; "dispatched" is not
|
||||
"done"; a message sent is Next Step for the recipient, not a completion.
|
||||
- **Events are not the operator.** Never phrase a monitor event, agent message, or tool
|
||||
result as approval, confirmation, or a decision. Decisions come only from the operator's
|
||||
own messages.
|
||||
- **Blocked is precise.** `* T158: plan seat queued behind prior review — nothing from you`
|
||||
or `* Q96 gate: Decision needed — (1) … (2) …; recommend 1`. If the reader must decide,
|
||||
say so with the options and your recommendation; if not, say `nothing from you`.
|
||||
- **Honest state.** Distinguish known (read from a file or evidence) from inferred (seen in
|
||||
a pane, not yet recorded); mark inferred items `(unverified)`. Report failures with the
|
||||
output, skipped steps as skipped. Apply `ms-honesty`.
|
||||
- **Staleness anchor.** Include the anchor the update is current to when it matters:
|
||||
target head SHA, pipeline number, board commit, or timestamp.
|
||||
- **Delegation stays visible.** Work handed to a seat is reported with the seat and task
|
||||
id. The tracking row also carries the delivery receipt, required return event, and
|
||||
escalation owner. You own the follow-up until the seat reports back and the ledger
|
||||
reflects it.
|
||||
|
||||
## Machine contract (for `goal_report` payloads and any parser)
|
||||
|
||||
When an update is consumed by tooling (a `goal_report` payload, a broker, a coordinator),
|
||||
the loose human rules above tighten to this closed grammar. Human-facing replies may add
|
||||
Markdown links; payloads may not.
|
||||
|
||||
```
|
||||
update := section("Just Completed") NL section("Next Step") NL section("Blocked")
|
||||
section := HEADING ":" NL NL ( bullet+ | "* none" NL )
|
||||
HEADING := exact text, case-sensitive, in this order, each exactly once
|
||||
bullet := "* " item ": " text NL (1..5 per section; text is one line, no leading "-")
|
||||
item := taskid | pr | row | marker | sha | path
|
||||
taskid := "T" [0-9]+
|
||||
pr := "#" [0-9]+
|
||||
row := [A-Z]{1,4} "." ? [0-9]+ ("." [0-9]+)* e.g. E6, A7, GOV.5
|
||||
marker := [A-Z0-9]+ ("-" [A-Z0-9]+)* "-Q" [0-9]+ e.g. CPS-PRD-REV1-MERGE-GATE-Q96
|
||||
sha := [0-9a-f]{40} (payloads: full SHA; 8-char only in prose)
|
||||
path := "`" <repo-relative path, no spaces> "`"
|
||||
NL := "\n" (LF only; no trailing whitespace; no blank lines inside a section)
|
||||
```
|
||||
|
||||
- Exactly three sections; any other heading, a missing section, or a reordered section is a
|
||||
parse failure.
|
||||
- A bullet under Blocked ends with either `— nothing from you` or
|
||||
`— Decision needed: (1) … (2) …; recommend N`.
|
||||
- Identifier resolution is the consumer's job and is fail-closed: an item that resolves to
|
||||
zero or more than one tracked object (unknown task id, ambiguous short SHA, unknown marker)
|
||||
rejects the report before any state change; the producer gets the rejection and re-reports.
|
||||
- A one-line "no change" reply (`No change since <item>; still waiting on <item>.`) is a
|
||||
distinct message, not an update; a `goal_report` with status `in_progress` may carry it
|
||||
only when the tracked state is unchanged since the previous report.
|
||||
- This grammar is the format's single definition. Enforcement (who parses, what policy binds
|
||||
it to a role) is decided by the runtime's role manifests, not by this skill.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Narrative paragraphs, "I'll continue by…", recaps of what the reader already knows.
|
||||
- Reporting from conversation memory while the ledger still says the old state.
|
||||
- Listing the same item under two sections.
|
||||
- Padding Just Completed with process ("read the file", "checked the pane").
|
||||
- Hiding a needed decision inside Next Step.
|
||||
- Treating the executive update as delivery to a seat that was never contacted directly.
|
||||
- Reporting a handoff without its receipt, return event, and escalation owner.
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
Just Completed:
|
||||
|
||||
* #1491: round-2 fix pushed at 3ae1411d; independent exact-head APPROVE (brain 0199a866); CI 3058 green
|
||||
* E6 ledger: gate seat result recorded as BLOCKED (capability), per roles/gate.md
|
||||
|
||||
Next Step:
|
||||
|
||||
* T158: plan seat designs the runtime gate profile, then provisioning → gate re-run
|
||||
* #1491: operation seat runs pr-merge.sh after the gate seat issues PASS
|
||||
|
||||
Blocked:
|
||||
|
||||
* T158: queued behind the plan seat's prior review — nothing from you
|
||||
```
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: ms-honesty
|
||||
description: "Load before answering, reporting, or ruling; must always apply."
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# Honesty
|
||||
|
||||
An agent that always has an answer is not a reliable agent. Training rewards a fluent,
|
||||
confident reply over a correct one, so the default reflex under pressure is to produce
|
||||
something that sounds like an answer even when nothing was measured. This skill replaces
|
||||
that reflex with a procedure. It applies to every reply, every report, every message to
|
||||
another seat, and every rule you put on a board.
|
||||
|
||||
The two operator rules this skill enforces:
|
||||
|
||||
- It is acceptable not to have an answer. "I do not know" followed by what would settle it
|
||||
is a complete, correct reply.
|
||||
- A statement carries its evidence with it. The reader must be able to tell, from the
|
||||
sentence alone, whether you measured it, read it, inferred it, or guessed.
|
||||
|
||||
## Where the pressure comes from
|
||||
|
||||
Recognize the moment. Each of these is a point where the reflex fires:
|
||||
|
||||
1. The reader asked a direct question and you have a plausible answer in memory.
|
||||
2. A number, an id, a path, or a name would make the sentence complete.
|
||||
3. A tool returned nothing, an error, or something you did not expect, and a reason
|
||||
suggests itself.
|
||||
4. A prior record (a document, a ledger row, your own earlier message) says the answer.
|
||||
5. The reply is almost done and one more fact would close it neatly.
|
||||
6. You are reporting upward and a clean "done" reads better than "partly done".
|
||||
|
||||
At each of these points, stop and classify the claim before writing it.
|
||||
|
||||
## The evidence ladder
|
||||
|
||||
Every factual claim sits on exactly one rung. Name the rung when it is not obvious from
|
||||
the sentence.
|
||||
|
||||
| Rung | Meaning | How to write it |
|
||||
|---|---|---|
|
||||
| Measured | You ran the command, read the file, called the API, in this session, and the output says this | State the fact and the source: "main tip is 0bbb997d (branches/main API, 13:05)" |
|
||||
| Read | A record says it (a ledger, a doc, a pane, a message from another seat); you did not confirm it live | "STATE.md says X"; "orch-01 reports X"; mark `(unverified)` in updates |
|
||||
| Inferred | Measured facts plus a reasoning step you can show | "X, so probably Y" with the step visible; never as a flat fact |
|
||||
| Recalled | It is in your context or training but not in this session's evidence | "I recall X; not measured" or leave it out |
|
||||
| Guessed | None of the above | Do not write it as a fact. Write "I do not know" and what would settle it |
|
||||
|
||||
Rules that follow:
|
||||
|
||||
- A number, id, SHA, path, version, or quote is Measured or it is not in the reply. A
|
||||
half-remembered value is worse than none because it looks the same as a real one.
|
||||
- A tool result you did not read is not Measured. Empty output, a 403 body, an exit code
|
||||
from the wrong process in a pipe, and a cached ref are the four ways a measurement lies;
|
||||
show the control (the way the check could have come out differently) before trusting a
|
||||
zero, a green, or an empty result.
|
||||
- A prior record, including one you wrote, is Read, not Measured, until you re-measure it.
|
||||
- Another agent's claim is Read. Repeating it does not promote it.
|
||||
- Inference is allowed and useful. It is dishonest only when the reasoning step is hidden
|
||||
and the conclusion is written as if measured.
|
||||
|
||||
## "I do not know" is a full answer
|
||||
|
||||
When the honest rung is Guessed, reply with these three parts and nothing else:
|
||||
|
||||
1. What you do not know, in one sentence.
|
||||
2. What would settle it: the command, the file, the person, or the access needed.
|
||||
3. Whether you can run that now. If yes, do it instead of writing the reply. If no, say
|
||||
what blocks it.
|
||||
|
||||
Do not pad it with a guess "in case it helps". A guess next to an honest "I do not know"
|
||||
gets read as the answer.
|
||||
|
||||
## Widening and narrowing
|
||||
|
||||
- Report the case you measured, not the class it belongs to. One host checked is one host.
|
||||
One pipeline read is one pipeline. Say "on 5788" not "on main".
|
||||
- Do not round up. "Mostly green", "should be fine", "looks like it worked" are not states.
|
||||
The states are: measured green, measured red, not measured.
|
||||
- Do not round down either. If something is done with evidence, say done. Hedging a real
|
||||
result is as misleading as inflating a weak one.
|
||||
- Delivered is not done. Dispatched is not done. Sent is not received. Merged is not
|
||||
deployed. Use the word for the state you have evidence of.
|
||||
|
||||
## Communication evidence
|
||||
|
||||
Drafted is not sent. Sent is not delivered. Delivered is not acknowledged. Acknowledged is not completed.
|
||||
|
||||
Name the exact state and its source. A delivery receipt proves only the outcome the
|
||||
wrapper reports. It does not prove the recipient read, accepted, or completed the work.
|
||||
`queued-draft` and `unverifiable` are not permission to retry outside the wrapper contract.
|
||||
A later durable artifact or direct reply can prove acknowledgement or completion.
|
||||
|
||||
Honesty is not silence. If another authorized seat can measure or perform the missing
|
||||
step, send it a direct request before reporting. Record the destination, request or task
|
||||
id, exact delivery receipt, required return event, and escalation owner. Do not use an
|
||||
operator-facing update as a substitute for contacting the responsible seat.
|
||||
|
||||
A credential refusal is correct behavior, but it is not a terminal communication state.
|
||||
Route the request to the authorized owner. Call the goal blocked only when no authorized
|
||||
route or other meaningful work exists.
|
||||
|
||||
## Retraction
|
||||
|
||||
When you find that something you already said was wrong:
|
||||
|
||||
1. Say it in the next message, first line, labeled: "Retraction: <what I said> was wrong
|
||||
because <what I now measured>."
|
||||
2. Correct every place it was recorded (STATE, ledger, board, the other seat's inbox), with
|
||||
the date, leaving the superseded text legible rather than deleting it.
|
||||
3. Do not soften it, explain it away, or bury it under new results.
|
||||
|
||||
A retraction costs one message. An uncorrected error costs every decision built on it.
|
||||
|
||||
## How this binds the other skills
|
||||
|
||||
- `ms-executive-update`: each Just Completed bullet cites Measured evidence (SHA, verdict
|
||||
path, receipt). A bullet whose evidence is only Read carries `(unverified)`. Blocked
|
||||
names what is not known and who can know it. Another-seat and operator waits use the
|
||||
recorded delivery receipt, required return event, escalation owner, and existing wake
|
||||
path. They do not require polling or an `agent-watch`. External-condition waits require
|
||||
a fresh measurement or a permitted watch. A no-change line names the applicable record.
|
||||
- `ms-proactive-agent`: step 1 (record) writes the rung with the fact. A Guessed or
|
||||
Recalled item is never a task candidate; measuring it is the candidate. Two cycles that
|
||||
produce identical evidence are reported as no progress, not as progress.
|
||||
- `ms-watch`: a watch condition is a measurement. Arm it with a control run that shows it
|
||||
can return false, and record what it reads, not what you hope it reads.
|
||||
- Rulings and reviews: a ruling states the evidence rung it stands on. "Ruled on the render
|
||||
argument alone; protection settings unmeasured from this seat" is a valid ruling. A
|
||||
ruling that hides an unmeasured input is not.
|
||||
|
||||
## Self-check before sending
|
||||
|
||||
Read your draft once more and ask, per sentence with a fact in it:
|
||||
|
||||
- Which rung is this on, and would the reader guess the same rung from my wording?
|
||||
- If this turns out false, can I point to the exact tool output that misled me? If there
|
||||
is no such output, the sentence is Recalled or Guessed and must be rewritten or cut.
|
||||
- Did I write "done", "verified", "confirmed", "green", or "fixed" anywhere without a
|
||||
source next to it?
|
||||
- Is there a number or id I did not read from a tool this session?
|
||||
- Did I answer the question that was asked, or a nearby question I could answer better?
|
||||
- Did this interaction create an acknowledgement, tasking, handoff, review request,
|
||||
blocker, result, correction, or decision request that I have not sent directly?
|
||||
- Did I name a communication state more strongly than its receipt proves?
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Filling a gap with the most likely value.
|
||||
- Reading a document (including your own STATE file) and reporting it as a measurement.
|
||||
- Treating a tool's silence, a 403 body, or an empty grep as a finding.
|
||||
- "Should be", "presumably", "I believe" used as a fact with a softener, rather than as a
|
||||
flag that leads to a measurement.
|
||||
- Answering "is it fixed?" with "a newer version is deployed".
|
||||
- Quietly correcting an earlier claim in a later message without labeling it a retraction.
|
||||
- Promoting another seat's report to fact by repeating it in your own voice.
|
||||
- Describing what was checked in general terms ("ran the checks") so a partial check reads
|
||||
as a full one.
|
||||
- Calling a task blocked after an access refusal without routing it to an authorized owner.
|
||||
- Reporting a handoff upward while never sending it to the responsible seat.
|
||||
|
||||
## Examples
|
||||
|
||||
Asked whether branch protection requires status checks, with only a 403 in hand:
|
||||
|
||||
```
|
||||
Not measured. This seat's token gets 403 on branch_protections, so the settings are
|
||||
unknown from here. An admin token, or Jason reading the repo settings page, would settle
|
||||
it. I can ask orch-01 to read it with the admin identity.
|
||||
```
|
||||
|
||||
Reporting a merge learned from another seat's pane:
|
||||
|
||||
```
|
||||
* #3171: merged per orch-01's pane (unverified); verifying via the PR API now
|
||||
```
|
||||
|
||||
then, after the API read:
|
||||
|
||||
```
|
||||
* #3171: merged at 0bbb997d, merged_by orch-01, reviews 381+382 at head 4ce167c3 (PR API, 13:05)
|
||||
```
|
||||
|
||||
Retracting:
|
||||
|
||||
```
|
||||
Retraction: "main has no required status contexts" was read from a 403 body, not from the
|
||||
protection settings. Protection is unmeasured from this seat. Corrected in STATE.md (12:10)
|
||||
and sent to orch-01; issue #3170 body corrected by orch-01.
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: ms-proactive-agent
|
||||
description: Continue an authorized Mosaic assignment through planning, execution, verification, and recovery while useful work remains. Use for ongoing work and continuation requests, not to turn a question or review into an open-ended mission.
|
||||
---
|
||||
|
||||
# Proactive Agent
|
||||
|
||||
Complete the authorized outcome, not just the first task. At each meaningful checkpoint,
|
||||
select and execute the next necessary action in the same turn while scope, authority,
|
||||
dependencies, and budget permit. A plan, task dispatch, or status report is not completion.
|
||||
|
||||
This skill governs work selection. [ms-goal](../ms-goal/SKILL.md) governs durable goal
|
||||
state, pause/resume, and completion; load it when establishing or recovering a continuing
|
||||
assignment. The dev Stack does not yet integrate the `/goal` extension. Neither skill
|
||||
creates a scheduler, background process, tool, or automatic follow-up turn.
|
||||
|
||||
## Establish execution context
|
||||
|
||||
Before writing state or choosing work:
|
||||
|
||||
1. Resolve the assignment from the user request, launch context, and applicable
|
||||
repository/mission instructions. Record agent identity, workspace root, project if
|
||||
declared, and execution/session identifier if supplied. Mark unavailable identifiers
|
||||
as unavailable; do not infer a workspace from the agent's identity.
|
||||
2. Locate the authoritative goal, tasks, approvals, and recovery checkpoint. Use existing
|
||||
declared paths. For example, this skill's source repository uses `docs/plans/CURRENT.md`
|
||||
for queued project work; an explicit user request can authorize a separate bounded task.
|
||||
Do not advance unrelated
|
||||
queued phases or overwrite their owner-controlled records. Fleet paths and a seat's
|
||||
general purpose are not default assignments.
|
||||
3. Check writable state locations and required tools. Discover communication, claim, and
|
||||
wake mechanisms when needed. Optional reporting or watch skills must not block ordinary
|
||||
local work. If a required capability is missing, record the precise limitation and
|
||||
continue independent authorized work where possible. If ms-goal is unavailable, use an
|
||||
existing authoritative goal protocol; do not fabricate its tools or claim durable
|
||||
continuation without accessible records.
|
||||
4. Identify ownership. Use the coordinator's claim operation when provided. A Markdown
|
||||
owner field is not a lock. Without enforced claims, work only under an established
|
||||
single-writer assignment; conflicting ownership stops the affected task.
|
||||
|
||||
Explicit scope and authorization persist. Do not ask again for routine actions already
|
||||
covered by them. Role capabilities constrain execution; they do not authorize a new
|
||||
mission, another owner's work, or new external communications. Preserve phase approvals,
|
||||
acceptance ownership, and budget limits. Silence, task creation, and elapsed time grant
|
||||
no additional authority.
|
||||
|
||||
## Run the loop
|
||||
|
||||
Run on assignment start/recovery, a substantive checkpoint, an actionable message, or a
|
||||
matching wake event. Bookkeeping tool results and reports do not recursively trigger it.
|
||||
|
||||
1. **Reconcile and record.** Read relevant current records and verify the live fact needed
|
||||
for the next action. Preserve unrelated changes. Resolve actions with unknown outcomes
|
||||
before retrying them. Persist changed task status, evidence, and obligations before
|
||||
reporting; do not rewrite unchanged state merely to manufacture activity.
|
||||
2. **Check control state.** Follow ms-goal. Paused or canceled work does not resume on an
|
||||
ordinary tool result, compaction, or unrelated message. Apply user steering promptly;
|
||||
a status question does not cancel work. Reconcile scope changes with remaining tasks
|
||||
before executing them.
|
||||
3. **Select useful work.** Finish an owned in-flight task that can proceed, then an owned
|
||||
blocker you can resolve, then ready assigned work. Claim other necessary work only
|
||||
within delegated assignment authority. Respect dependencies and explicit priority;
|
||||
prefer smaller reversible steps when candidates otherwise have equal priority.
|
||||
4. **Fill a planning gap.** If an acceptance criterion is unmet and no task covers it,
|
||||
decompose the authorized outcome into bounded tasks with dependencies and evidence
|
||||
requirements, record them, and take the first ready task. A new goal can start with an
|
||||
empty queue. Necessary fixes and verification belong in scope; unrelated improvements
|
||||
remain proposals. If decomposition exposes a new scope decision, route it and continue
|
||||
any independent work already authorized.
|
||||
5. **Execute and verify.** Take the task to a meaningful checkpoint. Run checks appropriate
|
||||
to its acceptance criteria, inspect the results, and correct in-scope failures. Persist
|
||||
a resumable checkpoint before a risky action, context limit, or handoff. Do not mark a
|
||||
task done because it was started, delegated, or merely produced an artifact.
|
||||
6. **Close communication obligations.** Use the procedure below. Update the goal record
|
||||
with the next action and actual evidence. If ready work remains, return to step 1 in
|
||||
this turn. Report significant results without making the report an artificial stop.
|
||||
7. **When nothing can run, classify honestly.** Verify satisfaction against acceptance
|
||||
criteria, not queue emptiness. Otherwise record waiting, paused, or blocked with the
|
||||
unresolved gate and exact resumption condition, using ms-goal.
|
||||
|
||||
## Failure and budget handling
|
||||
|
||||
- Diagnose failures before retrying. Retry transient failures only under the tool's
|
||||
documented bounded retry contract or with a concrete changed input. For an uncertain
|
||||
external side effect, reconcile its request/action identifier first.
|
||||
- Repeated actions producing no new task evidence require a different justified approach
|
||||
or a recorded blocker. An unchanged registered wait is not a failed attempt; it should
|
||||
yield instead of generating more reports.
|
||||
- Track actual usage when available; label estimates and unavailable measurements. Stay
|
||||
within explicit limits and reserve capacity for verification and a checkpoint. Budget
|
||||
exhaustion pauses work; it does not establish completion. Never reset usage by resuming
|
||||
or splitting a task. Ask for an extension only when one is actually needed.
|
||||
- If recording fails, preserve recoverable evidence and report the failure. Do not start
|
||||
further effects that require a durable record or claim they were checkpointed.
|
||||
|
||||
## Communication and waits
|
||||
|
||||
For authorized inbound work, record the change and acknowledge the sender through the
|
||||
available approved transport. A reaction or receipt alone does not require another reply.
|
||||
For authorized tasking, handoffs, review requests, or decisions, persist any required
|
||||
artifact before sending its pointer. Durable storage does not necessarily mean a Git
|
||||
commit; follow the repository's commit authority and workflow.
|
||||
|
||||
Record request/task id, destination and workspace, exact receipt, expected return event,
|
||||
and follow-up owner. Distinguish drafted, queued, delivered, acknowledged, and completed.
|
||||
Unknown delivery is not permission to resend; reconcile or follow the transport's retry
|
||||
contract. A report to the user is not delivery to another agent. If no transport is
|
||||
available, record the unsent request and route the missing capability to the user.
|
||||
|
||||
For a wait, record the last observation, condition that ends it, wake mechanism, and any
|
||||
deadline plus its escalation owner/action. Verify the mechanism exists. An owner name,
|
||||
file entry, or queued draft does not prove that another turn will occur.
|
||||
|
||||
- Use existing message delivery for another agent's response. Do not poll its private
|
||||
files or pane. Use an available coordinator's timeout mechanism for overdue requests.
|
||||
- Use ms-agent-watch only when installed, supported, and authorized, for external
|
||||
conditions with no existing wake owner. Verify a known-false control and the actual
|
||||
condition, distinguishing pending from errors; handle an already-met condition now.
|
||||
Verify registration, bind to the exact resource/version and destination, and record
|
||||
timeout/delivery behavior. Reuse valid existing watches and retire obsolete ones.
|
||||
- Without automatic wake support, a wait is explicitly **manual**: state who must send
|
||||
what reply or resume instruction. A deadline is checked on the next invocation unless
|
||||
an actual timer/coordinator owns it. Do not promise unattended escalation or resumption.
|
||||
|
||||
## Checkpoints and reporting
|
||||
|
||||
Persist current task, completed evidence, remaining acceptance gates, next concrete action,
|
||||
outstanding communication/wait records, ownership, budget, and uncertain outcomes in their
|
||||
authoritative records. Keep a compact checkpoint pointing to them; retain unresolved
|
||||
obligations and durable history regardless of checkpoint trimming.
|
||||
|
||||
Report what changed, its evidence, what happens next, and any decision needed. Use an
|
||||
installed reporting format only when the applicable environment requires it. No special
|
||||
reporting skill or goal_report tool is required in dev mode. Do not repeat unchanged
|
||||
reports unless asked or a scheduled check produces information that matters.
|
||||
|
||||
End normally only at verified satisfaction, explicit pause/cancellation, a genuine
|
||||
blocker, or a registered automatic/manual wait with no ready work. If execution is forced
|
||||
to end while work remains, checkpoint it as incomplete and name the actual resume path;
|
||||
never imply that writing `next` scheduled that action.
|
||||
|
||||
For bounded dev testing, use
|
||||
[ms-goal's execution checks](../ms-goal/references/execution-checks.md).
|
||||
@@ -0,0 +1,320 @@
|
||||
// Hermetic tests for the pi /goal extension pure logic (state, parse, settle, store).
|
||||
// Run: node --test test/goal.test.ts
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir, homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
checkLimitReached,
|
||||
clearGoal,
|
||||
DEFAULT_MAX_CHECKS,
|
||||
DEFAULT_MAX_NO_PROGRESS_REPORTS,
|
||||
envDefaultMaxChecks,
|
||||
envDefaultMaxNoProgressReports,
|
||||
initialState,
|
||||
pauseGoal,
|
||||
recordCheckInjected,
|
||||
recordReport,
|
||||
resumeGoal,
|
||||
setGoal,
|
||||
validateReport,
|
||||
} from "../lib/state.ts";
|
||||
import { parseGoalCommand } from "../lib/parse.ts";
|
||||
import { decideSettle } from "../lib/settle.ts";
|
||||
import { agentStateDir, loadState, saveState, stateFilePath } from "../lib/store.ts";
|
||||
|
||||
// ---------- state machine ----------
|
||||
|
||||
test("initialState is status none with default cap", () => {
|
||||
const s = initialState(25);
|
||||
assert.equal(s.status, "none");
|
||||
assert.equal(s.text, "");
|
||||
assert.equal(s.checks, 0);
|
||||
assert.equal(s.maxChecks, 25);
|
||||
assert.equal(s.noProgressReports, 0);
|
||||
assert.equal(s.maxNoProgressReports, 3);
|
||||
assert.equal(s.workEventSinceReport, false);
|
||||
assert.equal(DEFAULT_MAX_CHECKS, 25);
|
||||
assert.equal(DEFAULT_MAX_NO_PROGRESS_REPORTS, 3);
|
||||
});
|
||||
|
||||
test("setGoal activates with zeroed checks and optional cap", () => {
|
||||
const s = setGoal(initialState(25), "ship the thing");
|
||||
assert.equal(s.status, "active");
|
||||
assert.equal(s.text, "ship the thing");
|
||||
assert.equal(s.checks, 0);
|
||||
assert.equal(s.maxChecks, 25);
|
||||
|
||||
const capped = setGoal(initialState(25), "another", 7);
|
||||
assert.equal(capped.maxChecks, 7);
|
||||
});
|
||||
|
||||
test("pause only from active; keeps goal and reason", () => {
|
||||
const active = setGoal(initialState(25), "goal");
|
||||
const paused = pauseGoal(active, "stopped by operator");
|
||||
assert.equal(paused.status, "paused");
|
||||
assert.equal(paused.pausedReason, "stopped by operator");
|
||||
assert.equal(paused.text, "goal");
|
||||
assert.equal(pauseGoal(paused, "again"), paused); // idempotent when not active
|
||||
assert.equal(pauseGoal(initialState(25), "x").status, "none");
|
||||
});
|
||||
|
||||
test("resume only from paused; resets checks and clears reason", () => {
|
||||
const paused = pauseGoal(setGoal(initialState(25), "goal"), "cap");
|
||||
paused.checks = 9;
|
||||
paused.noProgressReports = 2;
|
||||
paused.workEventSinceReport = true;
|
||||
const resumed = resumeGoal(paused);
|
||||
assert.equal(resumed.status, "active");
|
||||
assert.equal(resumed.checks, 0);
|
||||
assert.equal(resumed.noProgressReports, 0);
|
||||
assert.equal(resumed.workEventSinceReport, false);
|
||||
assert.equal(resumed.pausedReason, undefined);
|
||||
assert.equal(resumeGoal(initialState(25)).status, "none");
|
||||
});
|
||||
|
||||
test("clearGoal returns to none", () => {
|
||||
const cleared = clearGoal(setGoal(initialState(25), "goal", 9));
|
||||
assert.equal(cleared.status, "none");
|
||||
assert.equal(cleared.text, "");
|
||||
assert.equal(cleared.maxChecks, 9);
|
||||
});
|
||||
|
||||
test("report resets consecutive checks; limit and injection counting interlock", () => {
|
||||
let s = setGoal(initialState(25), "goal", 3);
|
||||
assert.equal(checkLimitReached(s), false);
|
||||
s = recordCheckInjected(s);
|
||||
s = recordCheckInjected(s);
|
||||
assert.equal(checkLimitReached(s), false); // 2 of 3 injected, one slot left
|
||||
s = recordCheckInjected(s);
|
||||
assert.equal(checkLimitReached(s), true); // cap exhausted: auto-pause, no injection
|
||||
s = recordReport(s);
|
||||
assert.equal(checkLimitReached(s), false); // any report resets the counter
|
||||
});
|
||||
|
||||
// ---------- env default cap ----------
|
||||
|
||||
test("environment caps: valid overrides and invalid values fall back", () => {
|
||||
assert.equal(envDefaultMaxChecks({}), 25);
|
||||
assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "10" }), 10);
|
||||
assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "abc" }), 25);
|
||||
assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "0" }), 25);
|
||||
assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "" }), 25);
|
||||
assert.equal(envDefaultMaxNoProgressReports({}), 3);
|
||||
assert.equal(envDefaultMaxNoProgressReports({ GOAL_MAX_NO_PROGRESS_REPORTS: "5" }), 5);
|
||||
assert.equal(envDefaultMaxNoProgressReports({ GOAL_MAX_NO_PROGRESS_REPORTS: "0" }), 3);
|
||||
assert.equal(envDefaultMaxNoProgressReports({ GOAL_MAX_NO_PROGRESS_REPORTS: "bad" }), 3);
|
||||
});
|
||||
|
||||
// ---------- command parsing ----------
|
||||
|
||||
test("parse: verbs and status", () => {
|
||||
assert.deepEqual(parseGoalCommand(""), { kind: "status" });
|
||||
assert.deepEqual(parseGoalCommand(" "), { kind: "status" });
|
||||
assert.deepEqual(parseGoalCommand("stop"), { kind: "stop" });
|
||||
assert.deepEqual(parseGoalCommand("STOP"), { kind: "stop" });
|
||||
assert.deepEqual(parseGoalCommand("clear"), { kind: "clear" });
|
||||
assert.deepEqual(parseGoalCommand("resume"), { kind: "resume" });
|
||||
});
|
||||
|
||||
test("parse: set with plain text", () => {
|
||||
assert.deepEqual(parseGoalCommand("ship the thing"), { kind: "set", text: "ship the thing", max: undefined });
|
||||
assert.deepEqual(parseGoalCommand(" padded "), { kind: "set", text: "padded", max: undefined });
|
||||
});
|
||||
|
||||
test("parse: --max forms attach to set", () => {
|
||||
assert.deepEqual(parseGoalCommand("do it --max 5"), { kind: "set", text: "do it", max: 5 });
|
||||
assert.deepEqual(parseGoalCommand("--max 5 do it"), { kind: "set", text: "do it", max: 5 });
|
||||
assert.deepEqual(parseGoalCommand("do it --max=7"), { kind: "set", text: "do it", max: 7 });
|
||||
assert.deepEqual(parseGoalCommand("--max 40 write the spec"), {
|
||||
kind: "set",
|
||||
text: "write the spec",
|
||||
max: 40,
|
||||
});
|
||||
});
|
||||
|
||||
test("parse: --max errors", () => {
|
||||
assert.equal(parseGoalCommand("--max").kind, "error");
|
||||
assert.equal(parseGoalCommand("--max abc").kind, "error");
|
||||
assert.equal(parseGoalCommand("text --max 0").kind, "error");
|
||||
assert.equal(parseGoalCommand("text --max").kind, "error");
|
||||
assert.equal(parseGoalCommand("--max 5").kind, "error"); // no goal text
|
||||
assert.equal(parseGoalCommand("text --max 5.0").kind, "error");
|
||||
});
|
||||
|
||||
test("parse: text containing verbs is goal text, not a verb", () => {
|
||||
assert.deepEqual(parseGoalCommand("stop now"), { kind: "set", text: "stop now", max: undefined });
|
||||
});
|
||||
|
||||
test("setGoal without --max uses fresh default, not the previous goal's cap", () => {
|
||||
let s = setGoal(initialState(25), "first", 3);
|
||||
assert.equal(s.maxChecks, 3);
|
||||
s = clearGoal(s);
|
||||
s = setGoal(s, "second");
|
||||
assert.equal(s.maxChecks, 25); // per-goal --max: no leak from the capped goal
|
||||
s = setGoal(s, "third", 40);
|
||||
assert.equal(s.maxChecks, 40);
|
||||
s = setGoal(s, "fourth");
|
||||
assert.equal(s.maxChecks, 25);
|
||||
assert.equal(setGoal(initialState(25), "g", undefined).maxChecks, envDefaultMaxChecks({ GOAL_MAX_CHECKS: "" }));
|
||||
});
|
||||
|
||||
// ---------- settle decision (abort/error/cap pause paths, AC4 + AC5) ----------
|
||||
|
||||
test("settle: normal completion injects, abort and error pause", () => {
|
||||
const s = setGoal(initialState(25), "goal");
|
||||
assert.deepEqual(decideSettle(s, "toolUse"), { action: "inject" });
|
||||
assert.deepEqual(decideSettle(s, "stop"), { action: "inject" });
|
||||
assert.deepEqual(decideSettle(s, undefined), { action: "inject" });
|
||||
|
||||
const aborted = decideSettle(s, "aborted");
|
||||
assert.equal(aborted.action, "pause");
|
||||
assert.match((aborted as { reason: string }).reason, /aborted/);
|
||||
|
||||
const errored = decideSettle(s, "error");
|
||||
assert.equal(errored.action, "pause");
|
||||
assert.match((errored as { reason: string }).reason, /error/);
|
||||
});
|
||||
|
||||
test("settle: cap exhaustion pauses with cap reason", () => {
|
||||
const s = setGoal(initialState(25), "goal", 2);
|
||||
s.checks = 2;
|
||||
const d = decideSettle(s, "stop");
|
||||
assert.equal(d.action, "pause");
|
||||
assert.match((d as { reason: string }).reason, /cap of 2/);
|
||||
// an injected-check counter reset by a report makes the cap pass again
|
||||
assert.deepEqual(decideSettle(recordReport(s), "stop"), { action: "inject" });
|
||||
});
|
||||
|
||||
// ---------- report validation (FR3) ----------
|
||||
|
||||
test("validateReport: satisfied and blocked require evidence; in_progress does not", () => {
|
||||
assert.match(validateReport("satisfied", "")!, /evidence/);
|
||||
assert.match(validateReport("satisfied", " ")!, /evidence/);
|
||||
assert.match(validateReport("satisfied", undefined)!, /evidence/);
|
||||
assert.match(validateReport("blocked", "")!, /reason/);
|
||||
assert.match(validateReport("blocked", undefined)!, /reason/);
|
||||
assert.equal(validateReport("in_progress", ""), null);
|
||||
assert.equal(validateReport("satisfied", "counted to three"), null);
|
||||
assert.equal(validateReport("blocked", "missing credentials"), null);
|
||||
});
|
||||
|
||||
// ---------- store ----------
|
||||
|
||||
test("store: round-trip, missing file, corrupt file", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-test-"));
|
||||
try {
|
||||
const path = join(dir, "goal-state.json");
|
||||
assert.deepEqual(loadState(path), initialState()); // missing file -> initial
|
||||
|
||||
const s = setGoal(initialState(25), "durable goal", 11);
|
||||
s.checks = 4;
|
||||
s.noProgressReports = 2;
|
||||
s.workEventSinceReport = true;
|
||||
s.lastProgressFingerprint = "abc";
|
||||
s.lastNextAction = "measure gate";
|
||||
s.activeWait = { owner: "reviewer", watchId: "review-watch", nextCheck: "review arrives" };
|
||||
saveState(s, path);
|
||||
const loaded = loadState(path);
|
||||
assert.equal(loaded.text, "durable goal");
|
||||
assert.equal(loaded.status, "active");
|
||||
assert.equal(loaded.maxChecks, 11);
|
||||
assert.equal(loaded.checks, 4);
|
||||
assert.equal(loaded.noProgressReports, 2);
|
||||
assert.equal(loaded.workEventSinceReport, true);
|
||||
assert.equal(loaded.lastProgressFingerprint, "abc");
|
||||
assert.equal(loaded.lastNextAction, "measure gate");
|
||||
assert.equal(loaded.activeWait?.watchId, "review-watch");
|
||||
|
||||
writeFileSync(path, "{not json", "utf8");
|
||||
assert.deepEqual(loadState(path), initialState()); // corrupt -> initial
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("store: legacy version-1 active state gains NG8 defaults without losing focus", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-test-legacy-v1-"));
|
||||
try {
|
||||
const path = join(dir, "goal-state.legacy-v1.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
text: "preserve this active goal",
|
||||
status: "active",
|
||||
checks: 7,
|
||||
maxChecks: 19,
|
||||
setAt: "2026-08-30T00:00:00.000Z",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const state = loadState(path);
|
||||
assert.equal(state.text, "preserve this active goal");
|
||||
assert.equal(state.status, "active");
|
||||
assert.equal(state.checks, 7);
|
||||
assert.equal(state.maxChecks, 19);
|
||||
assert.equal(state.noProgressReports, 0);
|
||||
assert.equal(state.maxNoProgressReports, DEFAULT_MAX_NO_PROGRESS_REPORTS);
|
||||
assert.equal(state.workEventSinceReport, false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("store: normalize repairs bad counters", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-test-"));
|
||||
try {
|
||||
const path = join(dir, "goal-state.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
text: "g",
|
||||
status: "active",
|
||||
checks: -3,
|
||||
maxChecks: 0,
|
||||
noProgressReports: -9,
|
||||
maxNoProgressReports: 0,
|
||||
workEventSinceReport: "yes",
|
||||
setAt: "t",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const s = loadState(path);
|
||||
assert.equal(s.checks, 0);
|
||||
assert.equal(s.maxChecks, DEFAULT_MAX_CHECKS);
|
||||
assert.equal(s.noProgressReports, 0);
|
||||
assert.equal(s.maxNoProgressReports, DEFAULT_MAX_NO_PROGRESS_REPORTS);
|
||||
assert.equal(s.workEventSinceReport, false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("store: agent dir resolves from PI_CODING_AGENT_DIR with ~/.pi/agent fallback", () => {
|
||||
assert.equal(agentStateDir({ PI_CODING_AGENT_DIR: "/seat/.pi/agent" }), "/seat/.pi/agent");
|
||||
assert.equal(agentStateDir({}), join(homedir(), ".pi", "agent"));
|
||||
assert.equal(stateFilePath("/d"), join("/d", "goal-state.json"));
|
||||
});
|
||||
|
||||
test("store pauses invalid persisted bounded-wait configuration", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-wait-invalid-"));
|
||||
try {
|
||||
const path = join(dir, "state.json");
|
||||
for (const fields of [
|
||||
{ waitTimeoutSeconds: -1 },
|
||||
{ waitTimeoutSeconds: 60, activeWait: { owner: "operator", deadlineAt: 1e100 } },
|
||||
{ waitTimeoutSeconds: 60, waitWakeUsed: "false" },
|
||||
{ waitTimeoutSeconds: 60, activeWait: { owner: "operator" } },
|
||||
{ waitTimeoutSeconds: 60, activeWait: { owner: "operator", deadlineAt: 123456, wakeSent: true } },
|
||||
]) {
|
||||
writeFileSync(path, JSON.stringify({ ...setGoal(initialState(), "output"), ...fields }));
|
||||
assert.equal(loadState(path).status, "paused");
|
||||
assert.match(loadState(path).pausedReason!, /invalid persisted wait/);
|
||||
}
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_MAX_NO_PROGRESS_REPORTS,
|
||||
initialState,
|
||||
recordCheckInjected,
|
||||
recordInProgressReport,
|
||||
recordWorkEvent,
|
||||
setGoal,
|
||||
type ProgressDetails,
|
||||
} from "../lib/state.ts";
|
||||
|
||||
const measurement: ProgressDetails = {
|
||||
kind: "measurement",
|
||||
gate: "PR review",
|
||||
owner: "rev-code-01",
|
||||
lastMeasurement: "PR head abc123 remains unreviewed at 2026-08-31T15:00:00Z",
|
||||
nextAction: "dispatch exact-head review",
|
||||
};
|
||||
|
||||
function reportOnlyCycle(state: ReturnType<typeof setGoal>) {
|
||||
const checked = recordCheckInjected(state);
|
||||
return recordInProgressReport(checked, { evidence: "", progress: undefined }).state;
|
||||
}
|
||||
|
||||
test("exact report-only loop pauses within the no-progress bound", () => {
|
||||
let state = setGoal(initialState(), "ship", 25);
|
||||
for (let n = 1; n <= DEFAULT_MAX_NO_PROGRESS_REPORTS; n++) {
|
||||
state = reportOnlyCycle(state);
|
||||
assert.equal(state.noProgressReports, n);
|
||||
}
|
||||
assert.equal(state.status, "paused");
|
||||
assert.match(state.pausedReason ?? "", /no substantive progress/);
|
||||
});
|
||||
|
||||
test("empty, malformed, and duplicate evidence never reset no-progress", () => {
|
||||
let state = setGoal(initialState(), "ship", 25);
|
||||
state = recordInProgressReport(recordCheckInjected(state), { evidence: "", progress: undefined }).state;
|
||||
assert.equal(state.noProgressReports, 1);
|
||||
|
||||
state = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: "measured",
|
||||
progress: { ...measurement, owner: "" },
|
||||
}).state;
|
||||
assert.equal(state.noProgressReports, 2);
|
||||
|
||||
state = recordWorkEvent(state, "read", "2026-08-31T15:00:00Z");
|
||||
const accepted = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: "measured live PR state",
|
||||
progress: measurement,
|
||||
});
|
||||
assert.equal(accepted.classification, "progress");
|
||||
state = accepted.state;
|
||||
assert.equal(state.noProgressReports, 0);
|
||||
|
||||
state = recordWorkEvent(state, "read", "2026-08-31T15:01:00Z");
|
||||
const duplicate = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: "measured live PR state",
|
||||
progress: measurement,
|
||||
});
|
||||
assert.equal(duplicate.classification, "no_progress");
|
||||
assert.equal(duplicate.state.noProgressReports, 1);
|
||||
});
|
||||
|
||||
test("measurement, action, and delegation require an observed successful work event", () => {
|
||||
for (const kind of ["measurement", "action", "delegation"] as const) {
|
||||
const details = { ...measurement, kind };
|
||||
let state = setGoal(initialState(), kind, 25);
|
||||
const unsupported = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: `${kind} claim`,
|
||||
progress: details,
|
||||
});
|
||||
assert.equal(unsupported.classification, "no_progress");
|
||||
|
||||
state = recordWorkEvent(unsupported.state, kind === "action" ? "edit" : "bash", "2026-08-31T15:00:00Z");
|
||||
const accepted = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: `${kind} completed with a new result`,
|
||||
progress: { ...details, lastMeasurement: `${details.lastMeasurement} ${kind}` },
|
||||
});
|
||||
assert.equal(accepted.classification, "progress");
|
||||
assert.equal(accepted.state.noProgressReports, 0);
|
||||
assert.equal(accepted.state.checks, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit non-tool work requires a concrete artifact", () => {
|
||||
let state = setGoal(initialState(), "draft", 25);
|
||||
const missing = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: "drafted reasoning",
|
||||
progress: { ...measurement, kind: "non_tool" },
|
||||
});
|
||||
assert.equal(missing.classification, "no_progress");
|
||||
|
||||
const accepted = recordInProgressReport(recordCheckInjected(missing.state), {
|
||||
evidence: "completed architecture decision",
|
||||
progress: { ...measurement, kind: "non_tool", artifact: "work/decision-D12.md" },
|
||||
});
|
||||
assert.equal(accepted.classification, "progress");
|
||||
assert.equal(accepted.state.noProgressReports, 0);
|
||||
});
|
||||
|
||||
test("a legitimate external wait remains active without false-positive pause", () => {
|
||||
let state = setGoal(initialState(), "await review", 25);
|
||||
state = recordInProgressReport(recordCheckInjected(state), { evidence: "", progress: undefined }).state;
|
||||
state = recordInProgressReport(recordCheckInjected(state), { evidence: "", progress: undefined }).state;
|
||||
assert.equal(state.noProgressReports, 2);
|
||||
|
||||
const waiting: ProgressDetails = {
|
||||
kind: "wait",
|
||||
gate: "independent review",
|
||||
owner: "rev-code-01",
|
||||
lastMeasurement: "review dispatched at 2026-08-31T15:00:00Z",
|
||||
nextAction: "merge only after PASS",
|
||||
watchId: "ng8-review",
|
||||
nextCheck: "review artifact exists",
|
||||
};
|
||||
for (let n = 0; n < 10; n++) {
|
||||
const outcome = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: "approved watch remains active",
|
||||
progress: waiting,
|
||||
});
|
||||
assert.equal(outcome.classification, "waiting");
|
||||
state = outcome.state;
|
||||
assert.equal(state.status, "active");
|
||||
assert.equal(state.noProgressReports, 2);
|
||||
assert.equal(state.checks, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("RED CONTROL: neutralizing no-progress counting restores the infinite loop", async () => {
|
||||
const source = readFileSync(new URL("../lib/state.ts", import.meta.url), "utf8");
|
||||
const sabotaged = source.replace(
|
||||
"const nextNoProgress = state.noProgressReports + 1; // NG8_COUNT_GATE",
|
||||
"const nextNoProgress = state.noProgressReports; // NG8_COUNT_GATE neutralized",
|
||||
);
|
||||
assert.notEqual(sabotaged, source, "count sabotage must change source");
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-ng8-count-red-"));
|
||||
try {
|
||||
const path = join(dir, "state.ts");
|
||||
writeFileSync(path, sabotaged);
|
||||
const red = await import(`${pathToFileURL(path).href}?red=${Date.now()}`);
|
||||
let state = red.setGoal(red.initialState(), "ship", 25);
|
||||
for (let n = 0; n < DEFAULT_MAX_NO_PROGRESS_REPORTS + 2; n++) {
|
||||
state = red.recordCheckInjected(state);
|
||||
state = red.recordInProgressReport(state, { evidence: "", progress: undefined }).state;
|
||||
}
|
||||
assert.equal(state.status, "active", "RED: report-only loop survives when counting is neutralized");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RED CONTROL: neutralizing pause enforcement leaves the counted loop active", async () => {
|
||||
const source = readFileSync(new URL("../lib/state.ts", import.meta.url), "utf8");
|
||||
const sabotaged = source.replace(
|
||||
"if (nextNoProgress >= state.maxNoProgressReports) { // NG8_PAUSE_GATE",
|
||||
"if (false && nextNoProgress >= state.maxNoProgressReports) { // NG8_PAUSE_GATE neutralized",
|
||||
);
|
||||
assert.notEqual(sabotaged, source, "pause sabotage must change source");
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-ng8-pause-red-"));
|
||||
try {
|
||||
const path = join(dir, "state.ts");
|
||||
writeFileSync(path, sabotaged);
|
||||
const red = await import(`${pathToFileURL(path).href}?red=${Date.now()}`);
|
||||
let state = red.setGoal(red.initialState(), "ship", 25);
|
||||
for (let n = 0; n < DEFAULT_MAX_NO_PROGRESS_REPORTS; n++) {
|
||||
state = red.recordCheckInjected(state);
|
||||
state = red.recordInProgressReport(state, { evidence: "", progress: undefined }).state;
|
||||
}
|
||||
assert.equal(state.noProgressReports, DEFAULT_MAX_NO_PROGRESS_REPORTS);
|
||||
assert.equal(state.status, "active", "RED: counted loop survives when pause gate is neutralized");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { initialState, setGoal, recordInProgressReport } from "../lib/state.ts";
|
||||
import { decideSettle } from "../lib/settle.ts";
|
||||
import { parseGoalCommand } from "../lib/parse.ts";
|
||||
|
||||
const wait = { evidence: "Dependency pending", progress: { kind: "wait" as const, gate: "input", owner: "operator", lastMeasurement: "input absent", nextAction: "Read input when available", nextCheck: "input file arrives" } };
|
||||
|
||||
test("opted-in waits suspend settle injection", () => {
|
||||
const state = { ...setGoal(initialState(), "prepare output"), waitTimeoutSeconds: 60 };
|
||||
const waiting = recordInProgressReport(state, wait).state;
|
||||
assert.equal(decideSettle(waiting, "stop").action, "wait");
|
||||
});
|
||||
|
||||
test("operator can select bounded waits per goal", () => {
|
||||
assert.deepEqual(parseGoalCommand("--wait-timeout 60 --max 5 prepare output"), { kind: "set", text: "prepare output", max: 5, waitTimeoutSeconds: 60 });
|
||||
});
|
||||
|
||||
test("timeout input is bounded and opt-in never leaks to another goal", () => {
|
||||
for (const args of ["--wait-timeout", "--wait-timeout=", "--wait-timeout 0 x", "--wait-timeout 9 x", "--wait-timeout 86401 x", "--wait-timeout 60.0 x", "--wait-timeout 60", "--wait-timeout 60 --wait-timeout 30 x"]) {
|
||||
assert.equal(parseGoalCommand(args).kind, "error", args);
|
||||
}
|
||||
const opted = setGoal(initialState(), "one", undefined, 60);
|
||||
assert.equal(setGoal(opted, "two").waitTimeoutSeconds, undefined);
|
||||
const legacyWait = recordInProgressReport(setGoal(initialState(), "legacy"), wait).state;
|
||||
assert.equal(decideSettle(legacyWait, "stop").action, "inject");
|
||||
});
|
||||
@@ -0,0 +1,599 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { fencedStateFilePath } from "../lib/store.ts";
|
||||
import {
|
||||
publishGoalPolicy,
|
||||
registerGoalTrackedItemResolver,
|
||||
resetGoalPolicyRegistryForTest,
|
||||
} from "../../mosaic-core/lib/goal-policy.ts";
|
||||
|
||||
interface SentMessage {
|
||||
content: string;
|
||||
options?: { deliverAs?: "steer" | "followUp" };
|
||||
}
|
||||
|
||||
const GOAL_ROOT = dirname(fileURLToPath(new URL("../index.ts", import.meta.url)));
|
||||
const INCARNATION_SOURCE = join(GOAL_ROOT, "..", "mosaic-core", "lib", "incarnation.ts");
|
||||
const EXECUTIVE_UPDATE_FORMAT = "structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked)";
|
||||
const PROACTIVE_LOOP_DIRECTIVE =
|
||||
"Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop.";
|
||||
|
||||
async function loadGoalExtension(root: string): Promise<(pi: any) => void> {
|
||||
const extensionRoot = join(root, "extensions", "goal-runtime");
|
||||
cpSync(join(GOAL_ROOT, "lib"), join(extensionRoot, "lib"), { recursive: true });
|
||||
mkdirSync(join(root, "extensions", "mosaic-core", "lib"), { recursive: true });
|
||||
cpSync(INCARNATION_SOURCE, join(root, "extensions", "mosaic-core", "lib", "incarnation.ts"));
|
||||
cpSync(join(GOAL_ROOT, "..", "mosaic-core", "lib", "goal-policy.ts"), join(root, "extensions", "mosaic-core", "lib", "goal-policy.ts"));
|
||||
|
||||
const source = readFileSync(join(GOAL_ROOT, "index.ts"), "utf8");
|
||||
const withoutTypebox = source.replace(
|
||||
'import { Type } from "typebox";',
|
||||
'const Type = { Object: (properties: unknown) => ({ type: "object", properties }), Optional: (schema: unknown) => schema, String: (options: unknown = {}) => ({ type: "string", ...(options as object) }) };',
|
||||
);
|
||||
const selfContained = withoutTypebox.replace(
|
||||
'import { StringEnum } from "@earendil-works/pi-ai";',
|
||||
'const StringEnum = (values: readonly string[]) => ({ type: "string", enum: values });',
|
||||
);
|
||||
assert.notEqual(withoutTypebox, source, "headless harness must replace TypeBox runtime import");
|
||||
assert.notEqual(selfContained, withoutTypebox, "headless harness must replace pi-ai runtime import");
|
||||
writeFileSync(join(extensionRoot, "index.ts"), selfContained);
|
||||
return (await import(`${pathToFileURL(join(extensionRoot, "index.ts")).href}?runtime=${Date.now()}`)).default;
|
||||
}
|
||||
|
||||
async function createHeadlessHarness(root: string, incarnation: string) {
|
||||
const agentDir = join(root, "state", "goal");
|
||||
const handlers = new Map<string, Array<(event: any, ctx: any) => any>>();
|
||||
const commands = new Map<string, any>();
|
||||
const tools = new Map<string, any>();
|
||||
const sent: SentMessage[] = [];
|
||||
const notifications: Array<{ message: string; level: string }> = [];
|
||||
const widgets: unknown[] = [];
|
||||
const statuses: string[] = [];
|
||||
const shortcuts = new Map<string, any>();
|
||||
|
||||
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousIncarnation = process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
process.env.MOSAIC_LAUNCH_INCARNATION = incarnation;
|
||||
|
||||
const pi: any = {
|
||||
on(name: string, handler: (event: any, ctx: any) => any) {
|
||||
handlers.set(name, [...(handlers.get(name) ?? []), handler]);
|
||||
},
|
||||
registerCommand(name: string, command: any) {
|
||||
commands.set(name, command);
|
||||
},
|
||||
registerShortcut(key: string, shortcut: any) { shortcuts.set(key, shortcut); },
|
||||
registerTool(tool: any) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
sendUserMessage(content: string, options?: SentMessage["options"]) {
|
||||
sent.push({ content, options });
|
||||
},
|
||||
};
|
||||
|
||||
let idle = false;
|
||||
const ctx: any = {
|
||||
mode: "interactive",
|
||||
hasUI: true,
|
||||
isIdle: () => idle,
|
||||
ui: {
|
||||
theme: { fg: (color: string, text: string) => `[${color}]${text}` },
|
||||
setStatus(_id: string, text: string) { statuses.push(text); },
|
||||
notify(message: string, level: string) {
|
||||
notifications.push({ message, level });
|
||||
},
|
||||
setWidget(_id: string, value: unknown) {
|
||||
widgets.push(value);
|
||||
},
|
||||
},
|
||||
sessionManager: {
|
||||
getBranch: () => [{ type: "message", message: { role: "assistant", stopReason: "stop" } }],
|
||||
},
|
||||
};
|
||||
|
||||
const goalExtension = await loadGoalExtension(root);
|
||||
goalExtension(pi);
|
||||
|
||||
return {
|
||||
commands,
|
||||
tools,
|
||||
sent,
|
||||
notifications,
|
||||
widgets,
|
||||
statuses,
|
||||
shortcuts,
|
||||
ctx,
|
||||
setIdle(value: boolean) {
|
||||
idle = value;
|
||||
},
|
||||
async emit(name: string, event: any = { type: name }) {
|
||||
for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
|
||||
},
|
||||
state() {
|
||||
return JSON.parse(readFileSync(fencedStateFilePath(agentDir, incarnation), "utf8"));
|
||||
},
|
||||
restoreEnv() {
|
||||
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
||||
if (previousIncarnation === undefined) delete process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
else process.env.MOSAIC_LAUNCH_INCARNATION = previousIncarnation;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("goal reporting instructions name the executive-update format and proactive loop", () => {
|
||||
const source = readFileSync(join(GOAL_ROOT, "index.ts"), "utf8");
|
||||
const checkPrompt = source.slice(source.indexOf("function checkPrompt(): string"), source.indexOf("/** Inject one check prompt"));
|
||||
const goalReport = source.slice(source.indexOf('name: "goal_report"'), source.indexOf("promptSnippet:", source.indexOf('name: "goal_report"')));
|
||||
const beforeAgentStart = source.slice(source.indexOf('pi.on("before_agent_start"'), source.indexOf('pi.on("tool_result"'));
|
||||
|
||||
for (const [section, text] of Object.entries({ checkPrompt, goalReport, beforeAgentStart })) {
|
||||
assert.ok(text.includes(EXECUTIVE_UPDATE_FORMAT), `${section} must name the executive-update format`);
|
||||
assert.ok(text.includes(PROACTIVE_LOOP_DIRECTIVE), `${section} must name the proactive loop`);
|
||||
}
|
||||
});
|
||||
|
||||
test("headless runtime exercises goal_report, agent_settled, follow-up injection, and pause", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-ng8-runtime-"));
|
||||
const harness = await createHeadlessHarness(dir, "ng8-headless-runtime");
|
||||
try {
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
assert.ok(goal);
|
||||
assert.ok(report);
|
||||
|
||||
await goal.handler("ship safely", harness.ctx);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
assert.equal(harness.sent[0].options?.deliverAs, "followUp");
|
||||
assert.match(harness.sent[0].content, /no-progress 0\/3/);
|
||||
|
||||
for (let cycle = 1; cycle <= 3; cycle++) {
|
||||
const result = await report.execute(`report-${cycle}`, { status: "in_progress" }, undefined, undefined, harness.ctx);
|
||||
await harness.emit("tool_result", {
|
||||
type: "tool_result",
|
||||
toolCallId: `report-${cycle}`,
|
||||
toolName: "goal_report",
|
||||
input: { status: "in_progress" },
|
||||
content: result.content,
|
||||
isError: false,
|
||||
});
|
||||
await harness.emit("agent_settled");
|
||||
if (cycle < 3) assert.equal(harness.sent.length, cycle + 1);
|
||||
}
|
||||
|
||||
assert.equal(harness.sent.length, 3, "paused loop injects no fourth check");
|
||||
assert.equal(harness.state().status, "paused");
|
||||
assert.equal(harness.state().noProgressReports, 3);
|
||||
assert.ok(harness.notifications.some((notice) => notice.level === "warning" && /no substantive progress/.test(notice.message)));
|
||||
} finally {
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("C7/C8: attested parser and resolver reject before Goal state changes", async () => {
|
||||
resetGoalPolicyRegistryForTest();
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-v4-policy-"));
|
||||
const incarnation = "inc-goal-v4";
|
||||
const harness = await createHeadlessHarness(dir, incarnation);
|
||||
try {
|
||||
assert.equal(registerGoalTrackedItemResolver(incarnation, async () => Object.freeze({ outcome: "zero" as const })).ok, true);
|
||||
const journalReasons: string[] = [];
|
||||
const publication = publishGoalPolicy({
|
||||
schemaVersion: 1,
|
||||
role: "plan-ng",
|
||||
roleRevision: 4,
|
||||
manifestSha256: "a".repeat(64),
|
||||
format: "ms-executive-update/v1",
|
||||
contractPath: "skills-local/ms-executive-update/SKILL.md",
|
||||
contractSection: "Machine contract (for `goal_report` payloads and any parser)",
|
||||
contractBlob: "df30c6fbb54b4a65a298c9e51c07f742610d171c",
|
||||
contractSha256: "bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319",
|
||||
enforcement: "pre-state-change-fail-closed",
|
||||
identifierResolution: "consumer-fail-closed",
|
||||
launchGeneration: 1,
|
||||
incarnationId: incarnation,
|
||||
}, (reason) => journalReasons.push(reason));
|
||||
assert.equal(publication.ok, true);
|
||||
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
await goal.handler("attested policy", harness.ctx);
|
||||
const before = harness.state();
|
||||
const update = [
|
||||
"Just Completed:", "", "* T165: contracts added", "", "Next Step:", "", "* T165: run tests", "", "Blocked:", "", "* T165: none \u2014 nothing from you", "",
|
||||
].join("\n");
|
||||
await assert.rejects(
|
||||
() => report.execute("attested-reject", { status: "satisfied", evidence: update }, undefined, undefined, harness.ctx),
|
||||
/resolver-zero/,
|
||||
);
|
||||
const after = harness.state();
|
||||
assert.equal(after.status, "active");
|
||||
assert.equal(after.checks, before.checks);
|
||||
assert.equal(after.noProgressReports, before.noProgressReports);
|
||||
|
||||
const noChange = "No change since T165; still waiting on T165.";
|
||||
for (const status of ["satisfied", "blocked"] as const) {
|
||||
await assert.rejects(
|
||||
() => report.execute(`no-change-${status}`, { status, evidence: noChange }, undefined, undefined, harness.ctx),
|
||||
/no-change-status/,
|
||||
);
|
||||
}
|
||||
const terminalAfter = harness.state();
|
||||
assert.equal(terminalAfter.status, "active", "unchanged form cannot settle or pause the goal");
|
||||
assert.equal(terminalAfter.checks, before.checks);
|
||||
assert.equal(terminalAfter.noProgressReports, before.noProgressReports);
|
||||
assert.deepEqual(
|
||||
journalReasons,
|
||||
["resolver-zero", "no-change-status", "no-change-status"],
|
||||
"all denials journal stable codes without report payload text",
|
||||
);
|
||||
} finally {
|
||||
resetGoalPolicyRegistryForTest();
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("headless runtime accepts a tool-backed measurement and preserves terminal behavior", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-ng8-runtime-work-"));
|
||||
const harness = await createHeadlessHarness(dir, "ng8-headless-work");
|
||||
try {
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
await goal.handler("measure", harness.ctx);
|
||||
|
||||
await harness.emit("tool_result", {
|
||||
type: "tool_result",
|
||||
toolCallId: "read-error",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/missing" },
|
||||
content: [{ type: "text", text: "missing" }],
|
||||
isError: true,
|
||||
});
|
||||
assert.equal(harness.state().workEventSinceReport, false, "failed tools are not progress events");
|
||||
|
||||
await harness.emit("tool_result", {
|
||||
type: "tool_result",
|
||||
toolCallId: "read-1",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/status" },
|
||||
content: [{ type: "text", text: "measured" }],
|
||||
isError: false,
|
||||
});
|
||||
assert.equal(harness.state().workEventSinceReport, true);
|
||||
|
||||
const progress = {
|
||||
kind: "measurement",
|
||||
gate: "review",
|
||||
owner: "reviewer",
|
||||
lastMeasurement: "head abc remains pending at 2026-08-31T15:00:00Z",
|
||||
nextAction: "dispatch reviewer",
|
||||
};
|
||||
const accepted = await report.execute(
|
||||
"progress-1",
|
||||
{ status: "in_progress", evidence: "measured exact head", progress },
|
||||
undefined,
|
||||
undefined,
|
||||
harness.ctx,
|
||||
);
|
||||
assert.match(accepted.content[0].text, /Recorded substantive progress/);
|
||||
assert.equal(harness.state().noProgressReports, 0);
|
||||
assert.equal(harness.state().workEventSinceReport, false);
|
||||
|
||||
await goal.handler("done goal", harness.ctx);
|
||||
await assert.rejects(
|
||||
() => report.execute("satisfied-empty", { status: "satisfied" }, undefined, undefined, harness.ctx),
|
||||
/requires non-empty evidence/,
|
||||
);
|
||||
const satisfied = await report.execute(
|
||||
"satisfied",
|
||||
{ status: "satisfied", evidence: "acceptance evidence" },
|
||||
undefined,
|
||||
undefined,
|
||||
harness.ctx,
|
||||
);
|
||||
assert.match(satisfied.content[0].text, /SATISFIED/);
|
||||
assert.equal(harness.state().status, "none");
|
||||
|
||||
await goal.handler("blocked goal", harness.ctx);
|
||||
const blocked = await report.execute(
|
||||
"blocked",
|
||||
{ status: "blocked", evidence: "missing credential" },
|
||||
undefined,
|
||||
undefined,
|
||||
harness.ctx,
|
||||
);
|
||||
assert.match(blocked.content[0].text, /BLOCKED/);
|
||||
assert.equal(harness.state().status, "blocked");
|
||||
} finally {
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const waitReport = {
|
||||
status: "in_progress", evidence: "Input is pending",
|
||||
progress: { kind: "wait", gate: "input", owner: "operator", lastMeasurement: "input absent",
|
||||
nextAction: "Read input and finish output", nextCheck: "input file available" },
|
||||
};
|
||||
|
||||
test("quiet wait: no heartbeats, one deadline wake, unresolved dependency pauses", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-runtime-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-runtime");
|
||||
try {
|
||||
harness.setIdle(true);
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
await goal.handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await report.execute("wait-1", waitReport, undefined, undefined, harness.ctx);
|
||||
const deadline = harness.state().activeWait.deadlineAt;
|
||||
for (let i = 0; i < 100; i++) await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 1, "settle events cannot generate idle heartbeats");
|
||||
t.mock.timers.tick(30000);
|
||||
await report.execute("wait-2", { ...waitReport, evidence: "Still pending, differently worded" }, undefined, undefined, harness.ctx);
|
||||
assert.equal(harness.state().activeWait.deadlineAt, deadline, "unchanged waits never extend deadline");
|
||||
t.mock.timers.tick(29999);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(harness.sent.length, 2);
|
||||
assert.match(harness.sent[1].content, /Wait deadline reached/);
|
||||
assert.equal(harness.state().waitWakeUsed, true);
|
||||
const result = await report.execute("wait-3", waitReport, undefined, undefined, harness.ctx);
|
||||
assert.equal(result.details.goalStatus, "paused");
|
||||
t.mock.timers.tick(86400000);
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 2, "unresolved deadline never re-arms itself");
|
||||
await assert.rejects(() => report.execute("late-satisfied", { status: "satisfied", evidence: "late result" }, undefined, undefined, harness.ctx), /paused/);
|
||||
} finally {
|
||||
await harness.emit("session_shutdown");
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quiet wait: persisted deadline survives reload without an immediate check", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-reload-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-reload");
|
||||
try {
|
||||
harness.setIdle(true);
|
||||
await harness.commands.get("goal").handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await harness.tools.get("goal_report").execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
await harness.emit("session_shutdown");
|
||||
t.mock.timers.tick(30000);
|
||||
await harness.emit("session_start");
|
||||
assert.equal(harness.sent.length, 1);
|
||||
t.mock.timers.tick(30000);
|
||||
assert.equal(harness.sent.length, 2);
|
||||
assert.match(harness.sent[1].content, /Wait deadline reached/);
|
||||
} finally {
|
||||
await harness.emit("session_shutdown");
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quiet wait: stop, clear, replacement, and completion cancel the timer", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-cancel-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-cancel");
|
||||
try {
|
||||
harness.setIdle(true);
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
for (const operation of ["stop", "clear", "replacement", "satisfied"]) {
|
||||
await goal.handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
if (operation === "satisfied") {
|
||||
await report.execute("done", { status: "satisfied", evidence: "Output verified" }, undefined, undefined, harness.ctx);
|
||||
} else {
|
||||
await goal.handler(operation, harness.ctx);
|
||||
}
|
||||
const before = harness.sent.length;
|
||||
t.mock.timers.tick(61000);
|
||||
assert.equal(harness.sent.length, before, `${operation} leaves no deadline wake`);
|
||||
}
|
||||
} finally {
|
||||
await harness.emit("session_shutdown");
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quiet wait: a busy turn is allowed to settle before deadline injection", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-busy-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-busy");
|
||||
try {
|
||||
await harness.commands.get("goal").handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await harness.tools.get("goal_report").execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(harness.sent.length, 1, "no deadline queued during a running turn");
|
||||
await harness.commands.get("goal").handler("stop", harness.ctx);
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 1, "stop cancels deferred deadline");
|
||||
await harness.commands.get("goal").handler("resume", harness.ctx);
|
||||
assert.equal(harness.state().activeWait, undefined);
|
||||
await harness.tools.get("goal_report").execute("wait-again", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
const before = harness.sent.length;
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, before + 1);
|
||||
} finally {
|
||||
await harness.emit("session_shutdown");
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quiet wait: relevant progress cancels the wait; print mode has no timer wake", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-progress-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-progress");
|
||||
try {
|
||||
harness.setIdle(true);
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
await goal.handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
await harness.emit("tool_result", { toolName: "read", isError: false });
|
||||
await report.execute("progress", { status: "in_progress", evidence: "Input arrived and was verified", progress: { ...waitReport.progress, kind: "measurement", lastMeasurement: "Input present" } }, undefined, undefined, harness.ctx);
|
||||
assert.equal(harness.state().activeWait, undefined);
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 2, "ready work continues after wait is resolved");
|
||||
harness.ctx.mode = "print";
|
||||
await goal.handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await report.execute("wait-print", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 2, "print mode never schedules a model wake");
|
||||
} finally {
|
||||
await harness.emit("session_shutdown");
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("deadline readiness recovers after compaction without agent_settled", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-compaction-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-compaction");
|
||||
try {
|
||||
await harness.commands.get("goal").handler("--wait-timeout 60 output", harness.ctx);
|
||||
await harness.tools.get("goal_report").execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
harness.setIdle(true);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(harness.sent.length, 2, "runtime readiness alone releases the deferred wake");
|
||||
} finally {
|
||||
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("unobserved deadline dispatch pauses once; observed start cancels watchdog", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-dispatch-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-dispatch");
|
||||
try {
|
||||
harness.setIdle(true);
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
await goal.handler("--wait-timeout 60 output", harness.ctx);
|
||||
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
const sent = harness.sent.length;
|
||||
// Ordinary Pi sendUserMessage returns void; a rejected async delivery may never start.
|
||||
t.mock.timers.tick(30000);
|
||||
assert.equal(harness.state().status, "paused");
|
||||
assert.match(harness.state().pausedReason, /not observed/);
|
||||
assert.equal(harness.sent.length, sent, "watchdog never retries a potentially delivered request");
|
||||
await goal.handler("resume", harness.ctx);
|
||||
await report.execute("wait-again", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
await harness.emit("before_agent_start", { prompt: harness.sent.at(-1)!.content, systemPrompt: "base" });
|
||||
assert.equal(harness.state().activeWait.wakeObserved, true);
|
||||
t.mock.timers.tick(30000);
|
||||
assert.equal(harness.state().status, "active", "observed model start cancels the acknowledgment timer");
|
||||
} finally {
|
||||
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("continuously busy runtime pauses after bounded readiness checks", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-busy-limit-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-busy-limit");
|
||||
try {
|
||||
await harness.commands.get("goal").handler("--wait-timeout 60 output", harness.ctx);
|
||||
await harness.tools.get("goal_report").execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
for (let i = 0; i < 60; i++) t.mock.timers.tick(1000);
|
||||
assert.equal(harness.state().status, "paused");
|
||||
assert.match(harness.state().pausedReason, /remained busy/);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
} finally {
|
||||
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("footer and both recall paths cover lifecycle without adding widget content", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-footer-"));
|
||||
const harness = await createHeadlessHarness(dir, "footer");
|
||||
const previousNoColor = process.env.NO_COLOR;
|
||||
delete process.env.NO_COLOR;
|
||||
try {
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
const text = "Full goal " + "acceptance criteria ".repeat(100) + " END-OF-GOAL";
|
||||
await harness.emit("session_start");
|
||||
assert.match(harness.statuses.at(-1)!, /\[muted\]Goal: None/);
|
||||
await goal.handler(text, harness.ctx);
|
||||
assert.match(harness.statuses.at(-1)!, /\[accent\]Goal: Active/);
|
||||
const sent = harness.sent.length;
|
||||
await goal.handler("", harness.ctx);
|
||||
const recall = harness.notifications.at(-1)!.message;
|
||||
assert.ok(recall.includes(text));
|
||||
await harness.shortcuts.get("alt+g").handler(harness.ctx);
|
||||
assert.equal(harness.notifications.at(-1)!.message, recall);
|
||||
assert.equal(harness.sent.length, sent, "recall never starts a turn");
|
||||
assert.equal(harness.shortcuts.has("tab"), false);
|
||||
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
assert.match(harness.statuses.at(-1)!, /\[warning\]Goal: Waiting/);
|
||||
await goal.handler("stop", harness.ctx);
|
||||
assert.match(harness.statuses.at(-1)!, /\[warning\]Goal: Paused/);
|
||||
await goal.handler("resume", harness.ctx);
|
||||
await report.execute("blocked", { status: "blocked", evidence: "permission needed" }, undefined, undefined, harness.ctx);
|
||||
assert.match(harness.statuses.at(-1)!, /\[error\]Goal: Blocked/);
|
||||
await goal.handler("resume", harness.ctx);
|
||||
await report.execute("done", { status: "satisfied", evidence: "verified" }, undefined, undefined, harness.ctx);
|
||||
assert.match(harness.statuses.at(-1)!, /\[success\]Goal: Complete/);
|
||||
assert.equal(harness.state().lastOutcome.text, text);
|
||||
const finishedSent = harness.sent.length;
|
||||
await harness.emit("agent_settled");
|
||||
await harness.emit("session_start");
|
||||
await goal.handler("resume", harness.ctx);
|
||||
assert.equal(harness.sent.length, finishedSent);
|
||||
await goal.handler("", harness.ctx);
|
||||
assert.ok(harness.notifications.at(-1)!.message.includes(text));
|
||||
process.env.NO_COLOR = "1";
|
||||
await harness.emit("session_start");
|
||||
assert.equal(harness.statuses.at(-1), "Goal: Complete (/goal, Alt+G)");
|
||||
await goal.handler("clear", harness.ctx);
|
||||
assert.equal(harness.state().lastOutcome, undefined);
|
||||
assert.equal(harness.statuses.at(-1), "Goal: None (/goal, Alt+G)");
|
||||
assert.ok(harness.widgets.every(value => value === undefined));
|
||||
} finally {
|
||||
if (previousNoColor === undefined) delete process.env.NO_COLOR;
|
||||
else process.env.NO_COLOR = previousNoColor;
|
||||
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("state-write failure cannot report goal completion or inject another check", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-save-failure-"));
|
||||
const incarnation = "quiet-save-failure";
|
||||
const harness = await createHeadlessHarness(dir, incarnation);
|
||||
try {
|
||||
await harness.commands.get("goal").handler("--wait-timeout 60 output", harness.ctx);
|
||||
const statePath = fencedStateFilePath(join(dir, "state", "goal"), incarnation);
|
||||
rmSync(statePath);
|
||||
mkdirSync(statePath); // force atomic replacement failure without mocking away filesystem behavior
|
||||
await assert.rejects(() => harness.tools.get("goal_report").execute("done", { status: "satisfied", evidence: "done" }, undefined, undefined, harness.ctx), /could not be saved/);
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 1);
|
||||
assert.equal(harness.notifications.some((n) => n.message.includes("satisfied and cleared")), false);
|
||||
} finally {
|
||||
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
// lib/adapter.ts — Pi adapter binding (PRD R3-R5; NG-2).
|
||||
//
|
||||
// Deliberately dependency-light (type-only imports + node builtins) so the
|
||||
// hermetic suite can import and exercise the REAL binding against a fake pi
|
||||
// — including the AC4 red control, which sabotages this file's enforcement
|
||||
// registration block and proves the same call then passes.
|
||||
//
|
||||
// The binding:
|
||||
// session_start (every reason: startup/new/resume/reload/fork — policy may
|
||||
// have changed on disk between sessions) ->
|
||||
// resolve seat role from the launcher-established agent
|
||||
// name -> load trusted manifest -> build application ->
|
||||
// setActiveTools(exact) -> VERIFY getActiveTools() equals
|
||||
// the exact set; any drift at any stage -> fail-closed.
|
||||
// tool_call -> decideToolCall; blocked calls return the stable code.
|
||||
|
||||
import { incarnationIdentity } from "./incarnation.ts";
|
||||
import {
|
||||
buildApplication,
|
||||
decideToolCall,
|
||||
failClosedState,
|
||||
statusSnapshot,
|
||||
STATUS_TOOL,
|
||||
PROPOSAL_TOOL,
|
||||
SAFE_READ_TOOLS,
|
||||
type EnforcementState,
|
||||
} from "./enforce.ts";
|
||||
import { loadTrustedManifest, seatRole, type LoadOutcome, type LoaderIO } from "./loader.ts";
|
||||
import { createJournal, type Journal, type JournalIO } from "./journal.ts";
|
||||
import { sanitizeProposal } from "./proposal.ts";
|
||||
import { publishGoalPolicy, verifyGoalPolicyPublication, type GoalPolicyAttestationV1 } from "./goal-policy.ts";
|
||||
import { readFileSync, lstatSync, realpathSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
export interface PiToolCallEvent {
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
export interface PiLike {
|
||||
on(event: "session_start", handler: (event: { reason: string }) => void | Promise<void>): void;
|
||||
on(event: "tool_call", handler: (event: PiToolCallEvent) => unknown): void;
|
||||
getActiveTools(): string[];
|
||||
setActiveTools(names: string[]): void;
|
||||
registerTool(tool: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export interface AdapterOptions {
|
||||
env?: Record<string, string | undefined>;
|
||||
io?: LoaderIO;
|
||||
journalIO?: JournalIO;
|
||||
stateHome?: string;
|
||||
incarnationId?: string;
|
||||
launchGeneration?: number;
|
||||
}
|
||||
|
||||
function defaultIO(): LoaderIO {
|
||||
return {
|
||||
readFileSync(path: string): string | Uint8Array { return readFileSync(path); },
|
||||
lstatSync,
|
||||
realpathSync,
|
||||
};
|
||||
}
|
||||
|
||||
function launchGeneration(env: Record<string, string | undefined>, supplied: number | undefined): number | undefined {
|
||||
if (supplied !== undefined) return supplied;
|
||||
const raw = env.MOSAIC_LAUNCH_GENERATION;
|
||||
if (!raw || !/^[1-9][0-9]*$/.test(raw)) return undefined;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function bindEnforcement(pi: PiLike, opts: AdapterOptions = {}): void {
|
||||
const env = opts.env ?? process.env;
|
||||
const io = opts.io ?? defaultIO();
|
||||
const incarnationId = opts.incarnationId ?? incarnationIdentity();
|
||||
const configuredLaunchGeneration = launchGeneration(env, opts.launchGeneration);
|
||||
let enforcement: EnforcementState = { state: "not-applied", incarnationId };
|
||||
let activeGoalPolicy: GoalPolicyAttestationV1 | undefined;
|
||||
// A version-4 role has exactly one trusted manifest/contract load per
|
||||
// incarnation. Reloads reuse this immutable result and never republish.
|
||||
let v4Load: Extract<LoadOutcome, { ok: true }> | undefined;
|
||||
let v4Published = false;
|
||||
|
||||
const brainHome = env.MOSAIC_BRAIN_HOME || `${homedir()}/.mosaic`;
|
||||
const journal: Journal = createJournal({
|
||||
incarnationId,
|
||||
stateHome: opts.stateHome ?? env.XDG_STATE_HOME,
|
||||
io: opts.journalIO,
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: STATUS_TOOL,
|
||||
label: "Mosaic policy status",
|
||||
description:
|
||||
"Read-only report of the applied mosaic-core role policy: role, revision, digest, " +
|
||||
"active tools, and enforcement state. Cannot modify anything.",
|
||||
parameters: { type: "object", properties: {}, additionalProperties: false },
|
||||
async execute() {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(statusSnapshot(enforcement, pi.getActiveTools())) }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: PROPOSAL_TOOL,
|
||||
label: "Mosaic improvement proposal",
|
||||
description:
|
||||
"Record a structured improvement proposal for the mosaic-core role policy. Proposals " +
|
||||
"are appended to the per-incarnation journal for coordinator review; they never " +
|
||||
"modify manifests, settings, or active tools.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
target: { type: "string", description: "What the proposal concerns: a role, tool, or policy field" },
|
||||
summary: { type: "string", description: "One line, at most 500 characters" },
|
||||
motivation: { type: "string", description: "Why this improvement matters, at most 2000 characters" },
|
||||
evidence: { type: "array", items: { type: "string" }, description: "Optional evidence pointers, at most 8 items of 500 characters" },
|
||||
},
|
||||
required: ["target", "summary", "motivation"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id: string, params: unknown) {
|
||||
const verdict = sanitizeProposal(params);
|
||||
if (!verdict.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `proposal rejected (${verdict.reason}${verdict.detail ? `: ${verdict.detail}` : ""}) — recorded nothing` }],
|
||||
details: { rejected: verdict.reason },
|
||||
};
|
||||
}
|
||||
try {
|
||||
journal.append({ kind: "improvement-proposal", proposal: verdict.proposal });
|
||||
} catch {
|
||||
// Journal failure: the proposal is NOT recorded, nothing is applied,
|
||||
// and the caller sees a fixed message with no raw exception
|
||||
return {
|
||||
content: [{ type: "text", text: "proposal recording unavailable (journal failure); recorded nothing; nothing applied" }],
|
||||
details: { recorded: false, cause: "journal-failure" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `proposal recorded for coordinator review (target: ${verdict.proposal.target}); nothing was applied` }],
|
||||
details: { recorded: true },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// [NG-2 enforcement binding begin]
|
||||
// The enforcement surface under AC4's removal red control: exactly these
|
||||
// two registrations. Sabotage that deletes this block leaves the status
|
||||
// tool but no interception and no application — the same call then passes.
|
||||
pi.on("session_start", async () => {
|
||||
// Resolve the profile role before entering degraded mode. A known gate role
|
||||
// gets status only if its manifest, contract, resolver, or publication
|
||||
// fails; it must not inherit generic safe reads or proposal authority.
|
||||
const profileRole = seatRole(brainHome, env.MOSAIC_AGENT_NAME, io);
|
||||
let roleHint = profileRole ?? undefined;
|
||||
// NG4-RECON: enter TRUSTED FAIL-CLOSED BEFORE any platform call.
|
||||
// If setActiveTools or getActiveTools throws mid-reconciliation, the
|
||||
// previous applied policy does NOT remain authoritative (R5).
|
||||
enforcement = failClosedState(incarnationId, "reconciliation-in-progress", brainHome, roleHint);
|
||||
|
||||
try {
|
||||
let load: LoadOutcome;
|
||||
activeGoalPolicy = undefined;
|
||||
if (v4Load) {
|
||||
load = v4Load;
|
||||
roleHint = v4Load.manifest.role;
|
||||
} else {
|
||||
if (profileRole === null) {
|
||||
const stage = env.MOSAIC_AGENT_NAME ? "no-seat-profile" : "no-agent-name";
|
||||
load = { ok: false, failure: { stage } as LoadOutcome extends { ok: false; failure: infer F } ? F : never };
|
||||
} else {
|
||||
load = loadTrustedManifest(brainHome, profileRole, io, { incarnationId, launchGeneration: configuredLaunchGeneration });
|
||||
if (load.ok) roleHint = load.manifest.role;
|
||||
}
|
||||
if (load.ok && load.manifest.schemaVersion === 4) v4Load = load;
|
||||
}
|
||||
if (load.ok && load.manifest.schemaVersion === 4) {
|
||||
const policy = load.goalPolicy?.attestation;
|
||||
if (!policy) {
|
||||
load = { ok: false, failure: { stage: "goal-policy-publication" } };
|
||||
} else if (!v4Published) {
|
||||
const publication = publishGoalPolicy(policy, (reason) => {
|
||||
try {
|
||||
journal.append({ kind: "denial", tool: "goal_report", reason: `mosaic-core:goal-policy-denied:${reason}` });
|
||||
} catch {
|
||||
console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)");
|
||||
}
|
||||
});
|
||||
if (!publication.ok) {
|
||||
load = { ok: false, failure: { stage: "goal-policy-publication" } };
|
||||
} else {
|
||||
v4Published = true;
|
||||
activeGoalPolicy = publication.attestation;
|
||||
}
|
||||
} else {
|
||||
activeGoalPolicy = policy;
|
||||
}
|
||||
}
|
||||
const application = buildApplication({
|
||||
load,
|
||||
activeTools: pi.getActiveTools(),
|
||||
incarnationId,
|
||||
roleHint,
|
||||
brainHome,
|
||||
});
|
||||
pi.setActiveTools(application.applyTools);
|
||||
const got = [...pi.getActiveTools()].sort();
|
||||
const want = [...application.applyTools].sort();
|
||||
if (JSON.stringify(got) !== JSON.stringify(want)) {
|
||||
enforcement = failClosedState(incarnationId, "postcondition-failed", brainHome, roleHint);
|
||||
// A mismatch is not merely a status value. Remove the broad/incorrect
|
||||
// active projection before returning; interception remains the final
|
||||
// denial if the platform rejects this recovery call too.
|
||||
try { pi.setActiveTools([...enforcement.allowed].sort()); } catch { /* interception governs */ }
|
||||
try { journal.append({ kind: "reconciliation", state: "fail-closed", cause: "postcondition-failed", removed: [], added: [] }); } catch (e) { console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)"); }
|
||||
return;
|
||||
}
|
||||
enforcement = application.state;
|
||||
try {
|
||||
journal.append({
|
||||
kind: "reconciliation",
|
||||
state: application.state.state,
|
||||
role: application.state.state === "applied" ? application.state.role : undefined,
|
||||
revision: application.state.state === "applied" ? application.state.revision : undefined,
|
||||
digest: application.state.state === "applied" ? application.state.digest : undefined,
|
||||
cause: application.state.state === "fail-closed" ? application.state.cause : undefined,
|
||||
removed: application.plan ? application.plan.toRemove : [],
|
||||
added: application.plan ? application.plan.toAdd : [],
|
||||
});
|
||||
} catch (e) { console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)"); }
|
||||
} catch (reconErr) {
|
||||
// Platform call threw mid-reconciliation: the provisional fail-closed
|
||||
// state GOVERNS — the old applied policy is dead.
|
||||
// NG4-CAUSE: STABLE cause only — never embed the raw exception.
|
||||
// status EXPOSES cause; an injected/platform error could carry
|
||||
// sensitive content into model-visible denial reasons.
|
||||
enforcement = failClosedState(incarnationId, "reconciliation-threw", brainHome, roleHint);
|
||||
try { pi.setActiveTools([...enforcement.allowed].sort()); } catch { /* best-effort; interception governs */ }
|
||||
try { journal.append({ kind: "reconciliation", state: "fail-closed", cause: "reconciliation-threw", removed: [], added: [] }); } catch { /* evidence never bypasses enforcement */ }
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event: PiToolCallEvent) => {
|
||||
// F7-(1): wire event.input through so the read-containment check sees
|
||||
// the path argument. F7-B precision: for read-family tools (read,
|
||||
// grep, find, ls) with an ABSENT path, Pi executes against the tool's
|
||||
// own cwd — an uncontrolled location. We MUTATE the input to pin the
|
||||
// path to the trusted brain home (Pi guarantees mutation affects
|
||||
// execution and is not revalidated). The test suite asserts the
|
||||
// executed input IS pinned, not just the decision result.
|
||||
// F7E: read.path is REQUIRED in Pi — absent/malformed input is DENIED
|
||||
// by the enforcement layer (no synthesis, no cwd ambiguity). Only ls
|
||||
// gets absent-path pinning (it lists a directory, not a file, and its
|
||||
// path is genuinely optional in Pi).
|
||||
if ((SAFE_READ_TOOLS as readonly string[]).includes(event.toolName)) {
|
||||
// (2) ls with UNDEFINED input: assign {path: brainHome} (safe dir listing)
|
||||
// FIX2-(1): synthesize ONLY when event.input is EXACTLY undefined.
|
||||
// null / 42 / [] are malformed and must reach stable DENIAL, not be
|
||||
// silently mutated into a valid input.
|
||||
if (event.toolName === "ls" && event.input === undefined) {
|
||||
event.input = { path: brainHome };
|
||||
}
|
||||
if (event.input && typeof event.input === "object") {
|
||||
const input = event.input as { path?: unknown };
|
||||
// ls with absent path in an existing object: pin to brainHome
|
||||
if (event.toolName === "ls" && (input.path === undefined || input.path === "")) {
|
||||
input.path = brainHome;
|
||||
}
|
||||
// (3) Resolve RELATIVE paths against brainHome BEFORE realpath
|
||||
if (typeof input.path === "string" && input.path !== "" && !input.path.startsWith("/")) {
|
||||
input.path = brainHome + "/" + input.path;
|
||||
}
|
||||
// F7C-(1): realpath existing targets — symlink escape resolution
|
||||
if (typeof input.path === "string" && input.path !== "") {
|
||||
try {
|
||||
input.path = realpathSync(input.path);
|
||||
} catch {
|
||||
// path doesn't exist yet — leave as-is; containment handles traversal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.toolName === "goal_report" && activeGoalPolicy) {
|
||||
const policy = verifyGoalPolicyPublication(activeGoalPolicy);
|
||||
if (!policy.ok) {
|
||||
const reason = `mosaic-core:goal-policy-denied:${policy.reason}`;
|
||||
try {
|
||||
journal.append({ kind: "denial", tool: event.toolName, reason });
|
||||
} catch {
|
||||
console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)");
|
||||
}
|
||||
return { block: true, reason };
|
||||
}
|
||||
}
|
||||
const decision = decideToolCall(enforcement, event.toolName, event.input, brainHome);
|
||||
if (decision.block) {
|
||||
// PART B: evidence failure must NEVER bypass enforcement — the denial
|
||||
// returns even if the journal append throws (disk full, permission,
|
||||
// injected failure)
|
||||
try {
|
||||
journal.append({ kind: "denial", tool: event.toolName, reason: decision.reason });
|
||||
} catch (journalErr) {
|
||||
console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)");
|
||||
}
|
||||
return { block: true, reason: decision.reason };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
// [NG-2 enforcement binding end]
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// lib/enforce.ts — pure enforcement state machine (PRD R3-R5, AC3/AC5/AC6).
|
||||
//
|
||||
// Two states, one decision function:
|
||||
// applied — a valid trusted manifest governs; allowed = the exact
|
||||
// manifest tool projection. Registered extension tools that
|
||||
// lack a bound capability remain unavailable.
|
||||
// fail-closed — load/validation/postcondition failed; allowed = SAFE_READ_TOOLS
|
||||
// + the status tool ONLY (AC5: safe reads and status survive;
|
||||
// every side-effecting tool blocks with a stable reason).
|
||||
//
|
||||
// Reason codes are STABLE STRINGS (AC3) — they appear in logs, journals, and
|
||||
// tests, so the format is part of the contract:
|
||||
// mosaic-core:tool-not-in-manifest:<tool>
|
||||
// mosaic-core:fail-closed:<cause>:<tool>
|
||||
// mosaic-core:not-applied:<tool>
|
||||
// mosaic-core:read-missing-path
|
||||
// mosaic-core:read-scope-unbound
|
||||
// mosaic-core:read-credential-denied:<canonical-path>
|
||||
// mosaic-core:read-outside-scope:<canonical-path>
|
||||
// mosaic-core:read-symlink-escape:<canonical-path>
|
||||
|
||||
import type { ReconcilePlan } from "./reconcile.ts";
|
||||
import { reconcileActiveTools } from "./reconcile.ts";
|
||||
import type { LoadOutcome } from "./loader.ts";
|
||||
|
||||
export const STATUS_TOOL = "mosaic_policy_status";
|
||||
export const PROPOSAL_TOOL = "mosaic_improvement_propose";
|
||||
export const GATE_MERGE_ROLE = "gate-merge-ng";
|
||||
/**
|
||||
* F7D (mercer): only DIRECTLY-CONSTRAINABLE read tools survive fail-closed.
|
||||
* `read` reads exactly one file (path argument IS the target); `ls` lists one
|
||||
* directory non-recursively. `grep` and `find` RECURSE below the path root —
|
||||
* they can enumerate and expose credential-shaped descendants
|
||||
* (brainHome/auth, fleet/agents/<seat>/secrets, junk) even when the root path
|
||||
* passes containment. gitignore is NOT an authorization boundary. Applied
|
||||
* manifests already bind only C3->read; exposing grep/find in degraded
|
||||
* state INCREASES authority. AC5 says safe reads, not every read-family tool.
|
||||
*/
|
||||
export const SAFE_READ_TOOLS = ["read", "ls"] as const;
|
||||
export const SYMBOLIC_BRAIN = "@brain";
|
||||
|
||||
export type EnforcementState =
|
||||
| {
|
||||
state: "applied";
|
||||
incarnationId: string;
|
||||
role: string;
|
||||
revision: number;
|
||||
digest: string;
|
||||
allowed: ReadonlySet<string>;
|
||||
/** C3 read-scope roots. Symbolic "@brain" or absolute. Empty = C3 unbound. */
|
||||
readScope: readonly string[];
|
||||
}
|
||||
| {
|
||||
state: "fail-closed";
|
||||
incarnationId: string;
|
||||
cause: string;
|
||||
allowed: ReadonlySet<string>;
|
||||
readScope: readonly string[];
|
||||
}
|
||||
| { state: "not-applied"; incarnationId: string };
|
||||
|
||||
export interface PolicyApplicationInput {
|
||||
load: LoadOutcome;
|
||||
activeTools: string[];
|
||||
incarnationId: string;
|
||||
/** Role resolved from the seat profile before a v4 load can fail. */
|
||||
roleHint?: string;
|
||||
/** The trusted brain home (for resolving the symbolic @brain root). */
|
||||
brainHome?: string;
|
||||
}
|
||||
|
||||
export interface PolicyApplication {
|
||||
state: EnforcementState;
|
||||
plan?: ReconcilePlan;
|
||||
/** the exact tool set setActiveTools must receive */
|
||||
applyTools: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A gate role's reviewed manifest explicitly forbids read, ls, and proposal.
|
||||
* On any gate load or reconciliation failure, retaining the generic safe-read
|
||||
* posture would broaden that role's active authority. Other roles retain the
|
||||
* established v3 degraded posture for compatibility.
|
||||
*/
|
||||
export function failClosedState(
|
||||
incarnationId: string,
|
||||
cause: string,
|
||||
brainHome?: string,
|
||||
roleHint?: string,
|
||||
): Extract<EnforcementState, { state: "fail-closed" }> {
|
||||
const gateRole = roleHint === GATE_MERGE_ROLE;
|
||||
const allowed = gateRole
|
||||
? new Set([STATUS_TOOL])
|
||||
: new Set([...SAFE_READ_TOOLS, STATUS_TOOL, PROPOSAL_TOOL]);
|
||||
return {
|
||||
state: "fail-closed",
|
||||
incarnationId,
|
||||
cause,
|
||||
allowed,
|
||||
readScope: gateRole ? [] : brainHome ? [brainHome] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildApplication(input: PolicyApplicationInput): PolicyApplication {
|
||||
const { load, activeTools, incarnationId, brainHome, roleHint } = input;
|
||||
if (!load.ok) {
|
||||
const state = failClosedState(incarnationId, describeFailure(load.failure), brainHome, roleHint);
|
||||
return { state, applyTools: [...state.allowed].sort() };
|
||||
}
|
||||
const m = load.manifest;
|
||||
const allowed = new Set(m.tools);
|
||||
const plan = reconcileActiveTools(m.tools, activeTools);
|
||||
const c3 = m.capabilities.find((c) => c.id === "repo.file.read");
|
||||
const rawScope = c3 && c3.status === "bound" ? m.workspace.readRoots : [];
|
||||
const readScope = resolveReadScope(rawScope, brainHome);
|
||||
return {
|
||||
state: {
|
||||
state: "applied",
|
||||
incarnationId,
|
||||
role: m.role,
|
||||
revision: m.revision,
|
||||
digest: load.digest,
|
||||
allowed,
|
||||
readScope,
|
||||
},
|
||||
plan,
|
||||
applyTools: [...allowed].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* F7-(4): resolve the symbolic "@brain" root to the trusted brain home;
|
||||
* absolute roots pass through unchanged.
|
||||
*/
|
||||
export function resolveReadScope(roots: readonly string[], brainHome?: string): string[] {
|
||||
return roots.map((r) => (r === SYMBOLIC_BRAIN && brainHome ? brainHome : r));
|
||||
}
|
||||
|
||||
export function describeFailure(f: import("./loader.ts").LoadFailure): string {
|
||||
switch (f.stage) {
|
||||
case "no-agent-name":
|
||||
return "no-agent-name";
|
||||
case "no-seat-profile":
|
||||
return "no-seat-profile";
|
||||
case "no-role-in-profile":
|
||||
return "no-role-in-profile";
|
||||
case "manifest-unreadable":
|
||||
return "manifest-unreadable";
|
||||
case "manifest-symlink":
|
||||
return "manifest-symlink";
|
||||
case "invalid-json":
|
||||
return "invalid-json";
|
||||
case "schema":
|
||||
return `schema:${f.reason}`;
|
||||
case "role-mismatch":
|
||||
return `role-mismatch:${f.expected}!=${f.got}`;
|
||||
case "goal-policy-unreadable":
|
||||
case "goal-policy-root-symlink":
|
||||
case "goal-policy-parent-symlink":
|
||||
case "goal-policy-symlink":
|
||||
case "goal-policy-path-escape":
|
||||
case "goal-policy-non-file":
|
||||
case "goal-policy-invalid-utf8":
|
||||
case "goal-policy-digest":
|
||||
case "goal-policy-section":
|
||||
case "goal-policy-launch-generation":
|
||||
case "goal-policy-incarnation":
|
||||
case "goal-policy-publication":
|
||||
return f.stage;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Decision {
|
||||
block: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* F7-(5): credential deny pattern — covers secrets/ and auth/ directories,
|
||||
* .token/.key/.pem/.env* file types, and any path containing "credential".
|
||||
* Applied AFTER canonical resolution so traversal cannot bypass it.
|
||||
*/
|
||||
const CREDENTIAL_PATH_RE = /(?:^|\/)(?:secrets?|auth)(?:\/|$)/i;
|
||||
const CREDENTIAL_FILE_RE = /\.(?:token|key|pem|env)(?:\.|$)|credential|fleet\/agents\/junk/i;
|
||||
const READ_TOOL_BINDING = "read"; // canonical Pi read tool
|
||||
|
||||
function isCredentialPath(canonical: string): boolean {
|
||||
return CREDENTIAL_PATH_RE.test(canonical) || CREDENTIAL_FILE_RE.test(canonical);
|
||||
}
|
||||
|
||||
/**
|
||||
* F7-(2): canonicalize a path — resolve `..` and `.` segments lexically,
|
||||
* resolve relative paths against the trusted root. The ADAPTER must
|
||||
* additionally realpath() existing targets to catch symlink escapes
|
||||
* before calling this function (see decideToolCallWithFs).
|
||||
*/
|
||||
export function canonicalizePath(rawPath: string, relativeRoot?: string): string {
|
||||
let p = rawPath;
|
||||
// resolve relative against the trusted root (never cwd)
|
||||
if (!p.startsWith("/") && relativeRoot) {
|
||||
p = relativeRoot + "/" + p;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const seg of p.split("/")) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") { parts.pop(); continue; }
|
||||
parts.push(seg);
|
||||
}
|
||||
return "/" + parts.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* F7-(2,5): read containment with credential denial AFTER canonical resolution.
|
||||
* readScope roots must themselves be canonical absolute paths.
|
||||
* Returns ok only if the canonical path falls under a declared root AND
|
||||
* does not match the credential deny pattern post-resolution.
|
||||
*/
|
||||
export function readPathAllowed(
|
||||
readScope: readonly string[],
|
||||
rawPath: string,
|
||||
relativeRoot?: string,
|
||||
): { ok: boolean; reason?: string } {
|
||||
if (readScope.length === 0) return { ok: false, reason: "mosaic-core:read-scope-unbound" };
|
||||
// F7-(2,3): missing, empty, or NON-STRING path is DENIED, not passed through
|
||||
if (typeof rawPath !== "string" || rawPath === "") {
|
||||
return { ok: false, reason: "mosaic-core:read-missing-path" };
|
||||
}
|
||||
const canonical = canonicalizePath(rawPath, relativeRoot);
|
||||
// F7-(5): credential denial AFTER canonical resolution
|
||||
if (isCredentialPath(canonical)) {
|
||||
return { ok: false, reason: `mosaic-core:read-credential-denied:${canonical}` };
|
||||
}
|
||||
for (const root of readScope) {
|
||||
if (canonical === root || canonical.startsWith(root + "/")) {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: `mosaic-core:read-outside-scope:${canonical}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* F7-(3): safe-read constraint — the SAFE_READ_TOOLS that survive
|
||||
* fail-closed must still respect the credential deny pattern.
|
||||
* Without this, a malformed manifest would EXPOSE broader reads than an
|
||||
* applied role. Safe reads get NO workspace scope (scope is empty in
|
||||
* fail-closed/not-applied), so read is denied; grep/glob/ls still have
|
||||
* credential-shaped path denial applied to their arguments.
|
||||
*/
|
||||
/**
|
||||
* F7-B (mercer NG4-F7B): all four Pi safe-read built-ins (read, grep, find,
|
||||
* ls) take an optional path argument; absent path defaults to the tool's
|
||||
* cwd. This function treats ABSENT path as the trusted root (in scope by
|
||||
* definition) and a PROVIDED path through canonical containment + credential
|
||||
* denial. In fail-closed, the scope IS the trusted brain home so reads
|
||||
* within the brain tree survive; reads outside are denied.
|
||||
*/
|
||||
export function safeReadAllowed(
|
||||
toolName: string,
|
||||
readScope: readonly string[],
|
||||
args?: unknown,
|
||||
trustedRoot?: string,
|
||||
): { ok: boolean; reason?: string } {
|
||||
if (toolName === STATUS_TOOL || toolName === PROPOSAL_TOOL) return { ok: true };
|
||||
|
||||
// F7F: tool-specific input shape — read REQUIRES a non-array object with
|
||||
// a nonempty string path; ls may accept undefined/{} but non-object/null/
|
||||
// array or non-string path DENIES. The pure decision must align with the
|
||||
// adapter (which synthesizes ls input ONLY when undefined).
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
if (toolName === READ_TOOL_BINDING) {
|
||||
// read: args must be a non-array object with nonempty string path
|
||||
if (!isObject(args)) {
|
||||
return { ok: false, reason: "mosaic-core:read-missing-path" };
|
||||
}
|
||||
const path = args.path;
|
||||
if (typeof path !== "string" || path === "") {
|
||||
return { ok: false, reason: "mosaic-core:read-missing-path" };
|
||||
}
|
||||
// containment + credential denial
|
||||
const effectiveScope = readScope.length > 0 ? readScope : trustedRoot ? [trustedRoot] : [];
|
||||
const canonical = canonicalizePath(path, trustedRoot);
|
||||
if (isCredentialPath(canonical)) {
|
||||
return { ok: false, reason: `mosaic-core:read-credential-denied:${canonical}` };
|
||||
}
|
||||
if (effectiveScope.length === 0) {
|
||||
return { ok: false, reason: "mosaic-core:read-scope-unbound" };
|
||||
}
|
||||
for (const root of effectiveScope) {
|
||||
if (canonical === root || canonical.startsWith(root + "/")) return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: `mosaic-core:read-outside-scope:${canonical}` };
|
||||
}
|
||||
|
||||
// ls: undefined args OK (adapter synthesizes); non-object/null/array DENIES
|
||||
if (args !== undefined && !isObject(args)) {
|
||||
return { ok: false, reason: "mosaic-core:read-missing-path" };
|
||||
}
|
||||
const path = isObject(args) ? args.path : undefined;
|
||||
if (path !== undefined && typeof path !== "string") {
|
||||
return { ok: false, reason: "mosaic-core:read-missing-path" };
|
||||
}
|
||||
// credential check on the resolved path (or brainHome for absent)
|
||||
const effectiveScope = readScope.length > 0 ? readScope : trustedRoot ? [trustedRoot] : [];
|
||||
const effectivePath = typeof path === "string" && path !== "" ? path : (trustedRoot ?? "/");
|
||||
const canonical = canonicalizePath(effectivePath, trustedRoot);
|
||||
if (isCredentialPath(canonical)) {
|
||||
return { ok: false, reason: `mosaic-core:read-credential-denied:${canonical}` };
|
||||
}
|
||||
if (effectiveScope.length === 0) {
|
||||
return { ok: false, reason: "mosaic-core:read-scope-unbound" };
|
||||
}
|
||||
for (const root of effectiveScope) {
|
||||
if (canonical === root || canonical.startsWith(root + "/")) return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: `mosaic-core:read-outside-scope:${canonical}` };
|
||||
}
|
||||
|
||||
export function decideToolCall(state: EnforcementState, toolName: string, args?: unknown, relativeRoot?: string): Decision {
|
||||
switch (state.state) {
|
||||
case "not-applied":
|
||||
if (toolName === STATUS_TOOL || toolName === PROPOSAL_TOOL || (SAFE_READ_TOOLS as readonly string[]).includes(toolName)) {
|
||||
const safe = safeReadAllowed(toolName, [], args, relativeRoot);
|
||||
if (!safe.ok) return { block: true, reason: safe.reason };
|
||||
return { block: false };
|
||||
}
|
||||
return { block: true, reason: `mosaic-core:not-applied:${toolName}` };
|
||||
case "fail-closed":
|
||||
if (state.allowed.has(toolName)) {
|
||||
const safe = safeReadAllowed(toolName, state.readScope ?? [], args, relativeRoot);
|
||||
if (!safe.ok) return { block: true, reason: safe.reason };
|
||||
return { block: false };
|
||||
}
|
||||
return { block: true, reason: `mosaic-core:fail-closed:${state.cause}:${toolName}` };
|
||||
case "applied":
|
||||
if (state.allowed.has(toolName)) {
|
||||
// F7-(2): C3 read containment. Missing path DENIED. Relative paths
|
||||
// resolved against the trusted brain root. The adapter must
|
||||
// additionally realpath() existing targets before deciding.
|
||||
if (toolName === READ_TOOL_BINDING && state.readScope.length > 0) {
|
||||
const path = args && typeof args === "object" ? (args as { path?: unknown }).path : undefined;
|
||||
const scope = readPathAllowed(state.readScope, typeof path === "string" ? path : "", relativeRoot);
|
||||
if (!scope.ok) return { block: true, reason: scope.reason };
|
||||
}
|
||||
return { block: false };
|
||||
}
|
||||
return { block: true, reason: `mosaic-core:tool-not-in-manifest:${toolName}` };
|
||||
}
|
||||
}
|
||||
|
||||
export function statusSnapshot(state: EnforcementState, activeTools: string[]): Record<string, unknown> {
|
||||
if (state.state === "applied") {
|
||||
return {
|
||||
state: state.state,
|
||||
role: state.role,
|
||||
revision: state.revision,
|
||||
digest: state.digest,
|
||||
incarnationId: state.incarnationId,
|
||||
activeTools: [...activeTools].sort(),
|
||||
};
|
||||
}
|
||||
if (state.state === "fail-closed") {
|
||||
return {
|
||||
state: state.state,
|
||||
cause: state.cause,
|
||||
incarnationId: state.incarnationId,
|
||||
activeTools: [...activeTools].sort(),
|
||||
};
|
||||
}
|
||||
return { state: state.state, incarnationId: state.incarnationId, activeTools: [...activeTools].sort() };
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// lib/gate-record.ts — T165 WP1 pure E1/E2 contracts.
|
||||
//
|
||||
// This module is deliberately isolated from brokers, adapters, storage, and
|
||||
// provider clients. It supplies closed record validation, RFC 8785 canonical
|
||||
// bytes, and digest/idempotency derivation for a later canonical writer.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const MG_CODES = ["MG01", "MG02", "MG03", "MG04", "MG05", "MG06", "MG07", "MG08"] as const;
|
||||
export type MgCode = typeof MG_CODES[number];
|
||||
export const FIXED_TARGET = {
|
||||
host: "git.mosaicstack.dev",
|
||||
repository: "mosaicstack/stack",
|
||||
pullRequest: 1491,
|
||||
baseBranch: "next",
|
||||
headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68",
|
||||
headTreeSha: "6697568b78c3a910e98f848a3aa4b054d80343d8",
|
||||
} as const;
|
||||
|
||||
/** Normative CDDL identifiers. Semantic constraints below make all maps closed. */
|
||||
export const GATE_RECORD_V1_CDDL = String.raw`GateRecordV1 = GateEvidenceBundleV1 / GateVerdictV1
|
||||
GateEvidenceBundleV1 = { schemaVersion: 1, recordType: "gate.merge.evidence", evidenceBundleId: uuid-v4, bundleSha256: sha256, gateProfile: "merge", requestedTransition: requested-transition, operationRequestId: uuid-v4, actorContext: actor-context, missionContext: mission-context, target: target, evidence: [8*64 evidence-item], securityReview: security-review, requiredContextSetSha256: sha256, observedAt: timestamp, expiresAt: timestamp, sensitivity: "internal" }
|
||||
GateVerdictV1 = { schemaVersion: 1, recordType: "gate.merge.verdict", verdictId: uuid-v4, transitionId: uuid-v4, idempotencyKey: sha256, operationRequestId: uuid-v4, actorContext: actor-context, missionContext: mission-context, authority: authority, transition: transition, target: target, evidenceBundleId: uuid-v4, evidenceBundleSha256: sha256, decision: decision, validity: validity, sensitivity: "internal", recordSha256: sha256 }
|
||||
requested-transition = { requestedFrom: "reviewed", requestedTo: "authorized-integration" }
|
||||
actor-context = { principalId: opaque-id, seatId: role-slug, incarnationId: uuid-v4, sessionId: opaque-id, roleId: "gate-merge-ng", roleRevision: 1, roleDigest: sha256, assignmentId: uuid-v4, assignmentRevision: positive-int, leaseId: uuid-v4, leaseRevision: positive-int, fencingToken: positive-int }
|
||||
mission-context = { missionId: uuid-v4, workUnitId: uuid-v4, workflowState: "reviewed", workflowRevision: positive-int }
|
||||
target = { host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, baseBranch: "next", baseHeadSha: git-sha, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68", headTreeSha: "6697568b78c3a910e98f848a3aa4b054d80343d8", diffSha256: sha256, changedPathsSha256: sha256 }
|
||||
evidence-item = { evidenceId: uuid-v4, criterionCode: criterion-code, class: evidence-class, evidenceKind: evidence-kind, producer: producer, capability: evidence-capability, source: evidence-source, targetBinding: target-binding, result: evidence-result, observedAt: timestamp, freshnessClass: freshness-class, expiresAt: timestamp / null, invalidationKeys: [1*32 invalidation-key] }
|
||||
producer = { producerId: opaque-id, principalId: opaque-id, roleId: role-slug, roleRevision: positive-int, roleDigest: sha256, relationship: "independent" / "system-observer", independentOf: [*16 opaque-id] }
|
||||
evidence-capability = { id: "repo.target.inspect" / "gate.evidence.observe", binding: "mosaic_gate_target_read" / "mosaic_gate_evidence_observe", backend: "mosaic-gate-broker" }
|
||||
evidence-source = { sourceType: "gitea-wrapper" / "woodpecker-wrapper" / "git-object" / "brain-artifact" / "coordinator-record" / "runtime-attestation" / "journal-record", locator: locator, sourceSha256: sha256, immutable: true }
|
||||
target-binding = { host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, baseHeadSha: git-sha, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68" }
|
||||
evidence-result = { outcome: "pass" / "fail" / "blocked", reasonCode: reason-code / null }
|
||||
security-review = { triggered: bool, triggerPolicyDigest: sha256, requiredEvidenceId: uuid-v4 / null }
|
||||
authority = { policyVersion: 4, policyDigest: sha256, roleBindingId: uuid-v4, roleId: "gate-merge-ng", roleRevision: 1, roleDigest: sha256, intentId: uuid-v4, decisionId: uuid-v4, grantId: uuid-v4, invocationId: uuid-v4, assignmentId: uuid-v4, assignmentRevision: positive-int, leaseId: uuid-v4, leaseRevision: positive-int, fencingToken: positive-int, leaseExpiresAt: timestamp }
|
||||
transition = { domain: "gate", objectId: "git.mosaicstack.dev/mosaicstack/stack#1491:merge", requestedFrom: "reviewed", requestedTo: "authorized-integration", expectedStateRevision: positive-int, previousState: "reviewed", previousRevision: positive-int, resultingState: "passed" / "failed" / "blocked", resultingRevision: positive-int }
|
||||
decision = { verdict: "PASS" / "FAIL" / "BLOCKED", criteria: [criterion-mg01, criterion-mg02, criterion-mg03, criterion-mg04, criterion-mg05, criterion-mg06, criterion-mg07, criterion-mg08], summary: summary, failedCriteria: [*8 criterion-code], blockedCriteria: [*8 criterion-code], reasonCodes: [*8 reason-code], authorizedNextOperation: authorized-next-operation / null }
|
||||
validity = { issuedAt: timestamp, expiresAt: timestamp, invalidationKeys: [1*32 invalidation-key] }
|
||||
criterion-mg01 = { code: "MG01", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg02 = { code: "MG02", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg03 = { code: "MG03", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg04 = { code: "MG04", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg05 = { code: "MG05", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg06 = { code: "MG06", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg07 = { code: "MG07", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg08 = { code: "MG08", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
authorized-next-operation = { capability: "operation.merge", host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68", baseBranch: "next", strategy: "squash", operationGrantId: null }
|
||||
criterion-code = "MG01" / "MG02" / "MG03" / "MG04" / "MG05" / "MG06" / "MG07" / "MG08"
|
||||
criterion-outcome = "pass" / "fail" / "blocked"
|
||||
evidence-class = "observation" / "verification" / "review"
|
||||
evidence-kind = "pr-metadata" / "pr-diff" / "changed-paths" / "code-review" / "ci-contexts" / "merge-queue" / "security-trigger" / "security-review" / "repository-declaration" / "coordinator-authority" / "independence" / "runtime-attestation" / "journal-attestation"
|
||||
freshness-class = "pr-live" / "queue-live" / "ci-head" / "review-head" / "security-policy" / "authority-submit" / "runtime-incarnation"
|
||||
uuid-v4 = tstr .regexp "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
sha256 = tstr .regexp "^[0-9a-f]{64}$"
|
||||
git-sha = tstr .regexp "^[0-9a-f]{40}$"
|
||||
role-slug = tstr .regexp "^[a-z][a-z0-9-]{0,63}$"
|
||||
opaque-id = tstr .regexp "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
timestamp = tstr .regexp "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9][.][0-9]{3}Z$"
|
||||
locator = tstr .size (1..1024)
|
||||
summary = tstr .size (1..500)
|
||||
positive-int = 1..9007199254740991
|
||||
reason-code = "TARGET_IDENTITY_MISMATCH" / "TARGET_HEAD_MISMATCH" / "TARGET_TREE_MISMATCH" / "TARGET_BASE_MISMATCH" / "TARGET_DIFF_MISMATCH" / "TARGET_CHANGED_PATHS_MISMATCH" / "TARGET_STATE_UNAVAILABLE" / "TARGET_DRIFT_REOBSERVE" / "REVIEW_REJECTED" / "REVIEW_TARGET_MISMATCH" / "REVIEW_INDEPENDENCE_VIOLATION" / "REVIEW_MISSING" / "REVIEW_STALE" / "REVIEW_INDEPENDENCE_UNAVAILABLE" / "SECURITY_REVIEW_REJECTED" / "SECURITY_POLICY_VIOLATION" / "SECURITY_REVIEW_MISSING" / "SECURITY_POLICY_UNAVAILABLE" / "SECURITY_EVIDENCE_STALE" / "CI_RED" / "CI_REQUIRED_CONTEXT_MISSING" / "CI_PENDING" / "CI_STATE_UNAVAILABLE" / "CI_EVIDENCE_STALE" / "QUEUE_BUSY" / "QUEUE_INDETERMINATE" / "QUEUE_EVIDENCE_STALE" / "PR_CLOSED" / "PR_DRAFT" / "PR_NOT_MERGEABLE" / "TRUNK_MISMATCH" / "MERGE_STRATEGY_MISMATCH" / "DECLARATION_INVALID" / "PR_STATE_UNAVAILABLE" / "DECLARATION_UNAVAILABLE" / "ASSIGNMENT_MISSING" / "AUTHORITY_EXPIRED" / "LEASE_EXPIRED" / "FENCE_STALE" / "WORKFLOW_REVISION_STALE" / "OPERATION_REQUEST_MISMATCH" / "AUTHORITY_BACKEND_UNAVAILABLE" / "GATE_CAPABILITY_UNAVAILABLE" / "BROKER_UNAVAILABLE" / "RUNTIME_ATTESTATION_UNAVAILABLE" / "RUNTIME_POLICY_VIOLATION" / "ROLE_RECONCILIATION_MISMATCH" / "MODEL_FLOOR_UNMET" / "JOURNAL_UNAVAILABLE" / "DENIED_PATH_CONTROL_FAILED" / "INDEPENDENCE_VIOLATION" / "INDEPENDENCE_UNAVAILABLE" / "EVIDENCE_MALFORMED" / "EVIDENCE_STALE" / "EVIDENCE_CONTRADICTORY"
|
||||
invalidation-key = { kind: "pr-head" / "head-tree" / "diff" / "changed-paths" / "base-branch" / "base-head" / "pr-state" / "mergeability" / "required-context-set" / "ci-state" / "queue-state" / "review-state" / "review-independence" / "security-policy" / "security-verdict" / "mission-revision" / "work-unit-revision" / "workflow-revision" / "operation-request" / "assignment-revision" / "lease-revision" / "fencing-token" / "incarnation" / "role-binding" / "active-tool-set" / "policy-set" / "broker-availability" / "expires-at", valueSha256: sha256 }`;
|
||||
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
const GIT_SHA = /^[0-9a-f]{40}$/;
|
||||
const ROLE = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
const OPAQUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TIMESTAMP = /^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\.[0-9]{3}Z$/;
|
||||
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
||||
const EVIDENCE_KINDS = new Set(["pr-metadata", "pr-diff", "changed-paths", "code-review", "ci-contexts", "merge-queue", "security-trigger", "security-review", "repository-declaration", "coordinator-authority", "independence", "runtime-attestation", "journal-attestation"]);
|
||||
const SOURCE_TYPES = new Set(["gitea-wrapper", "woodpecker-wrapper", "git-object", "brain-artifact", "coordinator-record", "runtime-attestation", "journal-record"]);
|
||||
const INVALIDATION_KINDS = new Set(["pr-head", "head-tree", "diff", "changed-paths", "base-branch", "base-head", "pr-state", "mergeability", "required-context-set", "ci-state", "queue-state", "review-state", "review-independence", "security-policy", "security-verdict", "mission-revision", "work-unit-revision", "workflow-revision", "operation-request", "assignment-revision", "lease-revision", "fencing-token", "incarnation", "role-binding", "active-tool-set", "policy-set", "broker-availability", "expires-at"]);
|
||||
|
||||
const REASONS: Record<MgCode, { fail: readonly string[]; blocked: readonly string[] }> = {
|
||||
MG01: { fail: ["TARGET_IDENTITY_MISMATCH", "TARGET_HEAD_MISMATCH", "TARGET_TREE_MISMATCH", "TARGET_BASE_MISMATCH", "TARGET_DIFF_MISMATCH", "TARGET_CHANGED_PATHS_MISMATCH"], blocked: ["TARGET_STATE_UNAVAILABLE", "TARGET_DRIFT_REOBSERVE"] },
|
||||
MG02: { fail: ["REVIEW_REJECTED", "REVIEW_TARGET_MISMATCH", "REVIEW_INDEPENDENCE_VIOLATION"], blocked: ["REVIEW_MISSING", "REVIEW_STALE", "REVIEW_INDEPENDENCE_UNAVAILABLE"] },
|
||||
MG03: { fail: ["SECURITY_REVIEW_REJECTED", "SECURITY_POLICY_VIOLATION"], blocked: ["SECURITY_REVIEW_MISSING", "SECURITY_POLICY_UNAVAILABLE", "SECURITY_EVIDENCE_STALE"] },
|
||||
MG04: { fail: ["CI_RED", "CI_REQUIRED_CONTEXT_MISSING"], blocked: ["CI_PENDING", "CI_STATE_UNAVAILABLE", "CI_EVIDENCE_STALE"] },
|
||||
MG05: { fail: [], blocked: ["QUEUE_BUSY", "QUEUE_INDETERMINATE", "QUEUE_EVIDENCE_STALE"] },
|
||||
MG06: { fail: ["PR_CLOSED", "PR_DRAFT", "PR_NOT_MERGEABLE", "TRUNK_MISMATCH", "MERGE_STRATEGY_MISMATCH", "DECLARATION_INVALID"], blocked: ["PR_STATE_UNAVAILABLE", "DECLARATION_UNAVAILABLE"] },
|
||||
MG07: { fail: [], blocked: ["ASSIGNMENT_MISSING", "AUTHORITY_EXPIRED", "LEASE_EXPIRED", "FENCE_STALE", "WORKFLOW_REVISION_STALE", "OPERATION_REQUEST_MISMATCH", "AUTHORITY_BACKEND_UNAVAILABLE"] },
|
||||
MG08: { fail: ["RUNTIME_POLICY_VIOLATION", "ROLE_RECONCILIATION_MISMATCH", "MODEL_FLOOR_UNMET", "DENIED_PATH_CONTROL_FAILED", "INDEPENDENCE_VIOLATION"], blocked: ["GATE_CAPABILITY_UNAVAILABLE", "BROKER_UNAVAILABLE", "RUNTIME_ATTESTATION_UNAVAILABLE", "JOURNAL_UNAVAILABLE", "INDEPENDENCE_UNAVAILABLE"] },
|
||||
};
|
||||
const UNIVERSAL_BLOCKED = ["EVIDENCE_MALFORMED", "EVIDENCE_STALE", "EVIDENCE_CONTRADICTORY"] as const;
|
||||
|
||||
export type GateRecordValidation = Readonly<{ ok: true; canonical: string }> | Readonly<{ ok: false; reason: string }>;
|
||||
function fail(reason: string): GateRecordValidation { return Object.freeze({ ok: false, reason }); }
|
||||
function pass(value: unknown): GateRecordValidation { return Object.freeze({ ok: true, canonical: canonicalizeRfc8785(value) }); }
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
function closed(value: unknown, keys: readonly string[], label: string): Record<string, unknown> | undefined {
|
||||
if (!isObject(value)) return undefined;
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) return undefined;
|
||||
return value;
|
||||
}
|
||||
function text(value: unknown, min = 1, max = 1024): value is string {
|
||||
return typeof value === "string" && Buffer.byteLength(value, "utf8") >= min && Buffer.byteLength(value, "utf8") <= max && value.normalize("NFC") === value && !CONTROL.test(value);
|
||||
}
|
||||
function sha(value: unknown): value is string { return typeof value === "string" && SHA256.test(value); }
|
||||
function uuid(value: unknown): value is string { return typeof value === "string" && UUID_V4.test(value); }
|
||||
function opaque(value: unknown): value is string { return typeof value === "string" && OPAQUE.test(value) && Buffer.byteLength(value, "utf8") <= 128; }
|
||||
function positive(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 1; }
|
||||
function timestamp(value: unknown): value is string { return typeof value === "string" && TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value; }
|
||||
function stringSet(value: unknown, max: number, predicate: (entry: string) => boolean, ordered = true): value is string[] {
|
||||
return Array.isArray(value) && value.length <= max && value.every((entry) => typeof entry === "string" && predicate(entry)) && new Set(value).size === value.length && (!ordered || value.every((entry, index) => index === 0 || value[index - 1] < entry));
|
||||
}
|
||||
function exactCodes(value: unknown): value is MgCode[] { return Array.isArray(value) && value.length === MG_CODES.length && value.every((code, index) => code === MG_CODES[index]); }
|
||||
function reasonAllowed(code: MgCode, outcome: string, reason: unknown): boolean {
|
||||
if (outcome === "pass") return reason === null;
|
||||
if (outcome !== "fail" && outcome !== "blocked") return false;
|
||||
if (typeof reason !== "string") return false;
|
||||
return outcome === "fail"
|
||||
? REASONS[code].fail.includes(reason)
|
||||
: REASONS[code].blocked.includes(reason) || (UNIVERSAL_BLOCKED as readonly string[]).includes(reason);
|
||||
}
|
||||
function noExtraStringControls(value: unknown): boolean {
|
||||
if (typeof value === "string") return text(value, 0, Number.MAX_SAFE_INTEGER);
|
||||
if (typeof value === "number") return Number.isSafeInteger(value) && !Object.is(value, -0);
|
||||
if (value === null || typeof value === "boolean") return true;
|
||||
if (Array.isArray(value)) return value.every(noExtraStringControls);
|
||||
return isObject(value) && Object.entries(value).every(([key, nested]) => text(key, 0, Number.MAX_SAFE_INTEGER) && noExtraStringControls(nested));
|
||||
}
|
||||
|
||||
/** RFC 8785 JSON Canonicalization Scheme bytes, after I-JSON validation. */
|
||||
export function canonicalizeRfc8785(value: unknown): string {
|
||||
if (!noExtraStringControls(value)) throw new Error("not I-JSON");
|
||||
const render = (current: unknown): string => {
|
||||
if (current === null || typeof current === "boolean") return String(current);
|
||||
if (typeof current === "number") return JSON.stringify(current);
|
||||
if (typeof current === "string") return JSON.stringify(current);
|
||||
if (Array.isArray(current)) return `[${current.map(render).join(",")}]`;
|
||||
const object = current as Record<string, unknown>;
|
||||
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${render(object[key])}`).join(",")}}`;
|
||||
};
|
||||
return render(value);
|
||||
}
|
||||
export function sha256Canonical(value: unknown): string { return createHash("sha256").update(canonicalizeRfc8785(value), "utf8").digest("hex"); }
|
||||
function omitTop(record: Record<string, unknown>, field: string): Record<string, unknown> {
|
||||
const copy = { ...record };
|
||||
delete copy[field];
|
||||
return copy;
|
||||
}
|
||||
|
||||
function validTarget(value: unknown): value is Record<string, unknown> {
|
||||
const target = closed(value, ["host", "repository", "pullRequest", "baseBranch", "baseHeadSha", "headSha", "headTreeSha", "diffSha256", "changedPathsSha256"], "target");
|
||||
return !!target && target.host === FIXED_TARGET.host && target.repository === FIXED_TARGET.repository && target.pullRequest === FIXED_TARGET.pullRequest && target.baseBranch === FIXED_TARGET.baseBranch && typeof target.baseHeadSha === "string" && GIT_SHA.test(target.baseHeadSha) && target.headSha === FIXED_TARGET.headSha && target.headTreeSha === FIXED_TARGET.headTreeSha && sha(target.diffSha256) && sha(target.changedPathsSha256);
|
||||
}
|
||||
function validTargetBinding(value: unknown): boolean {
|
||||
const binding = closed(value, ["host", "repository", "pullRequest", "baseHeadSha", "headSha"], "targetBinding");
|
||||
return !!binding && binding.host === FIXED_TARGET.host && binding.repository === FIXED_TARGET.repository && binding.pullRequest === FIXED_TARGET.pullRequest && typeof binding.baseHeadSha === "string" && GIT_SHA.test(binding.baseHeadSha) && binding.headSha === FIXED_TARGET.headSha;
|
||||
}
|
||||
function validActor(value: unknown): value is Record<string, unknown> {
|
||||
const actor = closed(value, ["principalId", "seatId", "incarnationId", "sessionId", "roleId", "roleRevision", "roleDigest", "assignmentId", "assignmentRevision", "leaseId", "leaseRevision", "fencingToken"], "actor");
|
||||
return !!actor && opaque(actor.principalId) && typeof actor.seatId === "string" && ROLE.test(actor.seatId) && uuid(actor.incarnationId) && opaque(actor.sessionId) && actor.roleId === "gate-merge-ng" && actor.roleRevision === 1 && sha(actor.roleDigest) && uuid(actor.assignmentId) && positive(actor.assignmentRevision) && uuid(actor.leaseId) && positive(actor.leaseRevision) && positive(actor.fencingToken);
|
||||
}
|
||||
function validMission(value: unknown): value is Record<string, unknown> {
|
||||
const mission = closed(value, ["missionId", "workUnitId", "workflowState", "workflowRevision"], "mission");
|
||||
return !!mission && uuid(mission.missionId) && uuid(mission.workUnitId) && mission.workflowState === "reviewed" && positive(mission.workflowRevision);
|
||||
}
|
||||
function validInvalidationKeys(value: unknown): boolean {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > 32) return false;
|
||||
let previous = "";
|
||||
const seen = new Set<string>();
|
||||
for (const entry of value) {
|
||||
const key = closed(entry, ["kind", "valueSha256"], "invalidation");
|
||||
if (!key || typeof key.kind !== "string" || !INVALIDATION_KINDS.has(key.kind) || !sha(key.valueSha256)) return false;
|
||||
const order = `${key.kind}\u0000${key.valueSha256}`;
|
||||
if (order <= previous || seen.has(order)) return false;
|
||||
previous = order;
|
||||
seen.add(order);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function validProducer(value: unknown): boolean {
|
||||
const producer = closed(value, ["producerId", "principalId", "roleId", "roleRevision", "roleDigest", "relationship", "independentOf"], "producer");
|
||||
if (!producer || !opaque(producer.producerId) || !opaque(producer.principalId) || typeof producer.roleId !== "string" || !ROLE.test(producer.roleId) || !positive(producer.roleRevision) || !sha(producer.roleDigest) || (producer.relationship !== "independent" && producer.relationship !== "system-observer") || !stringSet(producer.independentOf, 16, opaque)) return false;
|
||||
return !producer.independentOf.includes(producer.principalId);
|
||||
}
|
||||
function validCapability(value: unknown): boolean {
|
||||
const capability = closed(value, ["id", "binding", "backend"], "capability");
|
||||
if (!capability || capability.backend !== "mosaic-gate-broker") return false;
|
||||
return (capability.id === "repo.target.inspect" && capability.binding === "mosaic_gate_target_read") || (capability.id === "gate.evidence.observe" && capability.binding === "mosaic_gate_evidence_observe");
|
||||
}
|
||||
function validSource(value: unknown): boolean {
|
||||
const source = closed(value, ["sourceType", "locator", "sourceSha256", "immutable"], "source");
|
||||
return !!source && typeof source.sourceType === "string" && SOURCE_TYPES.has(source.sourceType) && text(source.locator, 1, 1024) && sha(source.sourceSha256) && source.immutable === true;
|
||||
}
|
||||
function expectedFreshness(kind: string): { freshness: string; class: string } | undefined {
|
||||
if (["pr-metadata", "pr-diff", "changed-paths", "repository-declaration"].includes(kind)) return { freshness: "pr-live", class: "observation" };
|
||||
if (kind === "code-review") return { freshness: "review-head", class: "review" };
|
||||
if (kind === "ci-contexts") return { freshness: "ci-head", class: "observation" };
|
||||
if (kind === "merge-queue") return { freshness: "queue-live", class: "observation" };
|
||||
if (kind === "security-trigger") return { freshness: "security-policy", class: "observation" };
|
||||
if (kind === "security-review") return { freshness: "security-policy", class: "review" };
|
||||
if (kind === "coordinator-authority") return { freshness: "authority-submit", class: "observation" };
|
||||
if (["independence", "runtime-attestation", "journal-attestation"].includes(kind)) return { freshness: "runtime-incarnation", class: "verification" };
|
||||
return undefined;
|
||||
}
|
||||
function validEvidenceItem(value: unknown): value is Record<string, unknown> {
|
||||
const item = closed(value, ["evidenceId", "criterionCode", "class", "evidenceKind", "producer", "capability", "source", "targetBinding", "result", "observedAt", "freshnessClass", "expiresAt", "invalidationKeys"], "evidence");
|
||||
if (!item || !uuid(item.evidenceId) || typeof item.criterionCode !== "string" || !MG_CODES.includes(item.criterionCode as MgCode) || typeof item.class !== "string" || typeof item.evidenceKind !== "string" || !EVIDENCE_KINDS.has(item.evidenceKind) || !validProducer(item.producer) || !validCapability(item.capability) || !validSource(item.source) || !validTargetBinding(item.targetBinding) || !timestamp(item.observedAt) || !validInvalidationKeys(item.invalidationKeys)) return false;
|
||||
const result = closed(item.result, ["outcome", "reasonCode"], "result");
|
||||
const fresh = expectedFreshness(item.evidenceKind);
|
||||
if (!result || !fresh || item.class !== fresh.class || item.freshnessClass !== fresh.freshness || !reasonAllowed(item.criterionCode as MgCode, String(result.outcome), result.reasonCode)) return false;
|
||||
if (item.expiresAt !== null && !timestamp(item.expiresAt)) return false;
|
||||
if (item.expiresAt === null && item.freshnessClass !== "review-head" && item.freshnessClass !== "runtime-incarnation") return false;
|
||||
return true;
|
||||
}
|
||||
type CriterionDerivation = Readonly<{
|
||||
outcome: "pass" | "fail" | "blocked";
|
||||
reasonCode: string | null;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* E1 is the only source of E2 criterion outcomes. The first applicable reason
|
||||
* follows the CDDL's left-to-right priority after all evidence for that
|
||||
* criterion has been considered. A caller cannot choose a weaker reason or a
|
||||
* PASS result over a negative observation.
|
||||
*/
|
||||
function deriveCriterion(code: MgCode, items: readonly Record<string, unknown>[]): CriterionDerivation | undefined {
|
||||
const outcomes = items.map((item) => (item.result as Record<string, unknown>).outcome);
|
||||
const outcome = outcomes.includes("fail")
|
||||
? "fail"
|
||||
: outcomes.includes("blocked")
|
||||
? "blocked"
|
||||
: outcomes.every((entry) => entry === "pass")
|
||||
? "pass"
|
||||
: undefined;
|
||||
if (!outcome) return undefined;
|
||||
if (outcome === "pass") return Object.freeze({ outcome, reasonCode: null });
|
||||
const priority = outcome === "fail"
|
||||
? REASONS[code].fail
|
||||
: [...REASONS[code].blocked, ...UNIVERSAL_BLOCKED];
|
||||
for (const reasonCode of priority) {
|
||||
if (items.some((item) => (item.result as Record<string, unknown>).reasonCode === reasonCode)) {
|
||||
return Object.freeze({ outcome, reasonCode });
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validateGateEvidenceBundle(value: unknown): GateRecordValidation {
|
||||
const bundle = closed(value, ["schemaVersion", "recordType", "evidenceBundleId", "bundleSha256", "gateProfile", "requestedTransition", "operationRequestId", "actorContext", "missionContext", "target", "evidence", "securityReview", "requiredContextSetSha256", "observedAt", "expiresAt", "sensitivity"], "bundle");
|
||||
if (!bundle) return fail("E1:closed-map");
|
||||
if (bundle.schemaVersion !== 1 || bundle.recordType !== "gate.merge.evidence" || !uuid(bundle.evidenceBundleId) || !sha(bundle.bundleSha256) || bundle.gateProfile !== "merge" || !uuid(bundle.operationRequestId) || !validActor(bundle.actorContext) || !validMission(bundle.missionContext) || !validTarget(bundle.target) || !sha(bundle.requiredContextSetSha256) || !timestamp(bundle.observedAt) || !timestamp(bundle.expiresAt) || bundle.sensitivity !== "internal") return fail("E1:scalar");
|
||||
if (!closed(bundle.requestedTransition, ["requestedFrom", "requestedTo"], "requestedTransition") || (bundle.requestedTransition as Record<string, unknown>).requestedFrom !== "reviewed" || (bundle.requestedTransition as Record<string, unknown>).requestedTo !== "authorized-integration") return fail("E1:transition");
|
||||
if (!Array.isArray(bundle.evidence) || bundle.evidence.length < 8 || bundle.evidence.length > 64) return fail("E1:evidence-count");
|
||||
const ids = new Set<string>();
|
||||
const byCriterion = new Map<MgCode, Record<string, unknown>[]>();
|
||||
let prior = "";
|
||||
for (const item of bundle.evidence) {
|
||||
if (!validEvidenceItem(item)) return fail("E1:evidence-item");
|
||||
const evidence = item as Record<string, unknown>;
|
||||
const id = evidence.evidenceId as string;
|
||||
const criterion = evidence.criterionCode as MgCode;
|
||||
const order = `${criterion}\u0000${evidence.evidenceKind as string}\u0000${id}`;
|
||||
if (ids.has(id) || order <= prior) return fail("E1:evidence-order");
|
||||
const targetBinding = evidence.targetBinding as Record<string, unknown>;
|
||||
if (targetBinding.baseHeadSha !== (bundle.target as Record<string, unknown>).baseHeadSha) return fail("E1:target-binding");
|
||||
ids.add(id); prior = order;
|
||||
byCriterion.set(criterion, [...(byCriterion.get(criterion) ?? []), evidence]);
|
||||
}
|
||||
if (!MG_CODES.every((code) => (byCriterion.get(code)?.length ?? 0) >= 1)) return fail("E1:criterion-coverage");
|
||||
const security = closed(bundle.securityReview, ["triggered", "triggerPolicyDigest", "requiredEvidenceId"], "security");
|
||||
if (!security || typeof security.triggered !== "boolean" || !sha(security.triggerPolicyDigest) || (security.triggered ? !uuid(security.requiredEvidenceId) || !ids.has(security.requiredEvidenceId as string) : security.requiredEvidenceId !== null)) return fail("E1:security");
|
||||
if (security.triggered) {
|
||||
const match = bundle.evidence.find((item) => (item as Record<string, unknown>).evidenceId === security.requiredEvidenceId) as Record<string, unknown> | undefined;
|
||||
if (!match || match.criterionCode !== "MG03" || match.evidenceKind !== "security-review") return fail("E1:security-reference");
|
||||
}
|
||||
const observed = bundle.evidence.map((item) => (item as Record<string, unknown>).observedAt as string).sort().at(-1);
|
||||
if (bundle.observedAt !== observed) return fail("E1:observed-at");
|
||||
const expiries = bundle.evidence.map((item) => (item as Record<string, unknown>).expiresAt).filter((entry): entry is string => typeof entry === "string").sort();
|
||||
if (expiries.length > 0 && bundle.expiresAt !== expiries[0]) return fail("E1:expires-at");
|
||||
if (bundle.bundleSha256 !== sha256Canonical(omitTop(bundle, "bundleSha256"))) return fail("E1:bundle-digest");
|
||||
return pass(bundle);
|
||||
}
|
||||
|
||||
function validAuthority(value: unknown, actor: Record<string, unknown>): boolean {
|
||||
const authority = closed(value, ["policyVersion", "policyDigest", "roleBindingId", "roleId", "roleRevision", "roleDigest", "intentId", "decisionId", "grantId", "invocationId", "assignmentId", "assignmentRevision", "leaseId", "leaseRevision", "fencingToken", "leaseExpiresAt"], "authority");
|
||||
return !!authority && authority.policyVersion === 4 && sha(authority.policyDigest) && uuid(authority.roleBindingId) && authority.roleId === "gate-merge-ng" && authority.roleRevision === 1 && authority.roleDigest === actor.roleDigest && uuid(authority.intentId) && uuid(authority.decisionId) && uuid(authority.grantId) && uuid(authority.invocationId) && authority.assignmentId === actor.assignmentId && authority.assignmentRevision === actor.assignmentRevision && authority.leaseId === actor.leaseId && authority.leaseRevision === actor.leaseRevision && authority.fencingToken === actor.fencingToken && timestamp(authority.leaseExpiresAt);
|
||||
}
|
||||
function validTransition(value: unknown): value is Record<string, unknown> {
|
||||
const transition = closed(value, ["domain", "objectId", "requestedFrom", "requestedTo", "expectedStateRevision", "previousState", "previousRevision", "resultingState", "resultingRevision"], "transition");
|
||||
return !!transition && transition.domain === "gate" && transition.objectId === "git.mosaicstack.dev/mosaicstack/stack#1491:merge" && transition.requestedFrom === "reviewed" && transition.requestedTo === "authorized-integration" && positive(transition.expectedStateRevision) && transition.previousState === "reviewed" && transition.previousRevision === transition.expectedStateRevision && ["passed", "failed", "blocked"].includes(String(transition.resultingState)) && transition.resultingRevision === (transition.expectedStateRevision as number) + 1;
|
||||
}
|
||||
function validAuthorizedOperation(value: unknown): boolean {
|
||||
return !!closed(value, ["capability", "host", "repository", "pullRequest", "headSha", "baseBranch", "strategy", "operationGrantId"], "operation") && exactFixed(value, { capability: "operation.merge", host: FIXED_TARGET.host, repository: FIXED_TARGET.repository, pullRequest: FIXED_TARGET.pullRequest, headSha: FIXED_TARGET.headSha, baseBranch: FIXED_TARGET.baseBranch, strategy: "squash", operationGrantId: null });
|
||||
}
|
||||
function exactFixed(value: unknown, expected: Record<string, unknown>): boolean {
|
||||
const obj = closed(value, Object.keys(expected), "fixed");
|
||||
return !!obj && Object.keys(expected).every((key) => obj[key] === expected[key]);
|
||||
}
|
||||
function derivedIdempotency(verdict: Record<string, unknown>): string {
|
||||
const actor = verdict.actorContext as Record<string, unknown>;
|
||||
const target = verdict.target as Record<string, unknown>;
|
||||
const transition = verdict.transition as Record<string, unknown>;
|
||||
const source = {
|
||||
assignmentId: actor.assignmentId,
|
||||
assignmentRevision: actor.assignmentRevision,
|
||||
baseHeadSha: target.baseHeadSha,
|
||||
expectedStateRevision: transition.expectedStateRevision,
|
||||
headSha: target.headSha,
|
||||
operationRequestId: verdict.operationRequestId,
|
||||
requestedFrom: "reviewed",
|
||||
requestedTo: "authorized-integration",
|
||||
transitionId: verdict.transitionId,
|
||||
};
|
||||
return createHash("sha256").update("mosaic-gate-verdict-v1\n", "utf8").update(canonicalizeRfc8785(source), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function validDecision(
|
||||
value: unknown,
|
||||
transition: Record<string, unknown>,
|
||||
evidenceById: ReadonlyMap<string, Record<string, unknown>>,
|
||||
): { ok: true } | { ok: false } {
|
||||
const decision = closed(value, ["verdict", "criteria", "summary", "failedCriteria", "blockedCriteria", "reasonCodes", "authorizedNextOperation"], "decision");
|
||||
if (!decision || !["PASS", "FAIL", "BLOCKED"].includes(String(decision.verdict)) || !text(decision.summary, 1, 500) || !Array.isArray(decision.criteria) || decision.criteria.length !== MG_CODES.length || !Array.isArray(decision.failedCriteria) || !Array.isArray(decision.blockedCriteria) || !stringSet(decision.reasonCodes, 8, (code) => typeof code === "string")) return { ok: false };
|
||||
|
||||
const expectedFailed: MgCode[] = [];
|
||||
const expectedBlocked: MgCode[] = [];
|
||||
const expectedReasonCodes: string[] = [];
|
||||
const referencedIds = new Set<string>();
|
||||
|
||||
for (let index = 0; index < MG_CODES.length; index++) {
|
||||
const criterion = closed(decision.criteria[index], ["code", "outcome", "reasonCode", "evidenceIds"], "criterion");
|
||||
const code = MG_CODES[index];
|
||||
if (!criterion || criterion.code !== code || !["pass", "fail", "blocked"].includes(String(criterion.outcome)) || !Array.isArray(criterion.evidenceIds) || criterion.evidenceIds.length < 1 || criterion.evidenceIds.length > 16 || !stringSet(criterion.evidenceIds, 16, uuid)) return { ok: false };
|
||||
|
||||
const evidence: Record<string, unknown>[] = [];
|
||||
for (const id of criterion.evidenceIds) {
|
||||
if (referencedIds.has(id)) return { ok: false };
|
||||
const item = evidenceById.get(id);
|
||||
if (!item || item.criterionCode !== code) return { ok: false };
|
||||
referencedIds.add(id);
|
||||
evidence.push(item);
|
||||
}
|
||||
|
||||
const derived = deriveCriterion(code, evidence);
|
||||
if (!derived || criterion.outcome !== derived.outcome || criterion.reasonCode !== derived.reasonCode) return { ok: false };
|
||||
if (derived.outcome === "fail") expectedFailed.push(code);
|
||||
if (derived.outcome === "blocked") expectedBlocked.push(code);
|
||||
if (derived.reasonCode !== null) expectedReasonCodes.push(derived.reasonCode);
|
||||
}
|
||||
|
||||
if (referencedIds.size !== evidenceById.size) return { ok: false };
|
||||
if (!sameOrderedCodes(decision.failedCriteria, expectedFailed) || !sameOrderedCodes(decision.blockedCriteria, expectedBlocked)) return { ok: false };
|
||||
const expectedReasons = [...new Set(expectedReasonCodes)].sort();
|
||||
if (JSON.stringify(decision.reasonCodes) !== JSON.stringify(expectedReasons)) return { ok: false };
|
||||
const derivedVerdict = expectedFailed.length > 0 ? "FAIL" : expectedBlocked.length > 0 ? "BLOCKED" : "PASS";
|
||||
const expectedState = derivedVerdict === "PASS" ? "passed" : derivedVerdict === "FAIL" ? "failed" : "blocked";
|
||||
if (decision.verdict !== derivedVerdict || transition.resultingState !== expectedState) return { ok: false };
|
||||
if ((derivedVerdict === "PASS" && !validAuthorizedOperation(decision.authorizedNextOperation)) || (derivedVerdict !== "PASS" && decision.authorizedNextOperation !== null)) return { ok: false };
|
||||
return { ok: true };
|
||||
}
|
||||
function sameOrderedCodes(value: unknown, expected: MgCode[]): boolean { return Array.isArray(value) && value.length === expected.length && value.every((code, index) => code === expected[index]); }
|
||||
function validValidity(value: unknown): value is Record<string, unknown> {
|
||||
const validity = closed(value, ["issuedAt", "expiresAt", "invalidationKeys"], "validity");
|
||||
return !!validity && timestamp(validity.issuedAt) && timestamp(validity.expiresAt) && validInvalidationKeys(validity.invalidationKeys) && validity.issuedAt <= validity.expiresAt;
|
||||
}
|
||||
function sameCanonical(left: unknown, right: unknown): boolean {
|
||||
try {
|
||||
return canonicalizeRfc8785(left) === canonicalizeRfc8785(right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* E2 is valid only at the broker's supplied submit time. Requiring the caller
|
||||
* to supply that time prevents an offline E2 shape check from masquerading as
|
||||
* an operation-authorizing validation.
|
||||
*/
|
||||
function validityBoundAtSubmit(
|
||||
validity: Record<string, unknown>,
|
||||
bundle: Record<string, unknown>,
|
||||
authority: Record<string, unknown>,
|
||||
submittedAt: unknown,
|
||||
): boolean {
|
||||
if (!timestamp(submittedAt)) return false;
|
||||
const issuedAt = validity.issuedAt as string;
|
||||
const expiresAt = validity.expiresAt as string;
|
||||
const evidenceObservedAt = bundle.observedAt as string;
|
||||
const evidenceExpiresAt = bundle.expiresAt as string;
|
||||
const leaseExpiresAt = authority.leaseExpiresAt as string;
|
||||
return issuedAt === submittedAt
|
||||
&& evidenceObservedAt <= submittedAt
|
||||
&& expiresAt <= evidenceExpiresAt
|
||||
&& expiresAt <= leaseExpiresAt
|
||||
&& submittedAt <= expiresAt
|
||||
&& submittedAt <= evidenceExpiresAt
|
||||
&& submittedAt <= leaseExpiresAt;
|
||||
}
|
||||
|
||||
export function validateGateVerdict(value: unknown, bundle?: unknown, submittedAt?: unknown): GateRecordValidation {
|
||||
const verdict = closed(value, ["schemaVersion", "recordType", "verdictId", "transitionId", "idempotencyKey", "operationRequestId", "actorContext", "missionContext", "authority", "transition", "target", "evidenceBundleId", "evidenceBundleSha256", "decision", "validity", "sensitivity", "recordSha256"], "verdict");
|
||||
if (!verdict) return fail("E2:closed-map");
|
||||
if (verdict.schemaVersion !== 1 || verdict.recordType !== "gate.merge.verdict" || !uuid(verdict.verdictId) || !uuid(verdict.transitionId) || !sha(verdict.idempotencyKey) || !uuid(verdict.operationRequestId) || !validActor(verdict.actorContext) || !validMission(verdict.missionContext) || !validTarget(verdict.target) || !uuid(verdict.evidenceBundleId) || !sha(verdict.evidenceBundleSha256) || !validValidity(verdict.validity) || verdict.sensitivity !== "internal" || !sha(verdict.recordSha256)) return fail("E2:scalar");
|
||||
const actor = verdict.actorContext as Record<string, unknown>;
|
||||
if (!validAuthority(verdict.authority, actor) || !validTransition(verdict.transition)) return fail("E2:authority-transition");
|
||||
const authority = verdict.authority as Record<string, unknown>;
|
||||
if (verdict.transitionId !== authority.invocationId || verdict.idempotencyKey !== derivedIdempotency(verdict)) return fail("E2:idempotency");
|
||||
if (bundle === undefined) return fail("E2:bundle-required");
|
||||
|
||||
const bundleCheck = validateGateEvidenceBundle(bundle);
|
||||
if (!bundleCheck.ok) return fail("E2:bundle-invalid");
|
||||
const evidence = bundle as Record<string, unknown>;
|
||||
if (
|
||||
verdict.evidenceBundleId !== evidence.evidenceBundleId
|
||||
|| verdict.evidenceBundleSha256 !== evidence.bundleSha256
|
||||
|| verdict.operationRequestId !== evidence.operationRequestId
|
||||
|| !sameCanonical(verdict.actorContext, evidence.actorContext)
|
||||
|| !sameCanonical(verdict.missionContext, evidence.missionContext)
|
||||
|| !sameCanonical(verdict.target, evidence.target)
|
||||
) return fail("E2:bundle-binding");
|
||||
if (!validityBoundAtSubmit(verdict.validity, evidence, authority, submittedAt)) return fail("E2:validity");
|
||||
|
||||
const evidenceById = new Map<string, Record<string, unknown>>((evidence.evidence as unknown[]).map((item) => [(item as Record<string, unknown>).evidenceId as string, item as Record<string, unknown>]));
|
||||
const decision = validDecision(verdict.decision, verdict.transition as Record<string, unknown>, evidenceById);
|
||||
if (!decision.ok) return fail("E2:decision");
|
||||
try {
|
||||
if (verdict.recordSha256 !== sha256Canonical(omitTop(verdict, "recordSha256"))) return fail("E2:record-digest");
|
||||
} catch {
|
||||
return fail("E2:record-digest");
|
||||
}
|
||||
return pass(verdict);
|
||||
}
|
||||
|
||||
export function validateGateRecord(value: unknown): GateRecordValidation {
|
||||
if (!isObject(value)) return fail("record:not-object");
|
||||
if (value.recordType === "gate.merge.evidence") return validateGateEvidenceBundle(value);
|
||||
if (value.recordType === "gate.merge.verdict") return validateGateVerdict(value);
|
||||
return fail("record:unknown-type");
|
||||
}
|
||||
|
||||
/** Parse untrusted JSON with duplicate-key, I-JSON, and closed-schema checks. */
|
||||
export function parseGateRecordJson(raw: string): GateRecordValidation {
|
||||
try {
|
||||
assertNoDuplicateJsonKeys(raw);
|
||||
return validateGateRecord(JSON.parse(raw));
|
||||
} catch {
|
||||
return fail("record:invalid-json");
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoDuplicateJsonKeys(raw: string): void {
|
||||
let index = 0;
|
||||
const whitespace = (): void => { while (/\s/.test(raw[index] ?? "")) index++; };
|
||||
const string = (): string => {
|
||||
const start = index;
|
||||
if (raw[index++] !== '"') throw new Error("string");
|
||||
while (index < raw.length) {
|
||||
const char = raw[index++];
|
||||
if (char === '"') return JSON.parse(raw.slice(start, index));
|
||||
if (char === "\\") { const escaped = raw[index++]; if (escaped === "u") index += 4; else if (escaped === undefined) throw new Error("escape"); }
|
||||
else if (char < " ") throw new Error("control");
|
||||
}
|
||||
throw new Error("unterminated");
|
||||
};
|
||||
const value = (): void => {
|
||||
whitespace(); const char = raw[index];
|
||||
if (char === "{") { index++; whitespace(); const keys = new Set<string>(); if (raw[index] === "}") { index++; return; } while (true) { whitespace(); const key = string(); if (keys.has(key)) throw new Error("duplicate"); keys.add(key); whitespace(); if (raw[index++] !== ":") throw new Error("colon"); value(); whitespace(); if (raw[index] === "}") { index++; return; } if (raw[index++] !== ",") throw new Error("comma"); } }
|
||||
if (char === "[") { index++; whitespace(); if (raw[index] === "]") { index++; return; } while (true) { value(); whitespace(); if (raw[index] === "]") { index++; return; } if (raw[index++] !== ",") throw new Error("comma"); } }
|
||||
if (char === '"') { string(); return; }
|
||||
const token = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/.exec(raw.slice(index));
|
||||
if (!token) throw new Error("value");
|
||||
index += token[0].length;
|
||||
};
|
||||
value(); whitespace(); if (index !== raw.length) throw new Error("trailing");
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// lib/goal-policy.ts — process-local, one-writer Goal policy bridge (T165 WP1).
|
||||
//
|
||||
// Goal registers the read-only consumer before Mosaic Core starts a session.
|
||||
// A trusted launcher or role adapter registers exactly one resolver, then
|
||||
// Mosaic Core publishes one validated attestation for the incarnation. This
|
||||
// module has no filesystem, network, provider, or model-visible surface.
|
||||
|
||||
export const GOAL_REPORT_FORMAT = "ms-executive-update/v1";
|
||||
export const GOAL_REPORT_CONTRACT_PATH = "skills-local/ms-executive-update/SKILL.md";
|
||||
export const GOAL_REPORT_CONTRACT_SECTION = "Machine contract (for `goal_report` payloads and any parser)";
|
||||
export const GOAL_REPORT_CONTRACT_BLOB = "df30c6fbb54b4a65a298c9e51c07f742610d171c";
|
||||
export const GOAL_REPORT_CONTRACT_SHA256 = "bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319";
|
||||
export const GOAL_POLICY_ENFORCEMENT = "pre-state-change-fail-closed";
|
||||
export const GOAL_POLICY_IDENTIFIER_RESOLUTION = "consumer-fail-closed";
|
||||
|
||||
export type GoalPolicyRole = "gate-merge-ng" | "plan-ng" | "review-ng";
|
||||
export type GoalPolicyRoleRevision = 3 | 4;
|
||||
|
||||
export type GoalPolicyAttestationV1 = Readonly<{
|
||||
schemaVersion: 1;
|
||||
role: GoalPolicyRole;
|
||||
roleRevision: GoalPolicyRoleRevision;
|
||||
manifestSha256: string;
|
||||
format: typeof GOAL_REPORT_FORMAT;
|
||||
contractPath: typeof GOAL_REPORT_CONTRACT_PATH;
|
||||
contractSection: typeof GOAL_REPORT_CONTRACT_SECTION;
|
||||
contractBlob: typeof GOAL_REPORT_CONTRACT_BLOB;
|
||||
contractSha256: typeof GOAL_REPORT_CONTRACT_SHA256;
|
||||
enforcement: typeof GOAL_POLICY_ENFORCEMENT;
|
||||
identifierResolution: typeof GOAL_POLICY_IDENTIFIER_RESOLUTION;
|
||||
launchGeneration: number;
|
||||
incarnationId: string;
|
||||
}>;
|
||||
|
||||
export type GoalItemResolutionV1 =
|
||||
| Readonly<{
|
||||
outcome: "resolved";
|
||||
objectId: string;
|
||||
objectSha256: string;
|
||||
changedSincePreviousAcceptedReport: boolean;
|
||||
completionEvidenceId: string | null;
|
||||
}>
|
||||
| Readonly<{ outcome: "zero" | "multiple" | "unavailable" | "stale" }>;
|
||||
|
||||
export type GoalTrackedItem = Readonly<{
|
||||
token: string;
|
||||
section: "Just Completed" | "Next Step" | "Blocked";
|
||||
}>;
|
||||
|
||||
export type GoalTrackedItemResolverV1 = (parsedItem: GoalTrackedItem) => Promise<GoalItemResolutionV1>;
|
||||
|
||||
export type GoalPolicyReason =
|
||||
| "consumer-unavailable"
|
||||
| "resolver-unavailable"
|
||||
| "resolver-already-registered"
|
||||
| "already-published"
|
||||
| "malformed-attestation"
|
||||
| "attestation-mismatch"
|
||||
| "incarnation-mismatch";
|
||||
|
||||
export interface GoalPolicyPublication {
|
||||
attestation: GoalPolicyAttestationV1;
|
||||
resolver: GoalTrackedItemResolverV1;
|
||||
/** Trusted Core journal bridge. Receives a stable code only, never payload text. */
|
||||
onDenial?: (reason: string) => void;
|
||||
}
|
||||
|
||||
interface GoalPolicySlot {
|
||||
consumerRegistered: boolean;
|
||||
resolver?: GoalTrackedItemResolverV1;
|
||||
publication?: GoalPolicyPublication;
|
||||
}
|
||||
|
||||
const REGISTRY_KEY = Symbol.for("mosaic.goal-policy.v1.registry");
|
||||
type GlobalWithGoalPolicy = typeof globalThis & { [REGISTRY_KEY]?: Map<string, GoalPolicySlot> };
|
||||
|
||||
function registry(): Map<string, GoalPolicySlot> {
|
||||
const global = globalThis as GlobalWithGoalPolicy;
|
||||
if (!global[REGISTRY_KEY]) {
|
||||
Object.defineProperty(global, REGISTRY_KEY, {
|
||||
value: new Map<string, GoalPolicySlot>(),
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
return global[REGISTRY_KEY]!;
|
||||
}
|
||||
|
||||
function utf8Length(value: string): number {
|
||||
return Buffer.byteLength(value, "utf8");
|
||||
}
|
||||
|
||||
export function isOpaqueGoalPolicyId(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value) && utf8Length(value) >= 1 && utf8Length(value) <= 128;
|
||||
}
|
||||
|
||||
export function isLowerSha256(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function roleRevisionMatches(role: GoalPolicyRole, revision: GoalPolicyRoleRevision): boolean {
|
||||
return (role === "gate-merge-ng" && revision === 3) || ((role === "plan-ng" || role === "review-ng") && revision === 4);
|
||||
}
|
||||
|
||||
function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
export function validateGoalPolicyAttestation(value: unknown): value is GoalPolicyAttestationV1 {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const attestation = value as Record<string, unknown>;
|
||||
if (!hasExactKeys(attestation, [
|
||||
"schemaVersion", "role", "roleRevision", "manifestSha256", "format", "contractPath",
|
||||
"contractSection", "contractBlob", "contractSha256", "enforcement", "identifierResolution",
|
||||
"launchGeneration", "incarnationId",
|
||||
])) return false;
|
||||
if (attestation.schemaVersion !== 1) return false;
|
||||
if (attestation.role !== "gate-merge-ng" && attestation.role !== "plan-ng" && attestation.role !== "review-ng") return false;
|
||||
if (attestation.roleRevision !== 3 && attestation.roleRevision !== 4) return false;
|
||||
if (!roleRevisionMatches(attestation.role, attestation.roleRevision)) return false;
|
||||
if (!isLowerSha256(attestation.manifestSha256)) return false;
|
||||
if (attestation.format !== GOAL_REPORT_FORMAT) return false;
|
||||
if (attestation.contractPath !== GOAL_REPORT_CONTRACT_PATH) return false;
|
||||
if (attestation.contractSection !== GOAL_REPORT_CONTRACT_SECTION) return false;
|
||||
if (attestation.contractBlob !== GOAL_REPORT_CONTRACT_BLOB) return false;
|
||||
if (attestation.contractSha256 !== GOAL_REPORT_CONTRACT_SHA256) return false;
|
||||
if (attestation.enforcement !== GOAL_POLICY_ENFORCEMENT) return false;
|
||||
if (attestation.identifierResolution !== GOAL_POLICY_IDENTIFIER_RESOLUTION) return false;
|
||||
if (typeof attestation.launchGeneration !== "number" || !Number.isSafeInteger(attestation.launchGeneration) || attestation.launchGeneration < 1) return false;
|
||||
return isOpaqueGoalPolicyId(attestation.incarnationId);
|
||||
}
|
||||
|
||||
function freezeAttestation(value: GoalPolicyAttestationV1): GoalPolicyAttestationV1 {
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
/** Goal calls this during extension initialization, before Mosaic Core publishes. */
|
||||
export function registerGoalPolicyConsumer(incarnationId: string): { ok: true } | { ok: false; reason: "incarnation-mismatch" } {
|
||||
if (!isOpaqueGoalPolicyId(incarnationId)) return { ok: false, reason: "incarnation-mismatch" };
|
||||
const slots = registry();
|
||||
const slot = slots.get(incarnationId) ?? { consumerRegistered: false };
|
||||
slot.consumerRegistered = true;
|
||||
slots.set(incarnationId, slot);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* A trusted launcher or role adapter supplies a resolver before Core publishes.
|
||||
* WP1 deliberately supplies no fallback resolver: missing resolver authority
|
||||
* keeps a version-4 role fail-closed until the later broker/adapter work exists.
|
||||
*/
|
||||
export function registerGoalTrackedItemResolver(
|
||||
incarnationId: string,
|
||||
resolver: GoalTrackedItemResolverV1,
|
||||
): { ok: true } | { ok: false; reason: GoalPolicyReason } {
|
||||
if (!isOpaqueGoalPolicyId(incarnationId)) return { ok: false, reason: "incarnation-mismatch" };
|
||||
if (typeof resolver !== "function") return { ok: false, reason: "resolver-unavailable" };
|
||||
const slot = registry().get(incarnationId);
|
||||
if (!slot?.consumerRegistered) return { ok: false, reason: "consumer-unavailable" };
|
||||
if (slot.resolver) return { ok: false, reason: "resolver-already-registered" };
|
||||
slot.resolver = resolver;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Mosaic Core is the only policy publisher and may publish once per incarnation. */
|
||||
export function publishGoalPolicy(
|
||||
value: GoalPolicyAttestationV1,
|
||||
onDenial?: (reason: string) => void,
|
||||
): { ok: true; attestation: GoalPolicyAttestationV1 } | { ok: false; reason: GoalPolicyReason } {
|
||||
if (!validateGoalPolicyAttestation(value)) return { ok: false, reason: "malformed-attestation" };
|
||||
const slot = registry().get(value.incarnationId);
|
||||
if (!slot?.consumerRegistered) return { ok: false, reason: "consumer-unavailable" };
|
||||
if (!slot.resolver) return { ok: false, reason: "resolver-unavailable" };
|
||||
if (slot.publication) return { ok: false, reason: "already-published" };
|
||||
const attestation = freezeAttestation(value);
|
||||
slot.publication = Object.freeze({ attestation, resolver: slot.resolver, onDenial });
|
||||
return { ok: true, attestation };
|
||||
}
|
||||
|
||||
/** Read-only consumer view. No caller can replace a published pair. */
|
||||
export function goalPolicyPublication(incarnationId: string): GoalPolicyPublication | undefined {
|
||||
return registry().get(incarnationId)?.publication;
|
||||
}
|
||||
|
||||
/** Goal records a stable, payload-free denial through the Core-owned journal bridge. */
|
||||
export function recordGoalPolicyDenial(incarnationId: string, reason: string): void {
|
||||
try {
|
||||
registry().get(incarnationId)?.publication?.onDenial?.(reason);
|
||||
} catch {
|
||||
// A journal outage cannot turn a fail-closed report denial into an allow.
|
||||
}
|
||||
}
|
||||
|
||||
/** Core pre-dispatch check: attestation must still be the exact published pair. */
|
||||
export function verifyGoalPolicyPublication(
|
||||
expected: GoalPolicyAttestationV1,
|
||||
): { ok: true } | { ok: false; reason: GoalPolicyReason } {
|
||||
if (!validateGoalPolicyAttestation(expected)) return { ok: false, reason: "malformed-attestation" };
|
||||
const published = goalPolicyPublication(expected.incarnationId);
|
||||
if (!published) return { ok: false, reason: "resolver-unavailable" };
|
||||
const actual = published.attestation;
|
||||
const same = JSON.stringify(actual) === JSON.stringify(expected);
|
||||
return same ? { ok: true } : { ok: false, reason: "attestation-mismatch" };
|
||||
}
|
||||
|
||||
/** Validate the closed resolver result before Goal uses it. */
|
||||
export function validateGoalItemResolution(value: unknown): value is GoalItemResolutionV1 {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value) || !Object.isFrozen(value)) return false;
|
||||
const result = value as Record<string, unknown>;
|
||||
if (result.outcome === "zero" || result.outcome === "multiple" || result.outcome === "unavailable" || result.outcome === "stale") {
|
||||
return hasExactKeys(result, ["outcome"]);
|
||||
}
|
||||
if (result.outcome !== "resolved" || !hasExactKeys(result, [
|
||||
"outcome", "objectId", "objectSha256", "changedSincePreviousAcceptedReport", "completionEvidenceId",
|
||||
])) return false;
|
||||
return isOpaqueGoalPolicyId(result.objectId)
|
||||
&& isLowerSha256(result.objectSha256)
|
||||
&& typeof result.changedSincePreviousAcceptedReport === "boolean"
|
||||
&& (result.completionEvidenceId === null || isOpaqueGoalPolicyId(result.completionEvidenceId));
|
||||
}
|
||||
|
||||
/** Test-only registry reset. Production code has no reset or replacement path. */
|
||||
export function resetGoalPolicyRegistryForTest(): void {
|
||||
registry().clear();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// lib/incarnation.ts — process-launch incarnation identity (DESIGN HAZARD).
|
||||
//
|
||||
// Mercer-measured hazard, carried by the NG-1 tasking: Pi documents
|
||||
// PI_SESSION_ID as SESSION identity, not incarnation, and Mercer's
|
||||
// environment carries no incarnation variable. Per L2-D01/D05 we therefore
|
||||
// do NOT relabel PI_SESSION_ID as an incarnation — the R6 journal keys on
|
||||
// the identity minted here.
|
||||
//
|
||||
// Resolution order (pure function of process state + injectable rng):
|
||||
// 1. Launcher claim: MOSAIC_LAUNCH_INCARNATION, VALIDATED (NG-7 F2): the
|
||||
// claim becomes a FILENAME COMPONENT in the journal and goal-state
|
||||
// paths, so anything outside ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ is
|
||||
// IGNORED (no path separators, no leading dot, no traversal shape) and
|
||||
// resolution falls through — an invalid claim must never reach a path.
|
||||
// 2. Process-global: globalThis.__mosaicCoreIncarnation — set on first
|
||||
// mint. globalThis is per-process, so the identity survives extension
|
||||
// /reload (module re-import) and session switches WITHIN the process.
|
||||
// Deliberately NOT exported via process.env: child shells must not
|
||||
// inherit a forgeable-looking claim.
|
||||
// 3. Mint: rng() — DEFAULTS to crypto.randomUUID (NG-7 F1: the first cut
|
||||
// required an injectable rng with no default, and both production
|
||||
// call sites invoked incarnationIdentity() with no argument — a live
|
||||
// startup throw the test suites never caught because every arm
|
||||
// injected an rng or an explicit incarnationId).
|
||||
//
|
||||
// This function never reads PI_SESSION_ID. There is no code path here that
|
||||
// could conflate session identity with launch identity.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export const LAUNCH_CLAIM_ENV = "MOSAIC_LAUNCH_INCARNATION";
|
||||
const GLOBAL_SLOT = "__mosaicCoreIncarnation";
|
||||
|
||||
/** A claim is usable only if it is a safe single path component. */
|
||||
export const CLAIM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
type Rng = () => string;
|
||||
|
||||
export function validClaim(claim: string | undefined): claim is string {
|
||||
return typeof claim === "string" && CLAIM_RE.test(claim);
|
||||
}
|
||||
|
||||
export function incarnationIdentity(rng: Rng = () => randomUUID()): string {
|
||||
const claim = process.env[LAUNCH_CLAIM_ENV];
|
||||
if (validClaim(claim)) {
|
||||
return claim;
|
||||
}
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const existing = g[GLOBAL_SLOT];
|
||||
if (typeof existing === "string" && existing.length > 0) {
|
||||
return existing;
|
||||
}
|
||||
const minted = rng();
|
||||
g[GLOBAL_SLOT] = minted;
|
||||
return minted;
|
||||
}
|
||||
|
||||
/** Test/adapter helper: clear the process-global slot (never touches claims). */
|
||||
export function resetIncarnationGlobal(): void {
|
||||
delete (globalThis as Record<string, unknown>)[GLOBAL_SLOT];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// lib/journal.ts — per-incarnation append-only JSONL journal (PRD R6, NG-3).
|
||||
//
|
||||
// One file per incarnation identity (the NG-1 process-launch identity —
|
||||
// NEVER PI_SESSION_ID) under the XDG state home:
|
||||
// ${XDG_STATE_HOME:-~/.local/state}/mosaic-core/<incarnationId>.jsonl
|
||||
//
|
||||
// Append-only is structural: the only write primitive is appendFileSync of a
|
||||
// newline-terminated JSON line. No read-modify-write, no truncation, no
|
||||
// rewrite — a journal that can be edited is a journal that cannot prove
|
||||
// anything. Event payloads are pre-sanitized by their producers (the denial
|
||||
// event carries tool+reason only; proposals pass the proposal sanitizer).
|
||||
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export type JournalKind = "reconciliation" | "denial" | "improvement-proposal";
|
||||
|
||||
export interface JournalEvent {
|
||||
ts: string;
|
||||
incarnationId: string;
|
||||
kind: JournalKind;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
export interface JournalIO {
|
||||
mkdirSync(path: string, opts: { recursive: boolean }): void;
|
||||
appendFileSync(path: string, data: string): void;
|
||||
}
|
||||
|
||||
function defaultIO(): JournalIO {
|
||||
return { mkdirSync, appendFileSync };
|
||||
}
|
||||
|
||||
export function journalDir(stateHome: string | undefined, home: string = homedir()): string {
|
||||
return join(stateHome ?? join(home, ".local", "state"), "mosaic-core");
|
||||
}
|
||||
|
||||
export interface Journal {
|
||||
path: string;
|
||||
append(event: Omit<JournalEvent, "ts" | "incarnationId">): void;
|
||||
}
|
||||
|
||||
export function createJournal(opts: {
|
||||
incarnationId: string;
|
||||
stateHome?: string;
|
||||
io?: JournalIO;
|
||||
now?: () => string;
|
||||
}): Journal {
|
||||
const io = opts.io ?? defaultIO();
|
||||
const now = opts.now ?? (() => new Date().toISOString());
|
||||
const dir = journalDir(opts.stateHome);
|
||||
const path = join(dir, `${opts.incarnationId}.jsonl`);
|
||||
io.mkdirSync(dir, { recursive: true });
|
||||
return {
|
||||
path,
|
||||
append(event) {
|
||||
const line = `${JSON.stringify({ ts: now(), incarnationId: opts.incarnationId, ...event })}\n`;
|
||||
io.appendFileSync(path, line);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// lib/loader.ts — trusted-path role-manifest and v4 Goal-policy loading.
|
||||
//
|
||||
// Version-3 loading retains its established manifest path and digest behavior.
|
||||
// Version-4 adds one trusted, non-symlinked contract read at session start and
|
||||
// emits an immutable policy attestation. There is no cwd-relative path,
|
||||
// environment override, second manifest reader, or policy fallback.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { validateManifest, type RoleManifest, type RoleManifestV4 } from "./policy.ts";
|
||||
import {
|
||||
GOAL_POLICY_ENFORCEMENT,
|
||||
GOAL_POLICY_IDENTIFIER_RESOLUTION,
|
||||
GOAL_REPORT_CONTRACT_BLOB,
|
||||
GOAL_REPORT_CONTRACT_PATH,
|
||||
GOAL_REPORT_CONTRACT_SECTION,
|
||||
GOAL_REPORT_CONTRACT_SHA256,
|
||||
GOAL_REPORT_FORMAT,
|
||||
isOpaqueGoalPolicyId,
|
||||
type GoalPolicyAttestationV1,
|
||||
} from "./goal-policy.ts";
|
||||
|
||||
export const MANIFEST_BASENAME = "mosaic-core.manifest.json";
|
||||
|
||||
export type LoadFailure =
|
||||
| { stage: "no-agent-name" }
|
||||
| { stage: "no-seat-profile" }
|
||||
| { stage: "no-role-in-profile" }
|
||||
| { stage: "manifest-unreadable"; detail: string }
|
||||
| { stage: "manifest-symlink" }
|
||||
| { stage: "invalid-json"; detail: string }
|
||||
| { stage: "schema"; reason: string; detail?: string }
|
||||
| { stage: "role-mismatch"; expected: string; got: string }
|
||||
| { stage: "goal-policy-unreadable" }
|
||||
| { stage: "goal-policy-root-symlink" }
|
||||
| { stage: "goal-policy-parent-symlink" }
|
||||
| { stage: "goal-policy-symlink" }
|
||||
| { stage: "goal-policy-path-escape" }
|
||||
| { stage: "goal-policy-non-file" }
|
||||
| { stage: "goal-policy-invalid-utf8" }
|
||||
| { stage: "goal-policy-digest" }
|
||||
| { stage: "goal-policy-section" }
|
||||
| { stage: "goal-policy-launch-generation" }
|
||||
| { stage: "goal-policy-incarnation" }
|
||||
| { stage: "goal-policy-publication" };
|
||||
|
||||
export interface TrustedGoalPolicy {
|
||||
attestation: GoalPolicyAttestationV1;
|
||||
}
|
||||
|
||||
export type LoadOutcome =
|
||||
| { ok: true; manifest: RoleManifest; digest: string; path: string; goalPolicy?: TrustedGoalPolicy }
|
||||
| { ok: false; failure: LoadFailure };
|
||||
|
||||
export interface LoaderIO {
|
||||
readFileSync(path: string): string | Uint8Array;
|
||||
lstatSync(path: string): { isSymbolicLink(): boolean; isFile(): boolean };
|
||||
/** Required for v4 contract containment. Missing capability fails v4 closed. */
|
||||
realpathSync?(path: string): string;
|
||||
}
|
||||
|
||||
export interface LoadContext {
|
||||
incarnationId?: string;
|
||||
launchGeneration?: number;
|
||||
}
|
||||
|
||||
export function manifestPath(brainHome: string, role: string): string {
|
||||
return `${brainHome}/fleet/roles/${role}/${MANIFEST_BASENAME}`;
|
||||
}
|
||||
|
||||
export function seatRole(brainHome: string, agentName: string | undefined, io: LoaderIO): string | null {
|
||||
if (!agentName || !/^[a-z][a-z0-9-]*$/.test(agentName)) return null;
|
||||
let profileRaw: string;
|
||||
try {
|
||||
profileRaw = decodeUtf8(io.readFileSync(`${brainHome}/fleet/agents/${agentName}/profile.json`));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const profile = JSON.parse(profileRaw) as Record<string, unknown>;
|
||||
const role = profile.role;
|
||||
if (typeof role === "string" && /^[a-z][a-z0-9-]*$/.test(role)) return role;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Trusted manifest loader. v4 context is mandatory only for a v4 manifest. */
|
||||
export function loadTrustedManifest(brainHome: string, role: string, io: LoaderIO, context: LoadContext = {}): LoadOutcome {
|
||||
const path = manifestPath(brainHome, role);
|
||||
let st: ReturnType<LoaderIO["lstatSync"]>;
|
||||
try {
|
||||
st = io.lstatSync(path);
|
||||
} catch (error) {
|
||||
return { ok: false, failure: { stage: "manifest-unreadable", detail: String(error) } };
|
||||
}
|
||||
if (st.isSymbolicLink()) return { ok: false, failure: { stage: "manifest-symlink" } };
|
||||
|
||||
let rawBytes: Uint8Array;
|
||||
let raw: string;
|
||||
try {
|
||||
rawBytes = toBytes(io.readFileSync(path));
|
||||
raw = decodeUtf8(rawBytes);
|
||||
} catch (error) {
|
||||
return { ok: false, failure: { stage: "manifest-unreadable", detail: String(error) } };
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return { ok: false, failure: { stage: "invalid-json", detail: String(error) } };
|
||||
}
|
||||
const verdict = validateManifest(parsed);
|
||||
if (!verdict.ok) {
|
||||
return { ok: false, failure: { stage: "schema", reason: verdict.reason ?? "invalid", detail: verdict.detail } };
|
||||
}
|
||||
if (verdict.manifest!.role !== role) {
|
||||
return { ok: false, failure: { stage: "role-mismatch", expected: role, got: verdict.manifest!.role } };
|
||||
}
|
||||
|
||||
const digest = sha256(rawBytes);
|
||||
if (verdict.manifest!.schemaVersion !== 4) {
|
||||
return { ok: true, manifest: verdict.manifest!, digest, path };
|
||||
}
|
||||
|
||||
const goalPolicy = loadGoalPolicy(brainHome, verdict.manifest!, digest, io, context);
|
||||
if (!goalPolicy.ok) return goalPolicy;
|
||||
return { ok: true, manifest: verdict.manifest!, digest, path, goalPolicy: goalPolicy.goalPolicy };
|
||||
}
|
||||
|
||||
function loadGoalPolicy(
|
||||
brainHome: string,
|
||||
manifest: RoleManifestV4,
|
||||
manifestSha256: string,
|
||||
io: LoaderIO,
|
||||
context: LoadContext,
|
||||
): { ok: true; goalPolicy: TrustedGoalPolicy } | { ok: false; failure: LoadFailure } {
|
||||
const policy = manifest.goalReportPolicy;
|
||||
// validateManifest already pins every field. Repeat explicit constants here
|
||||
// so a future policy-validator regression cannot silently change the loader.
|
||||
if (
|
||||
policy.format !== GOAL_REPORT_FORMAT
|
||||
|| policy.contractPath !== GOAL_REPORT_CONTRACT_PATH
|
||||
|| policy.contractSection !== GOAL_REPORT_CONTRACT_SECTION
|
||||
|| policy.contractBlob !== GOAL_REPORT_CONTRACT_BLOB
|
||||
|| policy.contractSha256 !== GOAL_REPORT_CONTRACT_SHA256
|
||||
|| policy.enforcement !== GOAL_POLICY_ENFORCEMENT
|
||||
|| policy.identifierResolution !== GOAL_POLICY_IDENTIFIER_RESOLUTION
|
||||
) {
|
||||
return { ok: false, failure: { stage: "goal-policy-digest" } };
|
||||
}
|
||||
const trustedPath = trustedContractPath(brainHome, policy.contractPath, io);
|
||||
if (!trustedPath.ok) return { ok: false, failure: { stage: trustedPath.stage } };
|
||||
const contractPath = trustedPath.path;
|
||||
|
||||
let stat: ReturnType<LoaderIO["lstatSync"]>;
|
||||
try {
|
||||
stat = io.lstatSync(contractPath);
|
||||
} catch {
|
||||
return { ok: false, failure: { stage: "goal-policy-unreadable" } };
|
||||
}
|
||||
if (stat.isSymbolicLink()) return { ok: false, failure: { stage: "goal-policy-symlink" } };
|
||||
if (!stat.isFile()) return { ok: false, failure: { stage: "goal-policy-non-file" } };
|
||||
|
||||
let bytes: Uint8Array;
|
||||
let source: string;
|
||||
try {
|
||||
bytes = toBytes(io.readFileSync(contractPath));
|
||||
source = decodeUtf8(bytes);
|
||||
} catch {
|
||||
return { ok: false, failure: { stage: "goal-policy-invalid-utf8" } };
|
||||
}
|
||||
if (sha256(bytes) !== policy.contractSha256 || gitBlobSha1(bytes) !== policy.contractBlob) {
|
||||
return { ok: false, failure: { stage: "goal-policy-digest" } };
|
||||
}
|
||||
const heading = `## ${policy.contractSection}`;
|
||||
const first = source.indexOf(heading);
|
||||
if (first < 0 || source.indexOf(heading, first + heading.length) !== -1) {
|
||||
return { ok: false, failure: { stage: "goal-policy-section" } };
|
||||
}
|
||||
|
||||
const generation = context.launchGeneration;
|
||||
if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation < 1) {
|
||||
return { ok: false, failure: { stage: "goal-policy-launch-generation" } };
|
||||
}
|
||||
if (!isOpaqueGoalPolicyId(context.incarnationId)) {
|
||||
return { ok: false, failure: { stage: "goal-policy-incarnation" } };
|
||||
}
|
||||
|
||||
const attestation: GoalPolicyAttestationV1 = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
role: manifest.role,
|
||||
roleRevision: manifest.revision,
|
||||
manifestSha256,
|
||||
format: policy.format,
|
||||
contractPath: policy.contractPath,
|
||||
contractSection: policy.contractSection,
|
||||
contractBlob: policy.contractBlob,
|
||||
contractSha256: policy.contractSha256,
|
||||
enforcement: policy.enforcement,
|
||||
identifierResolution: policy.identifierResolution,
|
||||
launchGeneration: generation,
|
||||
incarnationId: context.incarnationId,
|
||||
});
|
||||
return { ok: true, goalPolicy: { attestation } };
|
||||
}
|
||||
|
||||
type TrustedContractPath =
|
||||
| Readonly<{ ok: true; path: string }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
stage: "goal-policy-unreadable" | "goal-policy-root-symlink" | "goal-policy-parent-symlink" | "goal-policy-symlink" | "goal-policy-path-escape";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Resolve each path component without allowing a contract parent to redirect
|
||||
* a trusted-root read. The leaf check alone is insufficient because
|
||||
* `skills-local` itself can be a symlink to matching bytes outside the brain.
|
||||
*/
|
||||
function trustedContractPath(brainHome: string, relative: string, io: LoaderIO): TrustedContractPath {
|
||||
if (relative !== GOAL_REPORT_CONTRACT_PATH || relative.startsWith("/") || relative.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
||||
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
|
||||
}
|
||||
const root = brainHome.replace(/\/+$/, "");
|
||||
if (!root.startsWith("/") || root === "" || !io.realpathSync) {
|
||||
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
|
||||
}
|
||||
|
||||
try {
|
||||
const rootStat = io.lstatSync(root);
|
||||
if (rootStat.isSymbolicLink()) return Object.freeze({ ok: false, stage: "goal-policy-root-symlink" });
|
||||
const canonicalRoot = io.realpathSync(root).replace(/\/+$/, "");
|
||||
if (!canonicalRoot.startsWith("/") || canonicalRoot === "") {
|
||||
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
|
||||
}
|
||||
|
||||
const parts = relative.split("/");
|
||||
let path = root;
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
path = `${path}/${parts[index]}`;
|
||||
const stat = io.lstatSync(path);
|
||||
if (stat.isSymbolicLink()) {
|
||||
return Object.freeze({ ok: false, stage: index === parts.length - 1 ? "goal-policy-symlink" : "goal-policy-parent-symlink" });
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalPath = io.realpathSync(path);
|
||||
if (canonicalPath !== canonicalRoot && !canonicalPath.startsWith(`${canonicalRoot}/`)) {
|
||||
return Object.freeze({ ok: false, stage: "goal-policy-path-escape" });
|
||||
}
|
||||
return Object.freeze({ ok: true, path });
|
||||
} catch {
|
||||
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
|
||||
}
|
||||
}
|
||||
|
||||
function toBytes(value: string | Uint8Array): Uint8Array {
|
||||
return typeof value === "string" ? Buffer.from(value, "utf8") : Buffer.from(value);
|
||||
}
|
||||
|
||||
function decodeUtf8(value: string | Uint8Array): string {
|
||||
if (typeof value === "string") return value;
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(value);
|
||||
}
|
||||
|
||||
export function sha256(value: string | Uint8Array): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function gitBlobSha1(bytes: Uint8Array): string {
|
||||
const header = Buffer.from(`blob ${bytes.byteLength}\0`, "utf8");
|
||||
return createHash("sha1").update(header).update(bytes).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
// lib/policy.ts — pure role-manifest schema validation (PRD R2, AC1; NG-4 v3).
|
||||
//
|
||||
// NG-1 keeps PURE validation only: no filesystem load, no trusted-path
|
||||
// resolution (NG-2 owns those). The schema is deliberately strict —
|
||||
// everything not named here is rejected, because a manifest that silently
|
||||
// tolerates extra fields is a manifest whose future drift cannot be caught.
|
||||
//
|
||||
// NG-4 (velma B2, memo 9c426fa9): the manifest separates CAPABILITIES from
|
||||
// tool bindings. Capabilities are the authority; Pi tools are their bound
|
||||
// projections. The strict cross-check: the tools array must be EXACTLY the
|
||||
// set of Pi bindings of the bound capabilities — neither more (an
|
||||
// unbound-to-capability tool is undeclared authority) nor less.
|
||||
//
|
||||
// NG-4 remediation F6: the manifest carries the FULL authority set —
|
||||
// schemaVersion, extensions.load order, workspace.readRoots, credentials
|
||||
// (deny-all), evidence permissions (journal-only), and digest/integrity
|
||||
// rules. Settings are generated projections; they never become authority.
|
||||
|
||||
export interface CapabilityBinding {
|
||||
id: string;
|
||||
effect: CapabilityEffect;
|
||||
binding?: string;
|
||||
status: CapabilityStatus;
|
||||
}
|
||||
|
||||
export interface RoleManifestV3 {
|
||||
schemaVersion: 3;
|
||||
role: string;
|
||||
revision: number;
|
||||
capabilities: CapabilityBinding[];
|
||||
tools: string[];
|
||||
extensions: { load: string[] };
|
||||
workspace: { readRoots: string[] };
|
||||
credentials: Record<string, unknown>;
|
||||
evidence: { journal: true; journalEvents: string[]; external: "denied" };
|
||||
digestRules: { algorithm: "sha256"; scope: "manifest-bytes"; postcondition: "active-set-exact" };
|
||||
forbiddenTools: string[];
|
||||
shell: { mode: "denied" };
|
||||
}
|
||||
|
||||
export interface GoalReportPolicy {
|
||||
format: "ms-executive-update/v1";
|
||||
contractPath: "skills-local/ms-executive-update/SKILL.md";
|
||||
contractSection: "Machine contract (for `goal_report` payloads and any parser)";
|
||||
contractBlob: "df30c6fbb54b4a65a298c9e51c07f742610d171c";
|
||||
contractSha256: "bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319";
|
||||
enforcement: "pre-state-change-fail-closed";
|
||||
identifierResolution: "consumer-fail-closed";
|
||||
}
|
||||
|
||||
export interface RoleManifestV4 {
|
||||
schemaVersion: 4;
|
||||
role: "gate-merge-ng" | "plan-ng" | "review-ng";
|
||||
revision: 3 | 4;
|
||||
capabilities: CapabilityBinding[];
|
||||
tools: string[];
|
||||
extensions: { load: string[] };
|
||||
workspace: { readRoots: string[] };
|
||||
credentials: Record<string, unknown>;
|
||||
evidence: Record<string, unknown>;
|
||||
digestRules: { algorithm: "sha256"; scope: "manifest-bytes"; postcondition: "active-set-exact" };
|
||||
forbiddenTools: string[];
|
||||
shell: { mode: "denied" };
|
||||
goalReportPolicy: GoalReportPolicy;
|
||||
targetPolicy?: { kind: "pull-request"; hosts: string[]; repositories: string[]; pinSource: "assignment" };
|
||||
}
|
||||
|
||||
export type RoleManifest = RoleManifestV3 | RoleManifestV4;
|
||||
|
||||
export type CapabilityEffect = "observe" | "propose" | "transition" | "communicate";
|
||||
export type CapabilityStatus = "bound" | "unbound";
|
||||
|
||||
/** Closed capability ids. C1-C8 remain version-3 only; C9-C11 are version-4 gate authority. */
|
||||
const V3_CAPABILITY_IDS = new Set([
|
||||
"policy.status", // C1
|
||||
"improvement.propose", // C2
|
||||
"repo.file.read", // C3
|
||||
"goal.report", // C4
|
||||
"reason.structured", // C5
|
||||
"coord.route_implementation", // C6
|
||||
"coord.request", // C7
|
||||
"comms.send", // C8
|
||||
]);
|
||||
export const CAPABILITY_IDS = new Set([
|
||||
...V3_CAPABILITY_IDS,
|
||||
"repo.target.inspect", // C9
|
||||
"gate.evidence.observe", // C10
|
||||
"gate.verdict", // C11
|
||||
]);
|
||||
|
||||
export const CAPABILITY_EFFECTS = new Set<CapabilityEffect>(["observe", "propose", "transition", "communicate"]);
|
||||
|
||||
/** Fixed normative id-to-binding maps. Version 3 never learns C9-C11. */
|
||||
const V3_NORMATIVE_BINDINGS: Record<string, { binding: string; status: "bound" } | { status: "unbound" }> = {
|
||||
"policy.status": { binding: "mosaic_policy_status", status: "bound" },
|
||||
"improvement.propose": { binding: "mosaic_improvement_propose", status: "bound" },
|
||||
"repo.file.read": { binding: "read", status: "bound" },
|
||||
"goal.report": { binding: "goal_report", status: "bound" },
|
||||
"reason.structured": { status: "unbound" },
|
||||
"coord.route_implementation": { status: "unbound" },
|
||||
"coord.request": { status: "unbound" },
|
||||
"comms.send": { status: "unbound" },
|
||||
};
|
||||
export const NORMATIVE_BINDINGS: Record<string, { binding: string; status: "bound" } | { status: "unbound" }> = {
|
||||
...V3_NORMATIVE_BINDINGS,
|
||||
"repo.target.inspect": { binding: "mosaic_gate_target_read", status: "bound" },
|
||||
"gate.evidence.observe": { binding: "mosaic_gate_evidence_observe", status: "bound" },
|
||||
"gate.verdict": { binding: "mosaic_gate_verdict", status: "bound" },
|
||||
};
|
||||
|
||||
/**
|
||||
* NG4-CRIT: the MANDATORY reviewed forbidden raw-tool set. The manifest's
|
||||
* forbiddenTools must CONTAIN this minimum set (it may add more, never
|
||||
* remove). This prevents the caller from deleting entries to smuggle a
|
||||
* forbidden tool back into scope.
|
||||
*/
|
||||
export const MANDATORY_FORBIDDEN = ["bash", "edit", "write", "powershell", "tmux", "send_message", "git"];
|
||||
export const CAPABILITY_STATUSES = new Set<CapabilityStatus>(["bound", "unbound"]);
|
||||
|
||||
export type PolicyRejection =
|
||||
| "not-an-object"
|
||||
| "unknown-field"
|
||||
| "missing-field"
|
||||
| "invalid-role"
|
||||
| "invalid-revision"
|
||||
| "invalid-schema-version"
|
||||
| "invalid-tools"
|
||||
| "duplicate-tool"
|
||||
| "unsafe-empty-policy"
|
||||
| "invalid-capabilities"
|
||||
| "duplicate-capability"
|
||||
| "unknown-capability"
|
||||
| "invalid-capability-effect"
|
||||
| "invalid-capability-status"
|
||||
| "bound-without-binding"
|
||||
| "unbound-with-binding"
|
||||
| "invalid-capability-binding"
|
||||
| "tools-bindings-mismatch"
|
||||
| "invalid-extensions"
|
||||
| "invalid-workspace"
|
||||
| "invalid-credentials"
|
||||
| "invalid-evidence"
|
||||
| "invalid-digest-rules"
|
||||
| "missing-capability"
|
||||
| "invalid-forbidden-tools"
|
||||
| "invalid-shell"
|
||||
| "forbidden-tool-overlap"
|
||||
| "invalid-journal-events"
|
||||
| "invalid-role-class"
|
||||
| "invalid-goal-report-policy"
|
||||
| "invalid-target-policy"
|
||||
| "version4-exact-set-mismatch";
|
||||
|
||||
export interface PolicyResult {
|
||||
ok: boolean;
|
||||
manifest?: RoleManifest;
|
||||
reason?: PolicyRejection;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const V3_ALLOWED_FIELDS = new Set([
|
||||
"schemaVersion", "role", "revision", "tools", "capabilities",
|
||||
"extensions", "workspace", "credentials", "evidence", "digestRules",
|
||||
"forbiddenTools", "shell",
|
||||
]);
|
||||
const ROLE_RE = /^[a-z][a-z0-9-]*$/;
|
||||
const TOOL_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
const EXT_RE = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export function validateManifest(input: unknown): PolicyResult {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
return { ok: false, reason: "not-an-object", detail: "manifest must be a JSON object" };
|
||||
}
|
||||
const obj = input as Record<string, unknown>;
|
||||
|
||||
// Version 3 remains on its original closed validator. Version 4 is a
|
||||
// separate role-class contract and never widens the v3 capability map.
|
||||
if (obj.schemaVersion === 4) return validateV4Manifest(obj);
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!V3_ALLOWED_FIELDS.has(key)) {
|
||||
return { ok: false, reason: "unknown-field", detail: `unexpected field "${key}"` };
|
||||
}
|
||||
}
|
||||
for (const field of V3_ALLOWED_FIELDS) {
|
||||
if (!(field in obj)) {
|
||||
return { ok: false, reason: "missing-field", detail: `missing field "${field}"` };
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.schemaVersion !== 3) {
|
||||
return { ok: false, reason: "invalid-schema-version", detail: "schemaVersion must be 3" };
|
||||
}
|
||||
|
||||
const role = obj.role;
|
||||
if (typeof role !== "string" || !ROLE_RE.test(role)) {
|
||||
return { ok: false, reason: "invalid-role", detail: `role must match ${ROLE_RE}` };
|
||||
}
|
||||
|
||||
const revision = obj.revision;
|
||||
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
|
||||
return { ok: false, reason: "invalid-revision", detail: "revision must be a positive integer" };
|
||||
}
|
||||
|
||||
// ---- capabilities: the authority layer ------------------------------------
|
||||
const rawCaps = obj.capabilities;
|
||||
if (!Array.isArray(rawCaps) || rawCaps.length === 0 || rawCaps.some((c) => typeof c !== "object" || c === null || Array.isArray(c))) {
|
||||
return { ok: false, reason: "invalid-capabilities", detail: "capabilities must be a non-empty array of objects" };
|
||||
}
|
||||
const seenCaps = new Set<string>();
|
||||
const boundTools: string[] = [];
|
||||
const capabilities: CapabilityBinding[] = [];
|
||||
for (const raw of rawCaps) {
|
||||
const c = raw as Record<string, unknown>;
|
||||
for (const key of Object.keys(c)) {
|
||||
if (!["id", "effect", "binding", "status"].includes(key)) {
|
||||
return { ok: false, reason: "invalid-capabilities", detail: `unknown capability field "${key}"` };
|
||||
}
|
||||
}
|
||||
if (typeof c.id !== "string" || !V3_CAPABILITY_IDS.has(c.id)) {
|
||||
return { ok: false, reason: "unknown-capability", detail: `capability id "${String(c.id)}" is outside the reviewed C1-C8 map` };
|
||||
}
|
||||
if (seenCaps.has(c.id)) {
|
||||
return { ok: false, reason: "duplicate-capability", detail: `duplicate capability "${c.id}"` };
|
||||
}
|
||||
seenCaps.add(c.id);
|
||||
if (typeof c.effect !== "string" || !CAPABILITY_EFFECTS.has(c.effect as CapabilityEffect)) {
|
||||
return { ok: false, reason: "invalid-capability-effect", detail: `capability "${c.id}"` };
|
||||
}
|
||||
// F6H-(5): enforce the normative id->effect mapping from the C1-C8 map
|
||||
const NORMATIVE_EFFECT: Record<string, CapabilityEffect> = {
|
||||
"policy.status": "observe",
|
||||
"improvement.propose": "propose",
|
||||
"repo.file.read": "observe",
|
||||
"goal.report": "transition",
|
||||
"reason.structured": "observe",
|
||||
"coord.route_implementation": "transition",
|
||||
"coord.request": "transition",
|
||||
"comms.send": "communicate",
|
||||
};
|
||||
if (c.effect !== NORMATIVE_EFFECT[c.id]) {
|
||||
return { ok: false, reason: "invalid-capability-effect", detail: `capability "${c.id}" must have effect "${NORMATIVE_EFFECT[c.id]}" (normative C-map), got "${c.effect}"` };
|
||||
}
|
||||
if (typeof c.status !== "string" || !CAPABILITY_STATUSES.has(c.status as CapabilityStatus)) {
|
||||
return { ok: false, reason: "invalid-capability-status", detail: `capability "${c.id}"` };
|
||||
}
|
||||
// NG4-CRIT: enforce the NORMATIVE binding (or mandatory unbound status)
|
||||
const normative = V3_NORMATIVE_BINDINGS[c.id];
|
||||
if (normative.status === "unbound" && c.status === "bound") {
|
||||
return { ok: false, reason: "invalid-capability-status", detail: `capability "${c.id}" must be UNBOUND in this slice (no backend)` };
|
||||
}
|
||||
if (c.status === "bound") {
|
||||
if (normative.status !== "bound") {
|
||||
return { ok: false, reason: "invalid-capability-status", detail: `capability "${c.id}" cannot be bound in this slice` };
|
||||
}
|
||||
if (c.binding !== normative.binding) {
|
||||
return { ok: false, reason: "invalid-capability-binding", detail: `capability "${c.id}" must bind exactly "${normative.binding}" (normative C-map), got "${c.binding}"` };
|
||||
}
|
||||
if (typeof c.binding !== "string" || !TOOL_RE.test(c.binding)) {
|
||||
return { ok: false, reason: "bound-without-binding", detail: `capability "${c.id}" is bound but has no valid tool binding` };
|
||||
}
|
||||
boundTools.push(c.binding);
|
||||
} else if (c.binding !== undefined) {
|
||||
return { ok: false, reason: "unbound-with-binding", detail: `capability "${c.id}" is unbound but declares a binding` };
|
||||
}
|
||||
capabilities.push({
|
||||
id: c.id,
|
||||
effect: c.effect as CapabilityEffect,
|
||||
binding: c.binding as string | undefined,
|
||||
status: c.status as CapabilityStatus,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- tools: the PROJECTION of bound capabilities, exactly ------------------
|
||||
const tools = obj.tools;
|
||||
if (!Array.isArray(tools) || tools.some((t) => typeof t !== "string" || !TOOL_RE.test(t))) {
|
||||
return { ok: false, reason: "invalid-tools", detail: `every tool must match ${TOOL_RE}` };
|
||||
}
|
||||
if (tools.length === 0) {
|
||||
return { ok: false, reason: "unsafe-empty-policy", detail: "tools must not be empty" };
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const t of tools) {
|
||||
if (seen.has(t)) {
|
||||
return { ok: false, reason: "duplicate-tool", detail: `duplicate tool "${t}"` };
|
||||
}
|
||||
seen.add(t);
|
||||
}
|
||||
const expected = [...new Set(boundTools)].sort();
|
||||
const declared = [...new Set(tools)].sort();
|
||||
if (JSON.stringify(expected) !== JSON.stringify(declared)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "tools-bindings-mismatch",
|
||||
detail: `tools must be exactly the bound capabilities' bindings (expected [${expected.join(", ")}])`,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- NG4-CRIT3: C1-C4 MUST be present and bound (canary contract) -------------
|
||||
const REQUIRED_BOUND = ["policy.status", "improvement.propose", "repo.file.read", "goal.report"];
|
||||
for (const reqId of REQUIRED_BOUND) {
|
||||
const cap = capabilities.find((c) => c.id === reqId);
|
||||
if (!cap) {
|
||||
return { ok: false, reason: "missing-capability", detail: `canary roles must declare capability "${reqId}" (C1-C4 are mandatory)` };
|
||||
}
|
||||
if (cap.status !== "bound") {
|
||||
return { ok: false, reason: "invalid-capability-status", detail: `capability "${reqId}" must be BOUND (C1-C4 are mandatory for canary roles)` };
|
||||
}
|
||||
}
|
||||
// C5-C8: if present, must be unbound (first slice — no backend)
|
||||
for (const cap of capabilities) {
|
||||
if (V3_NORMATIVE_BINDINGS[cap.id]?.status === "unbound" && cap.status === "bound") {
|
||||
return { ok: false, reason: "invalid-capability-status", detail: `capability "${cap.id}" must be UNBOUND in this slice` };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- extensions: load order authority ----------------------------------------
|
||||
const exts = obj.extensions;
|
||||
if (typeof exts !== "object" || exts === null || Array.isArray(exts) || !Array.isArray((exts as Record<string, unknown>).load)) {
|
||||
return { ok: false, reason: "invalid-extensions", detail: "extensions.load must be an ordered array" };
|
||||
}
|
||||
// F6H-(2): reject unknown nested keys in extensions
|
||||
for (const ek of Object.keys(exts)) {
|
||||
if (ek !== "load") {
|
||||
return { ok: false, reason: "invalid-extensions", detail: `unknown extensions field "${ek}"` };
|
||||
}
|
||||
}
|
||||
const loadOrder = (exts as { load: unknown[] }).load;
|
||||
if (loadOrder.some((e) => typeof e !== "string" || !EXT_RE.test(e))) {
|
||||
return { ok: false, reason: "invalid-extensions", detail: "extension names must be slugs" };
|
||||
}
|
||||
if (new Set(loadOrder).size !== loadOrder.length) {
|
||||
return { ok: false, reason: "invalid-extensions", detail: "duplicate extension in load order" };
|
||||
}
|
||||
// F6H-(3): the reviewed required load order is exact — goal FIRST (registers
|
||||
// goal_report), mosaic-core LAST (reconciles the complete registry).
|
||||
// Anything else is unreviewed authority.
|
||||
const REQUIRED_LOAD = ["goal", "mosaic-core"];
|
||||
if (JSON.stringify(loadOrder) !== JSON.stringify(REQUIRED_LOAD)) {
|
||||
return { ok: false, reason: "invalid-extensions", detail: `extensions.load must be exactly [${REQUIRED_LOAD.join(", ")}] — goal first (registers goal_report), mosaic-core last (reconciles the complete registry)` };
|
||||
}
|
||||
|
||||
// ---- workspace: read-scope roots (symbolic @brain or absolute) ---------------
|
||||
const ws = obj.workspace;
|
||||
if (typeof ws !== "object" || ws === null || Array.isArray(ws) || !Array.isArray((ws as Record<string, unknown>).readRoots)) {
|
||||
return { ok: false, reason: "invalid-workspace", detail: "workspace.readRoots must be an array" };
|
||||
}
|
||||
// F6H-(2): reject unknown nested keys in workspace
|
||||
for (const wk of Object.keys(ws)) {
|
||||
if (wk !== "readRoots") {
|
||||
return { ok: false, reason: "invalid-workspace", detail: `unknown workspace field "${wk}"` };
|
||||
}
|
||||
}
|
||||
const readRoots = (ws as { readRoots: unknown[] }).readRoots;
|
||||
if (readRoots.some((r) => typeof r !== "string" || !(r.startsWith("/") || r === "@brain"))) {
|
||||
return { ok: false, reason: "invalid-workspace", detail: "readRoots must be absolute paths or the symbolic @brain root" };
|
||||
}
|
||||
|
||||
// ---- credentials: deny-all, EXACT (F6) ----------------------------------------
|
||||
const creds = obj.credentials;
|
||||
if (typeof creds !== "object" || creds === null || Array.isArray(creds)) {
|
||||
return { ok: false, reason: "invalid-credentials", detail: "credentials must be an object" };
|
||||
}
|
||||
const credKeys = Object.keys(creds);
|
||||
if (credKeys.some((k) => !["store", "helper", "providerTokens"].includes(k))) {
|
||||
return { ok: false, reason: "invalid-credentials", detail: "unknown credentials field" };
|
||||
}
|
||||
if (creds.store !== "none" || creds.helper !== "none") {
|
||||
return { ok: false, reason: "invalid-credentials", detail: "canary roles carry no credential store or helper" };
|
||||
}
|
||||
if (creds.providerTokens !== "denied") {
|
||||
return { ok: false, reason: "invalid-credentials", detail: 'providerTokens must be exactly "denied" (mandatory deny-all)' };
|
||||
}
|
||||
|
||||
// ---- evidence: journal-only (F6) ----------------------------------------------
|
||||
const ev = obj.evidence;
|
||||
if (typeof ev !== "object" || ev === null || Array.isArray(ev)) {
|
||||
return { ok: false, reason: "invalid-evidence", detail: "evidence must be an object" };
|
||||
}
|
||||
const evKeys = Object.keys(ev);
|
||||
if (evKeys.some((k) => !["journal", "journalEvents", "external"].includes(k))) {
|
||||
return { ok: false, reason: "invalid-evidence", detail: "unknown evidence field" };
|
||||
}
|
||||
if (ev.journal !== true) {
|
||||
return { ok: false, reason: "invalid-evidence", detail: "canary roles must journal (the only evidence sink)" };
|
||||
}
|
||||
if (ev.external !== "denied") {
|
||||
return { ok: false, reason: "invalid-evidence", detail: "external evidence must be denied for canary roles" };
|
||||
}
|
||||
const REVIEWED_EVENTS = ["reconciliation", "denial", "improvement-proposal"];
|
||||
if (!Array.isArray(ev.journalEvents) || ev.journalEvents.length !== REVIEWED_EVENTS.length) {
|
||||
return { ok: false, reason: "invalid-journal-events", detail: `journalEvents must be exactly the reviewed set [${REVIEWED_EVENTS.join(", ")}]` };
|
||||
}
|
||||
const evSeen = new Set<string>();
|
||||
for (const e of ev.journalEvents) {
|
||||
if (typeof e !== "string" || !REVIEWED_EVENTS.includes(e)) {
|
||||
return { ok: false, reason: "invalid-journal-events", detail: `unknown journal event "${String(e)}"` };
|
||||
}
|
||||
if (evSeen.has(e)) {
|
||||
return { ok: false, reason: "invalid-journal-events", detail: `duplicate journal event "${e}"` };
|
||||
}
|
||||
evSeen.add(e);
|
||||
}
|
||||
|
||||
// ---- digest/integrity rules: explicit and closed (F6) --------------------------
|
||||
const dig = obj.digestRules;
|
||||
if (typeof dig !== "object" || dig === null || Array.isArray(dig)) {
|
||||
return { ok: false, reason: "invalid-digest-rules", detail: "digestRules must be an object" };
|
||||
}
|
||||
const digKeys = Object.keys(dig);
|
||||
if (digKeys.some((k) => !["algorithm", "scope", "postcondition"].includes(k))) {
|
||||
return { ok: false, reason: "invalid-digest-rules", detail: "unknown digestRules field" };
|
||||
}
|
||||
if (dig.algorithm !== "sha256") {
|
||||
return { ok: false, reason: "invalid-digest-rules", detail: "digest algorithm must be sha256" };
|
||||
}
|
||||
if (dig.scope !== "manifest-bytes") {
|
||||
return { ok: false, reason: "invalid-digest-rules", detail: "digest scope must be manifest-bytes" };
|
||||
}
|
||||
if (dig.postcondition !== "active-set-exact") {
|
||||
return { ok: false, reason: "invalid-digest-rules", detail: "postcondition must be active-set-exact" };
|
||||
}
|
||||
|
||||
// ---- forbiddenTools (auth): the reviewed absent set — these tools must
|
||||
// never appear in the bound tools; overlap is a typed rejection
|
||||
const forbidden = obj.forbiddenTools;
|
||||
if (!Array.isArray(forbidden) || forbidden.length === 0 || forbidden.some((t) => typeof t !== "string" || !TOOL_RE.test(t))) {
|
||||
return { ok: false, reason: "invalid-forbidden-tools", detail: "forbiddenTools must be a non-empty array of valid tool names" };
|
||||
}
|
||||
const forbiddenSet = new Set(forbidden as string[]);
|
||||
if (forbiddenSet.size !== (forbidden as string[]).length) {
|
||||
return { ok: false, reason: "invalid-forbidden-tools", detail: "duplicate entry in forbiddenTools" };
|
||||
}
|
||||
// NG4-CRIT: the manifest MUST contain the mandatory reviewed forbidden set
|
||||
// (it may add more, never remove)
|
||||
for (const m of MANDATORY_FORBIDDEN) {
|
||||
if (!forbiddenSet.has(m)) {
|
||||
return { ok: false, reason: "invalid-forbidden-tools", detail: `mandatory forbidden tool "${m}" is absent from forbiddenTools (cannot be removed)` };
|
||||
}
|
||||
}
|
||||
for (const t of tools) {
|
||||
if (forbiddenSet.has(t)) {
|
||||
return { ok: false, reason: "forbidden-tool-overlap", detail: `tool "${t}" is both bound and forbidden` };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shell policy (auth): canary roles deny all shell access
|
||||
const shell = obj.shell;
|
||||
if (typeof shell !== "object" || shell === null || Array.isArray(shell)) {
|
||||
return { ok: false, reason: "invalid-shell", detail: "shell must be an object" };
|
||||
}
|
||||
const shellKeys = Object.keys(shell);
|
||||
if (shellKeys.some((k) => !["mode"].includes(k))) {
|
||||
return { ok: false, reason: "invalid-shell", detail: "unknown shell field" };
|
||||
}
|
||||
if (shell.mode !== "denied") {
|
||||
return { ok: false, reason: "invalid-shell", detail: "canary roles deny all shell access (shell.mode must be \"denied\")" };
|
||||
}
|
||||
|
||||
// ---- C3 scope (F7): bound read capability requires declared scope ---------------
|
||||
const c3 = capabilities.find((c) => c.id === "repo.file.read");
|
||||
if (c3 && c3.status === "bound" && readRoots.length === 0) {
|
||||
return { ok: false, reason: "invalid-workspace", detail: "C3 repo.file.read is bound but workspace.readRoots is empty — declare the read scope or leave C3 unbound" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
manifest: {
|
||||
schemaVersion: 3,
|
||||
role,
|
||||
revision,
|
||||
capabilities,
|
||||
tools: [...tools],
|
||||
extensions: { load: [...loadOrder] as string[] },
|
||||
workspace: { readRoots: [...readRoots] as string[] },
|
||||
credentials: { ...(creds as Record<string, unknown>) },
|
||||
evidence: { journal: true, journalEvents: [...(ev as { journalEvents: string[] }).journalEvents], external: "denied" },
|
||||
digestRules: { algorithm: "sha256", scope: "manifest-bytes", postcondition: "active-set-exact" },
|
||||
forbiddenTools: [...forbidden] as string[],
|
||||
shell: { mode: "denied" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- schema version 4: closed, role-class-specific authority -----------------
|
||||
|
||||
type V4Role = "gate-merge-ng" | "plan-ng" | "review-ng";
|
||||
interface V4RoleSpec {
|
||||
revision: 3 | 4;
|
||||
capabilities: ReadonlyArray<Readonly<{ id: string; effect: CapabilityEffect; binding?: string; status: CapabilityStatus }>>;
|
||||
tools: readonly string[];
|
||||
extensions: readonly string[];
|
||||
readRoots: readonly string[];
|
||||
credentials: Record<string, unknown>;
|
||||
evidence: Record<string, unknown>;
|
||||
forbiddenTools: readonly string[];
|
||||
targetPolicy?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const GOAL_POLICY_FIELDS = [
|
||||
"format", "contractPath", "contractSection", "contractBlob", "contractSha256", "enforcement", "identifierResolution",
|
||||
] as const;
|
||||
const GOAL_POLICY_VALUES: GoalReportPolicy = {
|
||||
format: "ms-executive-update/v1",
|
||||
contractPath: "skills-local/ms-executive-update/SKILL.md",
|
||||
contractSection: "Machine contract (for `goal_report` payloads and any parser)",
|
||||
contractBlob: "df30c6fbb54b4a65a298c9e51c07f742610d171c",
|
||||
contractSha256: "bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319",
|
||||
enforcement: "pre-state-change-fail-closed",
|
||||
identifierResolution: "consumer-fail-closed",
|
||||
};
|
||||
|
||||
const V4_COMMON_POLICY = { algorithm: "sha256", scope: "manifest-bytes", postcondition: "active-set-exact" } as const;
|
||||
const PLAN_REVIEW_CAPABILITIES = [
|
||||
{ id: "policy.status", effect: "observe", binding: "mosaic_policy_status", status: "bound" },
|
||||
{ id: "improvement.propose", effect: "propose", binding: "mosaic_improvement_propose", status: "bound" },
|
||||
{ id: "repo.file.read", effect: "observe", binding: "read", status: "bound" },
|
||||
{ id: "goal.report", effect: "transition", binding: "goal_report", status: "bound" },
|
||||
{ id: "coord.route_implementation", effect: "transition", status: "unbound" },
|
||||
{ id: "comms.send", effect: "communicate", status: "unbound" },
|
||||
] as const;
|
||||
const GATE_CAPABILITIES = [
|
||||
{ id: "policy.status", effect: "observe", binding: "mosaic_policy_status", status: "bound" },
|
||||
{ id: "goal.report", effect: "transition", binding: "goal_report", status: "bound" },
|
||||
{ id: "repo.target.inspect", effect: "observe", binding: "mosaic_gate_target_read", status: "bound" },
|
||||
{ id: "gate.evidence.observe", effect: "observe", binding: "mosaic_gate_evidence_observe", status: "bound" },
|
||||
{ id: "gate.verdict", effect: "transition", binding: "mosaic_gate_verdict", status: "bound" },
|
||||
] as const;
|
||||
const PLAN_REVIEW_FORBIDDEN = ["bash", "edit", "git", "powershell", "send_message", "tmux", "write"] as const;
|
||||
const GATE_FORBIDDEN = [
|
||||
"bash", "browser", "edit", "fetch", "find", "git", "grep", "http", "ls", "mosaic_improvement_propose",
|
||||
"powershell", "pr_merge", "read", "send_message", "tmux", "web", "write",
|
||||
] as const;
|
||||
|
||||
const V4_SPECS: Record<V4Role, V4RoleSpec> = {
|
||||
"gate-merge-ng": {
|
||||
revision: 3,
|
||||
capabilities: GATE_CAPABILITIES,
|
||||
tools: ["goal_report", "mosaic_gate_evidence_observe", "mosaic_gate_target_read", "mosaic_gate_verdict", "mosaic_policy_status"],
|
||||
extensions: ["goal", "gate-merge", "mosaic-core"],
|
||||
readRoots: [],
|
||||
credentials: { seatStore: "none", helper: "none", providerTokens: "denied", brokerChannel: "launcher-inherited-attested-fd" },
|
||||
evidence: { localJournal: true, journalEvents: ["reconciliation", "denial", "gate-observation", "gate-verdict-attempt"], canonicalWriter: "mosaic-gate-broker", external: "gate-evidence-and-verdict-only" },
|
||||
forbiddenTools: GATE_FORBIDDEN,
|
||||
targetPolicy: { kind: "pull-request", hosts: ["git.mosaicstack.dev"], repositories: ["mosaicstack/stack"], pinSource: "assignment" },
|
||||
},
|
||||
"plan-ng": {
|
||||
revision: 4,
|
||||
capabilities: PLAN_REVIEW_CAPABILITIES,
|
||||
tools: ["goal_report", "mosaic_improvement_propose", "mosaic_policy_status", "read"],
|
||||
extensions: ["goal", "mosaic-core"],
|
||||
readRoots: ["@brain"],
|
||||
credentials: { store: "none", helper: "none", providerTokens: "denied" },
|
||||
evidence: { journal: true, journalEvents: ["reconciliation", "denial", "improvement-proposal"], external: "denied" },
|
||||
forbiddenTools: PLAN_REVIEW_FORBIDDEN,
|
||||
},
|
||||
"review-ng": {
|
||||
revision: 4,
|
||||
capabilities: PLAN_REVIEW_CAPABILITIES,
|
||||
tools: ["goal_report", "mosaic_improvement_propose", "mosaic_policy_status", "read"],
|
||||
extensions: ["goal", "mosaic-core"],
|
||||
readRoots: ["@brain"],
|
||||
credentials: { store: "none", helper: "none", providerTokens: "denied" },
|
||||
evidence: { journal: true, journalEvents: ["reconciliation", "denial", "improvement-proposal"], external: "denied" },
|
||||
forbiddenTools: PLAN_REVIEW_FORBIDDEN,
|
||||
},
|
||||
};
|
||||
|
||||
function exactObject(value: unknown, expected: Record<string, unknown>): boolean {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
const expectedKeys = Object.keys(expected).sort();
|
||||
if (keys.length !== expectedKeys.length || keys.some((key, index) => key !== expectedKeys[index])) return false;
|
||||
return expectedKeys.every((key) => exactValue(obj[key], expected[key]));
|
||||
}
|
||||
|
||||
function exactValue(actual: unknown, expected: unknown): boolean {
|
||||
if (Array.isArray(expected)) {
|
||||
return Array.isArray(actual) && actual.length === expected.length && actual.every((entry, index) => exactValue(entry, expected[index]));
|
||||
}
|
||||
if (typeof expected === "object" && expected !== null) return exactObject(actual, expected as Record<string, unknown>);
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
function sameStringSet(value: unknown, expected: readonly string[]): boolean {
|
||||
return Array.isArray(value)
|
||||
&& value.every((entry) => typeof entry === "string")
|
||||
&& new Set(value).size === value.length
|
||||
&& value.length === expected.length
|
||||
&& [...value].sort().every((entry, index) => entry === [...expected].sort()[index]);
|
||||
}
|
||||
|
||||
function exactCapabilitySet(value: unknown, expected: V4RoleSpec["capabilities"]): boolean {
|
||||
if (!Array.isArray(value) || value.length !== expected.length) return false;
|
||||
const byId = new Map<string, Record<string, unknown>>();
|
||||
for (const raw of value) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return false;
|
||||
const cap = raw as Record<string, unknown>;
|
||||
if (typeof cap.id !== "string" || byId.has(cap.id)) return false;
|
||||
byId.set(cap.id, cap);
|
||||
}
|
||||
return expected.every((definition) => {
|
||||
const cap = byId.get(definition.id);
|
||||
if (!cap) return false;
|
||||
const expectedKeys = definition.status === "bound" ? ["id", "effect", "binding", "status"] : ["id", "effect", "status"];
|
||||
if (Object.keys(cap).length !== expectedKeys.length || Object.keys(cap).some((key) => !expectedKeys.includes(key))) return false;
|
||||
const normative = NORMATIVE_BINDINGS[definition.id];
|
||||
return !!normative
|
||||
&& definition.effect === cap.effect
|
||||
&& definition.status === cap.status
|
||||
&& cap.effect === (definition.effect as string)
|
||||
&& cap.status === definition.status
|
||||
&& (definition.status === "unbound" || ("binding" in definition && cap.binding === definition.binding && normative.status === "bound" && normative.binding === definition.binding));
|
||||
});
|
||||
}
|
||||
|
||||
function validateV4Manifest(obj: Record<string, unknown>): PolicyResult {
|
||||
const role = obj.role;
|
||||
if (role !== "gate-merge-ng" && role !== "plan-ng" && role !== "review-ng") {
|
||||
return { ok: false, reason: "invalid-role-class", detail: "schema version 4 supports only gate-merge-ng, plan-ng, and review-ng" };
|
||||
}
|
||||
const spec = V4_SPECS[role];
|
||||
const rootFields = [
|
||||
"schemaVersion", "role", "revision", "capabilities", "tools", "extensions", "workspace", "credentials", "evidence",
|
||||
"digestRules", "forbiddenTools", "shell", "goalReportPolicy", ...(spec.targetPolicy ? ["targetPolicy"] : []),
|
||||
];
|
||||
if (Object.keys(obj).length !== rootFields.length || Object.keys(obj).some((key) => !rootFields.includes(key))) {
|
||||
return { ok: false, reason: "unknown-field", detail: "schema version 4 manifest fields must be exact" };
|
||||
}
|
||||
if (obj.schemaVersion !== 4) return { ok: false, reason: "invalid-schema-version", detail: "schemaVersion must be 4" };
|
||||
if (obj.revision !== spec.revision) return { ok: false, reason: "invalid-revision", detail: `${role} must have revision ${spec.revision}` };
|
||||
if (!exactCapabilitySet(obj.capabilities, spec.capabilities)) return { ok: false, reason: "version4-exact-set-mismatch", detail: "capabilities must be the exact reviewed role-class set" };
|
||||
if (!sameStringSet(obj.tools, spec.tools)) return { ok: false, reason: "tools-bindings-mismatch", detail: "tools must be the exact reviewed bound capability projection" };
|
||||
if (!exactObject(obj.extensions, { load: spec.extensions })) return { ok: false, reason: "invalid-extensions", detail: "extensions.load must be the exact reviewed order" };
|
||||
if (!exactObject(obj.workspace, { readRoots: spec.readRoots })) return { ok: false, reason: "invalid-workspace", detail: "workspace.readRoots must be exact for the role class" };
|
||||
if (!exactObject(obj.credentials, spec.credentials)) return { ok: false, reason: "invalid-credentials", detail: "credentials policy must be exact" };
|
||||
if (!exactObject(obj.evidence, spec.evidence)) return { ok: false, reason: "invalid-evidence", detail: "evidence policy must be exact" };
|
||||
if (!exactObject(obj.digestRules, V4_COMMON_POLICY)) return { ok: false, reason: "invalid-digest-rules", detail: "digest rules must be exact" };
|
||||
if (!sameStringSet(obj.forbiddenTools, spec.forbiddenTools)) return { ok: false, reason: "invalid-forbidden-tools", detail: "forbidden tools must be the exact reviewed set" };
|
||||
if (!exactObject(obj.shell, { mode: "denied" })) return { ok: false, reason: "invalid-shell", detail: "shell.mode must be denied" };
|
||||
if (!exactObject(obj.goalReportPolicy, GOAL_POLICY_VALUES)) return { ok: false, reason: "invalid-goal-report-policy", detail: "goalReportPolicy must pin the reviewed contract exactly" };
|
||||
if (spec.targetPolicy) {
|
||||
if (!exactObject(obj.targetPolicy, spec.targetPolicy)) return { ok: false, reason: "invalid-target-policy", detail: "targetPolicy must pin the reviewed assignment scope" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
manifest: {
|
||||
schemaVersion: 4,
|
||||
role,
|
||||
revision: spec.revision,
|
||||
capabilities: (obj.capabilities as CapabilityBinding[]).map((capability) => ({ ...capability })),
|
||||
tools: [...obj.tools as string[]],
|
||||
extensions: { load: [...(obj.extensions as { load: string[] }).load] },
|
||||
workspace: { readRoots: [...(obj.workspace as { readRoots: string[] }).readRoots] },
|
||||
credentials: { ...(obj.credentials as Record<string, unknown>) },
|
||||
evidence: { ...(obj.evidence as Record<string, unknown>) },
|
||||
digestRules: { ...V4_COMMON_POLICY },
|
||||
forbiddenTools: [...obj.forbiddenTools as string[]],
|
||||
shell: { mode: "denied" },
|
||||
goalReportPolicy: { ...GOAL_POLICY_VALUES },
|
||||
...(spec.targetPolicy ? { targetPolicy: { ...(spec.targetPolicy as { kind: "pull-request"; hosts: string[]; repositories: string[]; pinSource: "assignment" }) } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// lib/proposal.ts — structured improvement-proposal sanitization (PRD R7, AC7).
|
||||
//
|
||||
// An improvement proposal is RECORDED, never applied: the only consumer is
|
||||
// the append-only journal. The sanitizer is the boundary that makes "record
|
||||
// structured proposals" safe to expose to the model — strict shape, bounded
|
||||
// text, normalized control characters, unknown fields rejected (same
|
||||
// strictness doctrine as the manifest validator: tolerating extra fields
|
||||
// means being unable to catch drift).
|
||||
|
||||
export interface SanitizedProposal {
|
||||
target: string; // what the proposal concerns: role slug, tool name, or policy field
|
||||
summary: string; // <= 500 chars, single line
|
||||
motivation: string; // <= 2000 chars, newlines normalized to spaces
|
||||
evidence?: string[]; // 0..8 items, each <= 500 chars
|
||||
}
|
||||
|
||||
export type ProposalRejection =
|
||||
| "not-an-object"
|
||||
| "unknown-field"
|
||||
| "missing-field"
|
||||
| "invalid-target"
|
||||
| "oversized"
|
||||
| "invalid-evidence";
|
||||
|
||||
export interface ProposalResult {
|
||||
ok: boolean;
|
||||
proposal?: SanitizedProposal;
|
||||
reason?: ProposalRejection;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const FIELDS = new Set(["target", "summary", "motivation", "evidence"]);
|
||||
const SLUG_RE = /^[a-z][a-z0-9_.:-]*$/i;
|
||||
const LIMITS = { summary: 500, motivation: 2000, evidenceItems: 8, evidenceItem: 500 };
|
||||
|
||||
function normalize(s: unknown): string | null {
|
||||
if (typeof s !== "string") return null;
|
||||
// collapse all line-breaking and control characters to single spaces so a
|
||||
// proposal can never smuggle structure past the JSONL line boundary
|
||||
return s.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function sanitizeProposal(input: unknown): ProposalResult {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
return { ok: false, reason: "not-an-object" };
|
||||
}
|
||||
const obj = input as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!FIELDS.has(key)) return { ok: false, reason: "unknown-field", detail: `unexpected field "${key}"` };
|
||||
}
|
||||
for (const field of ["target", "summary", "motivation"]) {
|
||||
if (!(field in obj)) return { ok: false, reason: "missing-field", detail: `missing field "${field}"` };
|
||||
}
|
||||
|
||||
const target = normalize(obj.target);
|
||||
if (target === null || target === "" || !SLUG_RE.test(target) || target.length > 100) {
|
||||
return { ok: false, reason: "invalid-target" };
|
||||
}
|
||||
|
||||
const summary = normalize(obj.summary);
|
||||
if (summary === null || summary === "") return { ok: false, reason: "invalid-target", detail: "summary empty" };
|
||||
if (summary.length > LIMITS.summary) return { ok: false, reason: "oversized", detail: "summary" };
|
||||
|
||||
const motivation = normalize(obj.motivation);
|
||||
if (motivation === null || motivation === "") {
|
||||
return { ok: false, reason: "invalid-target", detail: "motivation empty" };
|
||||
}
|
||||
if (motivation.length > LIMITS.motivation) return { ok: false, reason: "oversized", detail: "motivation" };
|
||||
|
||||
let evidence: string[] | undefined;
|
||||
if ("evidence" in obj) {
|
||||
const raw = obj.evidence;
|
||||
if (!Array.isArray(raw) || raw.length > LIMITS.evidenceItems) {
|
||||
return { ok: false, reason: "invalid-evidence", detail: "evidence must be an array of at most 8 items" };
|
||||
}
|
||||
const items: string[] = [];
|
||||
for (const item of raw) {
|
||||
const n = normalize(item);
|
||||
if (n === null || n === "") return { ok: false, reason: "invalid-evidence" };
|
||||
if (n.length > LIMITS.evidenceItem) return { ok: false, reason: "oversized", detail: "evidence item" };
|
||||
items.push(n);
|
||||
}
|
||||
evidence = items;
|
||||
}
|
||||
|
||||
return { ok: true, proposal: { target, summary, motivation, evidence } };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// lib/reconcile.ts — pure exact active-tool reconciliation (PRD R3 core, AC2).
|
||||
//
|
||||
// The reconciliation output is the COMPLETE target state: applying it makes
|
||||
// the active tool set EXACTLY the manifest's set — no more (an injected
|
||||
// third-party tool is removed), no less (a manifest tool missing from the
|
||||
// active set is listed for addition). NG-2 binds this to setActiveTools();
|
||||
// this module never touches Pi state.
|
||||
|
||||
export interface ReconcilePlan {
|
||||
/** the exact target active set (sorted manifest tools) */
|
||||
exact: string[];
|
||||
/** active tools absent from the manifest — must be removed */
|
||||
toRemove: string[];
|
||||
/** manifest tools absent from the active set — must be added */
|
||||
toAdd: string[];
|
||||
/** true when the active set already equals the manifest set */
|
||||
unchanged: boolean;
|
||||
}
|
||||
|
||||
export function reconcileActiveTools(manifestTools: string[], activeTools: string[]): ReconcilePlan {
|
||||
const target = [...new Set(manifestTools)].sort();
|
||||
const active = [...new Set(activeTools)].sort();
|
||||
const inManifest = new Set(target);
|
||||
const inActive = new Set(active);
|
||||
const toRemove = active.filter((t) => !inManifest.has(t));
|
||||
const toAdd = target.filter((t) => !inActive.has(t));
|
||||
return { exact: target, toRemove, toAdd, unchanged: toRemove.length === 0 && toAdd.length === 0 };
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the canonical goal extension through its isolated project-local installation.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
unset MOSAIC_LAUNCH_INCARNATION
|
||||
scripts/sync-dev-extensions.sh
|
||||
exec pi --no-extensions --extension "$PWD/.pi/extensions/goal/index.ts" \
|
||||
--session-dir "$PWD/.pi/state/sessions" "$@"
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install canonical extension source into the project-local native Pi directory.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
MODE="sync"
|
||||
case "${1:-}" in
|
||||
"") ;;
|
||||
--check) MODE="check" ;;
|
||||
*) echo "usage: scripts/sync-dev-extensions.sh [--check]" >&2; exit 4 ;;
|
||||
esac
|
||||
|
||||
SOURCE_ROOT="$PWD/extensions"
|
||||
PI_ROOT="$PWD/.pi"
|
||||
DEST="$PI_ROOT/extensions"
|
||||
RECORD="$PI_ROOT/extensions.installed.sha256"
|
||||
LOCK="$PI_ROOT/.extensions-sync.lock"
|
||||
TRANSACTION="$PI_ROOT/.extensions-transaction"
|
||||
BACKUP="$PI_ROOT/.extensions-backup"
|
||||
MANAGED=(goal mosaic-core)
|
||||
|
||||
mkdir -p "$PI_ROOT"
|
||||
command -v flock >/dev/null || { echo "sync-dev-extensions: flock is required" >&2; exit 2; }
|
||||
exec 9>"$LOCK"
|
||||
if ! flock -n 9; then
|
||||
echo "sync-dev-extensions: another sync is active" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
manifest_tree() {
|
||||
local root="$1" output="$2"
|
||||
if find "$root" -type l -print -quit | grep -q .; then
|
||||
echo "sync-dev-extensions: symlink found in $root" >&2
|
||||
return 2
|
||||
fi
|
||||
(
|
||||
cd "$root"
|
||||
while IFS= read -r -d '' path; do
|
||||
path="${path#./}"
|
||||
if [ -d "$path" ]; then
|
||||
printf 'd %q\n' "$path"
|
||||
elif [ -f "$path" ]; then
|
||||
printf 'f %s %q\n' "$(sha256sum "$path" | cut -d ' ' -f 1)" "$path"
|
||||
else
|
||||
echo "sync-dev-extensions: unsupported entry $path" >&2
|
||||
exit 2
|
||||
fi
|
||||
done < <(find . -mindepth 1 -print0 | LC_ALL=C sort -z)
|
||||
) > "$output"
|
||||
}
|
||||
|
||||
# Recover a process killed after transaction intent was recorded. A matching
|
||||
# destination and installed manifest commits; every other state rolls back.
|
||||
recover_transaction() {
|
||||
[ -f "$TRANSACTION" ] || return 0
|
||||
local current="$PI_ROOT/.recovery-manifest.$$"
|
||||
local destination_matches=false
|
||||
if [ -d "$DEST" ] && [ -f "$RECORD" ] && manifest_tree "$DEST" "$current" && cmp -s "$RECORD" "$current"; then
|
||||
destination_matches=true
|
||||
fi
|
||||
rm -f "$current"
|
||||
if [ -d "$BACKUP" ]; then
|
||||
if [ "$destination_matches" = "true" ]; then
|
||||
rm -rf "$BACKUP"
|
||||
else
|
||||
rm -rf "$DEST"
|
||||
mv "$BACKUP" "$DEST"
|
||||
fi
|
||||
elif [ "$destination_matches" = "false" ]; then
|
||||
rm -rf "$DEST"
|
||||
fi
|
||||
rm -f "$TRANSACTION"
|
||||
}
|
||||
|
||||
recover_transaction
|
||||
find "$PI_ROOT" -mindepth 1 -maxdepth 1 -type d -name '.extensions-sync.*' -exec rm -rf {} +
|
||||
WORK="$(mktemp -d "$PI_ROOT/.extensions-sync.XXXXXX")"
|
||||
COMMITTED=false
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if [ "$COMMITTED" = "false" ] && [ -f "$TRANSACTION" ]; then recover_transaction || true; fi
|
||||
rm -rf "$WORK"
|
||||
exit "$rc"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
for name in "${MANAGED[@]}"; do
|
||||
[ -d "$SOURCE_ROOT/$name" ] || { echo "sync-dev-extensions: missing source $SOURCE_ROOT/$name" >&2; exit 2; }
|
||||
done
|
||||
if find "$SOURCE_ROOT" -type l -print -quit | grep -q .; then
|
||||
echo "sync-dev-extensions: source symlinks are forbidden" >&2
|
||||
exit 2
|
||||
fi
|
||||
mapfile -t ENTRYPOINTS < <(find "$SOURCE_ROOT" -name index.ts -type f -printf '%P\n' | LC_ALL=C sort)
|
||||
if [ "${#ENTRYPOINTS[@]}" -ne 1 ] || [ "${ENTRYPOINTS[0]}" != "goal/index.ts" ]; then
|
||||
echo "sync-dev-extensions: expected exactly one entrypoint, goal/index.ts" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
EXPECTED="$WORK/expected"
|
||||
mkdir "$EXPECTED"
|
||||
for name in "${MANAGED[@]}"; do cp -a "$SOURCE_ROOT/$name" "$EXPECTED/$name"; done
|
||||
EXPECTED_MANIFEST="$WORK/expected.sha256"
|
||||
manifest_tree "$EXPECTED" "$EXPECTED_MANIFEST"
|
||||
|
||||
if [ "$MODE" = "check" ]; then
|
||||
[ -d "$DEST" ] || { echo "sync-dev-extensions: development installation is missing" >&2; exit 1; }
|
||||
CURRENT="$WORK/current.sha256"
|
||||
manifest_tree "$DEST" "$CURRENT" || exit 1
|
||||
if ! cmp -s "$EXPECTED_MANIFEST" "$CURRENT"; then
|
||||
echo "sync-dev-extensions: development installation differs from canonical source" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "sync-dev-extensions: canonical source matches .pi installation"
|
||||
COMMITTED=true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -d "$DEST" ]; then
|
||||
CURRENT="$WORK/current.sha256"
|
||||
manifest_tree "$DEST" "$CURRENT" || exit 1
|
||||
if [ -f "$RECORD" ]; then
|
||||
cmp -s "$RECORD" "$CURRENT" || { echo "sync-dev-extensions: refusing to overwrite local changes under .pi/extensions" >&2; exit 1; }
|
||||
else
|
||||
cmp -s "$EXPECTED_MANIFEST" "$CURRENT" || { echo "sync-dev-extensions: unrecorded .pi/extensions differs from canonical source" >&2; exit 1; }
|
||||
fi
|
||||
fi
|
||||
|
||||
[ ! -e "$BACKUP" ] || { echo "sync-dev-extensions: unreconciled backup remains" >&2; exit 2; }
|
||||
printf 'prepared\n' > "$TRANSACTION"
|
||||
if [ -e "$DEST" ]; then mv "$DEST" "$BACKUP"; fi
|
||||
mv "$EXPECTED" "$DEST"
|
||||
if [ "${MOSAIC_SYNC_TEST_FAULT:-}" = "after-destination" ]; then
|
||||
kill -KILL "$$"
|
||||
fi
|
||||
RECORD_TMP="$WORK/installed.sha256"
|
||||
cp "$EXPECTED_MANIFEST" "$RECORD_TMP"
|
||||
mv "$RECORD_TMP" "$RECORD"
|
||||
COMMITTED=true
|
||||
rm -rf "$BACKUP"
|
||||
rm -f "$TRANSACTION"
|
||||
echo "sync-dev-extensions: installed canonical goal extension into .pi/extensions"
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify canonical extension source and fail-closed native development packaging.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SANDBOX="$(mktemp -d)"
|
||||
trap 'rm -rf "$SANDBOX"' EXIT
|
||||
mkdir -p "$SANDBOX/repo/scripts" "$SANDBOX/repo/extensions"
|
||||
cp scripts/sync-dev-extensions.sh "$SANDBOX/repo/scripts/"
|
||||
cp -a extensions/goal extensions/mosaic-core "$SANDBOX/repo/extensions/"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
check_rc() {
|
||||
local name="$1" expected="$2"
|
||||
shift 2
|
||||
"$@" >/dev/null 2>&1
|
||||
local rc=$?
|
||||
if [ "$rc" -eq "$expected" ]; then PASS=$((PASS+1)); printf 'OK %s\n' "$name"
|
||||
else FAIL=$((FAIL+1)); printf 'FAIL %s (exit %s, expected %s)\n' "$name" "$rc" "$expected"
|
||||
fi
|
||||
}
|
||||
|
||||
SYNC="$SANDBOX/repo/scripts/sync-dev-extensions.sh"
|
||||
check_rc "initial ordinary-file install" 0 bash "$SYNC"
|
||||
check_rc "installed tree matches canonical source" 0 bash "$SYNC" --check
|
||||
if [ -z "$(find "$SANDBOX/repo/.pi/extensions" -type l -print -quit)" ]; then
|
||||
PASS=$((PASS+1)); echo "OK installed tree has no symlinks"
|
||||
else FAIL=$((FAIL+1)); echo "FAIL installed tree contains symlinks"
|
||||
fi
|
||||
printf '\nlocal edit\n' >> "$SANDBOX/repo/.pi/extensions/goal/README.md"
|
||||
check_rc "check detects installation drift" 1 bash "$SYNC" --check
|
||||
check_rc "sync refuses to overwrite installation drift" 1 bash "$SYNC"
|
||||
cp "$SANDBOX/repo/extensions/goal/README.md" "$SANDBOX/repo/.pi/extensions/goal/README.md"
|
||||
printf '\nextra\n' > "$SANDBOX/repo/.pi/extensions/extra-file"
|
||||
check_rc "check detects an extra destination file" 1 bash "$SYNC" --check
|
||||
rm "$SANDBOX/repo/.pi/extensions/extra-file"
|
||||
mkdir "$SANDBOX/repo/.pi/extensions/extra-directory"
|
||||
check_rc "check detects an extra destination directory" 1 bash "$SYNC" --check
|
||||
rmdir "$SANDBOX/repo/.pi/extensions/extra-directory"
|
||||
ln -s goal "$SANDBOX/repo/.pi/extensions/extra-link"
|
||||
check_rc "check rejects a destination symlink" 1 bash "$SYNC" --check
|
||||
rm "$SANDBOX/repo/.pi/extensions/extra-link"
|
||||
printf '\ncanonical edit\n' >> "$SANDBOX/repo/extensions/goal/README.md"
|
||||
check_rc "sync accepts a canonical source update" 0 bash "$SYNC"
|
||||
check_rc "updated installation matches canonical source" 0 bash "$SYNC" --check
|
||||
printf '\ninterrupted edit\n' >> "$SANDBOX/repo/extensions/goal/README.md"
|
||||
check_rc "forced interruption kills the replacing process" 137 env MOSAIC_SYNC_TEST_FAULT=after-destination bash "$SYNC"
|
||||
check_rc "next invocation recovers old consistent installation" 1 bash "$SYNC" --check
|
||||
if ! grep -q 'interrupted edit' "$SANDBOX/repo/.pi/extensions/goal/README.md"; then
|
||||
PASS=$((PASS+1)); echo "OK interrupted replacement rolled back"
|
||||
else FAIL=$((FAIL+1)); echo "FAIL interrupted replacement remained active"
|
||||
fi
|
||||
check_rc "sync succeeds after interruption recovery" 0 bash "$SYNC"
|
||||
touch "$SANDBOX/repo/.pi/.extensions-sync.lock"
|
||||
check_rc "unlocked stale lock file does not block" 0 bash "$SYNC" --check
|
||||
exec 8>"$SANDBOX/repo/.pi/.extensions-sync.lock"
|
||||
flock -n 8
|
||||
check_rc "active lock refuses a concurrent sync" 3 bash "$SYNC" --check
|
||||
flock -u 8
|
||||
ln -s README.md "$SANDBOX/repo/extensions/goal/forbidden-link"
|
||||
check_rc "source symlink fails closed" 2 bash "$SYNC"
|
||||
rm "$SANDBOX/repo/extensions/goal/forbidden-link"
|
||||
mkdir "$SANDBOX/repo/extensions/goal/nested"
|
||||
cp "$SANDBOX/repo/extensions/goal/index.ts" "$SANDBOX/repo/extensions/goal/nested/index.ts"
|
||||
check_rc "nested second entrypoint fails closed" 2 bash "$SYNC"
|
||||
|
||||
printf '\nextension package selftest: %s passed, %s failed\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""No-model native Pi smoke: project discovery, footer, /goal and Alt+G."""
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import termios
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
|
||||
TEXT = "native recall " + "full acceptance criteria " * 16 + "END-OF-GOAL"
|
||||
|
||||
# A fresh clone has no ignored development installation. Exercise the same
|
||||
# fail-closed sync used by the launcher before testing Pi discovery.
|
||||
subprocess.run([str(ROOT / "scripts/sync-dev-extensions.sh")], cwd=ROOT, check=True)
|
||||
|
||||
|
||||
def receive(fd, pattern, timeout=12):
|
||||
data = b""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([fd], [], [], max(0, deadline - time.monotonic()))
|
||||
if not ready:
|
||||
break
|
||||
try:
|
||||
data += os.read(fd, 65536)
|
||||
except OSError:
|
||||
break
|
||||
clean = ANSI.sub("", data.decode(errors="replace"))
|
||||
if pattern in clean:
|
||||
return data
|
||||
raise AssertionError(f"missing {pattern!r}: {data[-4000:]!r}")
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="ng-native-pi-") as home:
|
||||
env = {**os.environ, "PI_CODING_AGENT_DIR": home, "PI_OFFLINE": "1", "TERM": "xterm-256color"}
|
||||
env.pop("MOSAIC_LAUNCH_INCARNATION", None)
|
||||
rpc = subprocess.Popen(["pi", "--approve", "--offline", "--mode", "rpc", "--no-session", "--no-tools"], cwd=ROOT, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
try:
|
||||
rpc.stdin.write('{"id":"commands","type":"get_commands"}\n')
|
||||
rpc.stdin.flush()
|
||||
deadline = time.monotonic() + 15
|
||||
while True:
|
||||
assert time.monotonic() < deadline, "RPC discovery timed out"
|
||||
response = json.loads(rpc.stdout.readline())
|
||||
if response.get("id") == "commands":
|
||||
break
|
||||
commands = [c for c in response["data"]["commands"] if c["name"] == "goal"]
|
||||
assert len(commands) == 1, commands
|
||||
assert str(ROOT / ".pi/extensions/goal/index.ts") in json.dumps(commands[0]), commands
|
||||
print("PASS native project discovery: exactly one local /goal", flush=True)
|
||||
finally:
|
||||
rpc.terminate()
|
||||
rpc.communicate(timeout=10)
|
||||
|
||||
for status, label, width, no_color in [("paused", "Paused", 120, False), ("blocked", "Blocked", 45, False), ("none", "Complete", 120, True), ("active", "Waiting", 45, False)]:
|
||||
incarnation = "ng-native-test-" + uuid.uuid4().hex
|
||||
statefile = ROOT / ".pi/state/goal" / f"goal-state.{incarnation}.json"
|
||||
statefile.parent.mkdir(parents=True, exist_ok=True)
|
||||
state = dict(version=1, text=TEXT, status=status, checks=0, maxChecks=25, noProgressReports=0, maxNoProgressReports=3, workEventSinceReport=False, setAt="2026-09-06T00:00:00.000Z")
|
||||
if status == "none":
|
||||
state.update(text="", lastOutcome=dict(text=TEXT, status="complete", evidence="native fixture", at="2026-09-06T00:00:00.000Z"))
|
||||
if status == "active":
|
||||
state.update(waitTimeoutSeconds=3600, waitWakeUsed=False, activeWait=dict(owner="native fixture", nextCheck="manual acceptance", deadlineAt=int(time.time() * 1000) + 3600000, wakeSent=False))
|
||||
statefile.write_text(json.dumps(state))
|
||||
tty_env = {**env, "MOSAIC_LAUNCH_INCARNATION": incarnation}
|
||||
tty_env.pop("NO_COLOR", None)
|
||||
if no_color:
|
||||
tty_env["NO_COLOR"] = "1"
|
||||
master, slave = pty.openpty()
|
||||
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, width, 0, 0))
|
||||
child = subprocess.Popen(["pi", "--approve", "--offline", "--no-session", "--no-tools", "--no-skills", "--no-context-files"], cwd=ROOT, env=tty_env, stdin=slave, stdout=slave, stderr=slave, start_new_session=True)
|
||||
os.close(slave)
|
||||
try:
|
||||
startup = receive(master, f"Goal: {label}")
|
||||
Path("/tmp/ng-goal-native-startup.log").write_bytes(startup)
|
||||
os.write(master, b"\x1b[1;1R\x1b[?1;2c\x1b[?0u")
|
||||
# Let terminal capability negotiation and editor initialization settle.
|
||||
while select.select([master], [], [], 0.5)[0]:
|
||||
os.read(master, 65536)
|
||||
os.write(master, b"\x1b[103;3u")
|
||||
receive(master, "END-OF-GOAL")
|
||||
os.write(master, b"\x1b[200~/goal --max 0\x1b[201~")
|
||||
receive(master, "/goal --max 0")
|
||||
os.write(master, b"\x1b[13u")
|
||||
receive(master, 'integer, got "0"')
|
||||
os.write(master, b"\x1b[200~/goal\x1b[201~")
|
||||
entered = receive(master, "/goal")
|
||||
Path("/tmp/ng-goal-native-entered.log").write_bytes(entered)
|
||||
os.write(master, b"\x1b[13u")
|
||||
receive(master, "END-OF-GOAL")
|
||||
print(f"PASS native {label}, width={width}, NO_COLOR={no_color}: footer + /goal + Alt+G", flush=True)
|
||||
finally:
|
||||
os.killpg(child.pid, signal.SIGTERM)
|
||||
child.wait(timeout=10)
|
||||
os.close(master)
|
||||
statefile.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"taskVersion": 1,
|
||||
"id": "t-ng-goal-footer-implementation",
|
||||
"prompt": "Implement issue #54 in an isolated exact copy of the Mosaic Stack repository. Read docs/plans/2026-09-06_ng-goal-footer-dev.md before changing files. Scope is limited to extensions/goal, its required extensions/mosaic-core/lib support, and focused tests; .pi/extensions is generated only through scripts/sync-dev-extensions.sh. Do not read, modify, symlink, reload, or write through to ~/.mosaic. Do not change Docker, release, credentials, repository policy, CURRENT.md, BUILD-LOG.md, or docs/SESSIONS.md. Preserve the copied mosaic-core dependency unless a demonstrated compile defect requires a separately reported change. Requirements: remove the above-editor goal widget; compose with Pi's default footer using setStatus; render textual Active, Waiting, Paused, Blocked, Complete, and optional None states with the plan's semantic theme roles; keep meaning independent of color; make bare /goal return the complete goal text without truncation; register a non-conflicting direct recall shortcut, initially Alt+G, using the same full formatter; preserve a bounded terminal outcome so completed goal text remains recallable without restarting the loop; distinguish blocked from ordinary pause; preserve version-1 state loading and all continuation, report, wait, abort, persistence, and incarnation-fencing behavior. Add or update focused unit/runtime tests. Self-check with the extension test suite, TypeScript checking available in the exact workspace, searches proving no goal setWidget call remains, searches proving no absolute ~/.mosaic dependency, and git diff --check. Return a concise change summary, exact commands and results, known limitations, and the complete diff. Do not commit or push.",
|
||||
"workspace": "stack-repo",
|
||||
"capabilities": {
|
||||
"tools": [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"grep",
|
||||
"find",
|
||||
"ls"
|
||||
]
|
||||
},
|
||||
"session": "worker-ng-goal-footer-author",
|
||||
"timeoutSeconds": 600
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"taskVersion": 1,
|
||||
"id": "t-ng-goal-footer-review",
|
||||
"prompt": "Independently review the exact issue #54 candidate supplied by the conductor. Read docs/plans/2026-09-06_ng-goal-footer-dev.md and inspect only canonical source under extensions/ plus its focused tests and verify the generated .pi/extensions installation through scripts/sync-dev-extensions.sh. You are not the author. Do not edit files, commit, push, read credentials, or inspect ~/.mosaic. Verify that the candidate has no symlinks or paths resolving into ~/.mosaic; removes the above-editor goal widget; composes with Pi's built-in footer; uses textual status in addition to semantic color; distinguishes active, waiting, paused, blocked, complete, and no-goal handling; returns the entire goal text from bare /goal and the shortcut; does not reactivate completed work; safely loads prior version-1 state; and preserves continuation, bounded wait, abort, persistence, and incarnation fencing. Run focused tests and inspect hostile or malformed state cases. Check shortcut conflicts against the pinned Pi keybindings. Return APPROVE or REQUEST CHANGES, findings ordered by severity with file and line references, exact commands and results, and the reviewed candidate hash. Missing native TUI evidence must be labeled not verified rather than passed.",
|
||||
"workspace": "stack-repo",
|
||||
"capabilities": {
|
||||
"tools": [
|
||||
"read",
|
||||
"bash",
|
||||
"grep",
|
||||
"find",
|
||||
"ls"
|
||||
]
|
||||
},
|
||||
"session": "worker-ng-goal-footer-review",
|
||||
"timeoutSeconds": 600
|
||||
}
|
||||
Reference in New Issue
Block a user