Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ storage/stele/*.db
storage/stele/*.db-shm
storage/stele/*.db-wal
storage/stele/artifacts/*
storage/stele/archive/
!storage/stele/.gitkeep
!storage/stele/artifacts/.gitkeep
storage/backups/
60 changes: 38 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,43 @@ missing, and promotes validated skills back into the shared skill catalog.
One MCP service, registered once with ContextForge.

> **Repo status:** this is the reference implementation for a private
> deployment. It is **not yet clone-and-run** for outside operators — the
> promotion substrate (`stele`) resolves from a local path and the MCP server
> imports it at startup. See [Stele substrate](#stele-substrate) and
> deployment. The MCP server imports the promotion substrate (`stele`) at
> startup; see [Stele substrate](#stele-substrate) and
> [Local deployment](#local-deployment-deep-sixed) before attempting to run it.

## Stele substrate

The promotion step commits validated `SKILL.md` artifacts to a **Stele
ledger** — the real EVECOR Stele `LedgerStore`, driven **in-process as a
library dependency**, not a vendored copy and not a separate service.
The promotion step records validated `SKILL.md` artifacts in a **Stele
ledger** — Stele's `LedgerStore` bound to its evidence archive (`BlobStore`),
driven **in-process as a library dependency**, not a vendored copy and not a
separate service. Once sealed, the skill is installed into the approved
catalog (`storage/skills/approved/<dir_name>/SKILL.md`) and the Skill Server
index is reloaded, so the next lookup for the task finds it.

- `EvecorStelePort` (`src/agentsync/kanon/evecor_stele_port.py`) implements the
`StelePromotionPort` seam against `stele.ledger.store.LedgerStore`.
- It bypasses Stele's `ledger_transaction(store, SandboxResult)` write path —
a promoted `SKILL.md` is a pre-trusted blob with no sandbox run — and calls
the store primitives directly: `create_pending(...)` then `commit(...)`.
- The `storage/stele/ledger.db` schema (`artifact_records`) and the artifact
files under `storage/stele/artifacts/` are owned by Stele, not by AgentSync.
the store primitives directly: `create_pending(parser=agentsync-kanon, ...)`
then `seal(...)`. A sealed record is reported as promotion state `committed`.
- The obligation's `run_id` is the Stele run id: one ledger record per
obligation, found with `store.get_by_run_id(run_id)`.
- The `storage/stele/ledger.db` schema (`artifact_records`), the evidence
archive under `storage/stele/archive/`, and the artifact files under
`storage/stele/artifacts/` are owned by Stele, not by AgentSync.
- Stele is an **optional dependency** (`[stele]` extra). Tests run without it
via `FakeStelePromotionPort`; the live MCP server requires it (it imports
`stele.ledger.store` at module load and fails loud if absent).

Stele is currently sourced from a local path (`pyproject.toml`):
Stele is pinned to the commit whose ledger API the port targets
(`pyproject.toml`):

```toml
stele = ["stele @ file:///mnt/jarvis-data/projects/Stele"]
stele = ["stele @ git+https://github.com/Deep-Sixed/Stele@10bbf3f24a4cd4b15fc501c57ce58bb2fcf7b54a"]
```

Outside operators must repoint this at their own Stele checkout, index, or
wheel before `--extra stele` or the MCP server will work.
Moving the pin means re-running `tests/test_evecor_stele_port.py` against the
new Stele.

## Setup

Expand Down Expand Up @@ -99,8 +106,9 @@ systemctl --user status agentsync-mcp.service
|------|-------|---------|
| `storage/obligations/obligations.jsonl` | AgentSync | Authoritative obligation lifecycle log |
| `storage/obligations/pre_obligations.jsonl` | AgentSync | Non-authoritative rule evaluation records |
| `storage/skills/approved/` | AgentSync | Approved SKILL.md registry |
| `storage/skills/approved/` | AgentSync | Approved SKILL.md registry (Kanon installs promoted skills here) |
| `storage/stele/ledger.db` | Stele | Stele `LedgerStore` artifact ledger (`artifact_records`) |
| `storage/stele/archive/` | Stele | Stele evidence archive (`BlobStore`) the ledger points into |
| `storage/stele/artifacts/` | Stele | Committed SKILL.md artifact files |

All runtime paths overridable via `AGENTSYNC_*` env vars (see
Expand All @@ -118,7 +126,8 @@ git-ignored — the tree ships empty dirs (`.gitkeep`) plus the `rules.yaml` /
./scripts/restore-storage.sh storage/backups/agentsync-YYYYMMDD-HHMMSS.tar.gz
```

Backups include obligations, the Stele ledger, artifacts, and approved skills.
Backups include obligations, the Stele ledger and its evidence archive,
artifacts, and approved skills.

## ContextForge registration

Expand All @@ -139,14 +148,20 @@ uv run pytest tests/test_operational_hardening.py -v
uv run pytest tests/test_agent_facing_a2a_smoke.py -v # requires ContextForge
```

The `test_agent_facing_a2a_smoke.py` and `test_contextforge_smoke.py` suites
require a running ContextForge and a valid `ROUTERCORE_MCP_BEARER_TOKEN`; the
rest run standalone.
The suite runs on a clean clone. Tests read seed skills from the committed
`tests/fixtures/approved/` tree, never from the git-ignored
`storage/skills/approved/`, and write only to pytest's `tmp_path`.

- Without the `[stele]` extra, the Stele port, local-service and MCP-service
tests skip ("needs the [stele] extra"); with it, they run.
- The `test_agent_facing_a2a_smoke.py` and `test_contextforge_smoke.py` suites
skip unless ContextForge is running and a valid
`ROUTERCORE_MCP_BEARER_TOKEN` is available.

## Frozen checkpoints

Current validated gate: **`v1.1-kanon-operational-hardening` — 98/98**
(HEAD is this tag plus docs-only commits).
Last tagged gate: **`v1.1-kanon-operational-hardening` — 98/98**, on the
reference machine with ContextForge running.

| Tag | Gate | Note |
|-----|------|------|
Expand All @@ -161,8 +176,9 @@ Concrete paths for the reference deployment on JARVIS:
# project root
cd /mnt/jarvis-data/projects/AgentSync

# Stele source (pyproject [stele] extra)
# stele @ file:///mnt/jarvis-data/projects/Stele
# Stele source: the [stele] extra pins a Deep-Sixed/Stele commit; to run
# against the local checkout instead:
# uv pip install -e /mnt/jarvis-data/projects/Stele

# mint the ContextForge agent bearer
ROUTERCORE_MCP_BEARER_TOKEN="$(/mnt/jarvis-data/projects/EVECOR/bin/evecor-routercore-mcp-bearer)"
Expand Down
1 change: 1 addition & 0 deletions ops/agentsync.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ AGENTSYNC_PRE_OBLIGATIONS_PATH=/mnt/jarvis-data/projects/AgentSync/storage/oblig
AGENTSYNC_APPROVED_ROOT=/mnt/jarvis-data/projects/AgentSync/storage/skills/approved
AGENTSYNC_OBLIGATIONS_PATH=/mnt/jarvis-data/projects/AgentSync/storage/obligations/obligations.jsonl
AGENTSYNC_STELE_DB_PATH=/mnt/jarvis-data/projects/AgentSync/storage/stele/ledger.db
AGENTSYNC_STELE_ARCHIVE_ROOT=/mnt/jarvis-data/projects/AgentSync/storage/stele/archive
AGENTSYNC_ARTIFACTS_BASE=/mnt/jarvis-data/projects/AgentSync/storage/stele/artifacts

# ContextForge agent auth (for smoke tests / MCP clients — not read by AgentSync server)
Expand Down
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ dependencies = [
]

[project.optional-dependencies]
mcp = ["mcp[cli]>=1.0"] # FastMCP transport — required to run agentsync.mcp.server
dev = ["pytest>=8.0", "mcp[cli]>=1.0", "anyio>=4.0"]
stele = ["stele @ file:///mnt/jarvis-data/projects/Stele"]
mcp = ["mcp[cli]>=1.0,<2"] # FastMCP transport — required to run agentsync.mcp.server; mcp 2 renamed FastMCP
dev = ["pytest>=8.0", "mcp[cli]>=1.0,<2", "anyio>=4.0"]
# Pinned: EvecorStelePort targets this ledger API (create_pending(parser=...) -> seal).
stele = ["stele @ git+https://github.com/Deep-Sixed/Stele@10bbf3f24a4cd4b15fc501c57ce58bb2fcf7b54a"]

[build-system]
requires = ["hatchling"]
Expand Down
5 changes: 4 additions & 1 deletion scripts/backup-storage.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Backup AgentSync mutable state: obligations, Stele ledger, artifacts.
# Backup AgentSync mutable state: obligations, Stele ledger + evidence archive,
# artifacts, approved skills.
#
# Usage:
# ./scripts/backup-storage.sh [output-dir]
Expand Down Expand Up @@ -30,6 +31,8 @@ copy_if_exists() {
copy_if_exists "$STORAGE/obligations" "$TMP/obligations"
copy_if_exists "$STORAGE/stele/ledger.db" "$TMP/stele/ledger.db"
copy_if_exists "$STORAGE/stele/artifacts" "$TMP/stele/artifacts"
# The ledger's records point into the archive; back them up together.
copy_if_exists "$STORAGE/stele/archive" "$TMP/stele/archive"
copy_if_exists "$STORAGE/skills/approved" "$TMP/skills/approved"

tar -czf "$ARCHIVE" -C "$TMP" .
Expand Down
3 changes: 2 additions & 1 deletion scripts/bootstrap-storage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ STORAGE="${AGENTSYNC_STORAGE:-$ROOT/storage}"
mkdir -p \
"$STORAGE/obligations" \
"$STORAGE/skills/approved" \
"$STORAGE/stele/artifacts"
"$STORAGE/stele/artifacts" \
"$STORAGE/stele/archive"

exit 0
1 change: 1 addition & 0 deletions scripts/restore-storage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ if [[ -d "$TMP/stele" ]]; then
mkdir -p "$STORAGE/stele"
[[ -f "$TMP/stele/ledger.db" ]] && cp -a "$TMP/stele/ledger.db" "$STORAGE/stele/ledger.db" && echo "restored: $STORAGE/stele/ledger.db"
[[ -d "$TMP/stele/artifacts" ]] && rm -rf "$STORAGE/stele/artifacts" && cp -a "$TMP/stele/artifacts" "$STORAGE/stele/artifacts" && echo "restored: $STORAGE/stele/artifacts"
[[ -d "$TMP/stele/archive" ]] && rm -rf "$STORAGE/stele/archive" && cp -a "$TMP/stele/archive" "$STORAGE/stele/archive" && echo "restored: $STORAGE/stele/archive"
fi
restore_tree "skills/approved"

Expand Down
20 changes: 14 additions & 6 deletions src/agentsync/enforcer/enforcer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from .lookup import InProcessSkillLookup, SkillLookup
from .models import EnforcementResult, ObligationStatus, SkillObligation
from .store import append_obligation, find_open_for_task, get_obligation
from .store import append_obligation, find_open_for_task, find_redeemed_for_task, get_obligation


def _now() -> str:
Expand Down Expand Up @@ -79,12 +79,16 @@ def enforce(self, evaluation: RuleEvaluation, task_description: str = "") -> Enf

# Confirm the gap. The Skill Server's matcher is conservative, so a
# 'missing' here is trustworthy. Feed it the real task text — without
# the description the matcher can't find coverage.
# the description the matcher can't find coverage. If this task already
# redeemed an obligation, the skill it produced is looked up by id: the
# task is covered while that skill is still approved.
query_text = (task_description or "").strip() or task_id
redeemed = find_redeemed_for_task(task_id, self._path)
result = self._lookup.find_matching_skill(SkillQuery(
text=query_text,
skill_family=evaluation.skill_family,
capability_family=evaluation.capability_family,
canonical_id=redeemed.redeemed_skill_id if redeemed else None,
))

if result.status == "found":
Expand Down Expand Up @@ -134,18 +138,22 @@ def submit_candidate(self, token: str, candidate_path: str) -> SkillObligation:
})
return append_obligation(updated, self._path)

def redeem(self, token: str, artifact_hash: str) -> SkillObligation:
def redeem(
self, token: str, artifact_hash: str, skill_id: str | None = None,
) -> SkillObligation:
"""Kanon promoted the candidate through Stele. SUBMITTED -> REDEEMED.

artifact_hash is Stele's committed ArtifactRecord.artifact_hash — the
proof that promotion actually happened through the substrate. Closure
is unblocked here.
artifact_hash is Stele's sealed ArtifactRecord.artifact_hash — the
proof that promotion actually happened through the substrate. skill_id
is the approved canonical_id Kanon installed, if any. Closure is
unblocked here.
"""
obl = self._require_status(token, {ObligationStatus.SUBMITTED})
updated = obl.model_copy(update={
"status": ObligationStatus.REDEEMED,
"closure_blocked": False,
"redeemed_artifact_hash": artifact_hash,
"redeemed_skill_id": skill_id,
"updated_at": _now(),
"note": "redeemed after Stele commit",
})
Expand Down
1 change: 1 addition & 0 deletions src/agentsync/enforcer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class SkillObligation(BaseModel):
updated_at: str
candidate_path: str | None = None # set on SUBMITTED
redeemed_artifact_hash: str | None = None # set on REDEEMED (from Stele)
redeemed_skill_id: str | None = None # set on REDEEMED: approved canonical_id
note: str | None = None


Expand Down
15 changes: 15 additions & 0 deletions src/agentsync/enforcer/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,18 @@ def find_open_for_task(task_id: str, path: Path | None = None) -> SkillObligatio
if obl.task_id == task_id and obl.status.value in ("open", "submitted"):
return obl
return None


def find_redeemed_for_task(task_id: str, path: Path | None = None) -> SkillObligation | None:
"""Return the most recently redeemed obligation for a task, if any.

A redeemed obligation names the approved skill it produced; the Enforcer
uses it so a task whose skill was already authored is not re-obligated.
"""
latest: SkillObligation | None = None
for obl in current_state(path).values():
if obl.task_id != task_id or obl.status.value != "redeemed":
continue
if latest is None or obl.updated_at > latest.updated_at:
latest = obl
return latest
Loading