Skip to content
Draft
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
5 changes: 2 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ name: Test

on:
push:
branches: [master, main]
branches: [master, main, "agent/**"]
pull_request:
branches: [master, main]

jobs:
test:
Expand All @@ -26,7 +25,7 @@ jobs:
run: go test ./...

- name: Build
run: go build ./cmd/scud/
run: go build ./cmd/scud/ ./cmd/scudv2/

- name: Vet
run: go vet ./...
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ callers continue to use the legacy Rho CLI adapter by default; embedders can opt
into the versioned `rho.run/v1` JSONL adapter. See
[`pkg/executor`](pkg/executor/README.md) for the protocol integration boundary.

The opt-in v2 core is a smaller embeddable harness: deterministic reducer and
reconciler, DAG bridge, bounded runtime, and replaceable policy, execution, and
event-store adapters. `v2/adapters/tdhttp` persists its canonical event stream
in td under the ticket's live attempt/execution fence and obtains
Shen-authorized BIP340 execution witnesses. `v2/adapters/rho` executes the same
provider-neutral request through Rho, which supports Anthropic, OpenAI, and
xAI. SCUD owns goal/DAG orchestration, td owns identity, leases, policy and
durability, and Rho owns bounded provider/tool execution.

To try the protocol adapter without changing project configuration:

```sh
Expand Down
140 changes: 140 additions & 0 deletions cmd/scudv2/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// scudv2 is an opt-in, provider-blind view of the v2 core. It intentionally
// only reads a JSON goal/event document; execution and persistence remain
// adapter responsibilities.
package main

import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"

"github.com/reuben/scud/v2/core"
)

type document struct {
Goal *core.Goal `json:"goal,omitempty"`
Budget core.Budget `json:"budget,omitempty"`
Events []core.Event `json:"events"`
}

func main() {
if err := run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "scudv2:", err)
os.Exit(1)
}
}

func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
return runWithStdin(ctx, args, os.Stdin, stdout, stderr)
}

func runWithStdin(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
if len(args) == 0 {
return errors.New("usage: scudv2 <validate|plan|replay> <document.json>")
}
command := args[0]
if command != "validate" && command != "plan" && command != "replay" {
return fmt.Errorf("unknown command %q (want validate, plan, or replay)", command)
}
fs := flag.NewFlagSet(command, flag.ContinueOnError)
fs.SetOutput(stderr)
if err := fs.Parse(args[1:]); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("usage: scudv2 %s <document.json>", command)
}
if err := ctx.Err(); err != nil {
return err
}
doc, err := readDocument(fs.Arg(0), stdin)
if err != nil {
return err
}
state, err := replay(doc)
if err != nil {
return err
}
if err := state.Validate(); err != nil {
return fmt.Errorf("validate document: %w", err)
}

var output any
switch command {
case "validate":
output = map[string]any{"valid": true, "revision": state.Revision}
case "replay":
output = state
case "plan":
decision, err := core.Reconcile(state)
if err != nil {
return fmt.Errorf("plan: %w", err)
}
output = decision
}
encoded, err := json.MarshalIndent(output, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintf(stdout, "%s\n", encoded)
return err
}

func readDocument(path string, stdin io.Reader) (document, error) {
var data []byte
var err error
if path == "-" {
data, err = io.ReadAll(stdin)
} else {
data, err = os.ReadFile(path)
}
if err != nil {
return document{}, fmt.Errorf("read document: %w", err)
}
trimmed := strings.TrimSpace(string(data))
if trimmed == "" {
return document{}, errors.New("document is empty")
}
if strings.HasPrefix(trimmed, "[") {
var events []core.Event
if err := json.Unmarshal(data, &events); err != nil {
return document{}, fmt.Errorf("decode events: %w", err)
}
return document{Events: events}, nil
}
var doc document
if err := json.Unmarshal(data, &doc); err != nil {
return document{}, fmt.Errorf("decode document: %w", err)
}
return doc, nil
}

func replay(doc document) (core.State, error) {
events := append([]core.Event(nil), doc.Events...)
if doc.Goal != nil {
if len(events) == 0 || events[0].Kind != core.EventGoalCreated {
events = append([]core.Event{{ID: core.ID("goal-" + string(doc.Goal.ID)), Kind: core.EventGoalCreated, Goal: *doc.Goal}}, events...)
}
}
if doc.Budget.MaxSteps == 0 && doc.Budget.MaxCost == 0 {
return core.Replay(events)
}
state := core.NewStateWithBudget(doc.Budget)
for _, event := range events {
var err error
if event.Sequence == 0 {
state, err = state.Append(event)
} else {
state, err = core.Reduce(state, event)
}
if err != nil {
return core.State{}, err
}
}
return state, nil
}
77 changes: 77 additions & 0 deletions cmd/scudv2/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package main

import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

func fixture(t *testing.T) string {
t.Helper()
doc := `{"events":[{"id":"goal-event","kind":"goal_created","goal":{"id":"g","title":"demo"}},{"id":"obligation-event","kind":"obligation_added","obligation":{"id":"a","goal_id":"g","description":"first"}}]}`
path := filepath.Join(t.TempDir(), "goal.json")
if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
t.Fatal(err)
}
return path
}

func TestValidateAndPlan(t *testing.T) {
path := fixture(t)
var out, errOut bytes.Buffer
if err := run(context.Background(), []string{"validate", path}, &out, &errOut); err != nil {
t.Fatalf("validate: %v (%s)", err, errOut.String())
}
var summary map[string]any
if err := json.Unmarshal(out.Bytes(), &summary); err != nil || summary["valid"] != true {
t.Fatalf("unexpected validate output: %s", out.String())
}
out.Reset()
if err := run(context.Background(), []string{"plan", path}, &out, &errOut); err != nil {
t.Fatalf("plan: %v", err)
}
if !strings.Contains(out.String(), `"kind": "execute"`) || !strings.Contains(out.String(), `"obligation_id": "a"`) {
t.Fatalf("unexpected plan output: %s", out.String())
}
}

func TestReplayFromStdinAndInvalidDocument(t *testing.T) {
path := fixture(t)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
if err := runWithStdin(context.Background(), []string{"replay", "-"}, bytes.NewReader(data), &out, &errOut); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), `"revision": 2`) {
t.Fatalf("unexpected replay output: %s", out.String())
}
bad := filepath.Join(t.TempDir(), "bad.json")
if err := os.WriteFile(bad, []byte(`{"events":[{"kind":"obligation_added","obligation":{"id":"x","goal_id":"missing"}}]}`), 0o600); err != nil {
t.Fatal(err)
}
if err := run(context.Background(), []string{"validate", bad}, &out, &errOut); err == nil {
t.Fatal("expected invalid document error")
}
}

func TestGoalShorthandPrependsGoalEvent(t *testing.T) {
doc := `{"goal":{"id":"g","title":"shorthand"},"events":[{"kind":"obligation_added","obligation":{"id":"a","goal_id":"g"}}]}`
path := filepath.Join(t.TempDir(), "goal-shorthand.json")
if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
if err := run(context.Background(), []string{"validate", path}, &out, &errOut); err != nil {
t.Fatalf("validate shorthand: %v", err)
}
if !strings.Contains(out.String(), `"revision": 2`) {
t.Fatalf("expected synthesized goal event: %s", out.String())
}
}
127 changes: 127 additions & 0 deletions docs/scud-v2-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# SCUD v2 migration boundary

This document records the first migration seam for v2. It is deliberately
additive: the current CLI and graph implementation remain the default until a
consumer opts into the v2 packages.

## Shape

The v2 core owns task orchestration, graph decisions, and lifecycle state. It
does not import a model provider, `rho-cli`, Shen, SQLite, SCG, or the `td`
command. Those concerns are represented by interfaces in
`v2/adapters`:

| concern | v2 seam | concrete integration |
| --- | --- | --- |
| bounded agent run | `adapters.Runner` | `v2/adapters/rho` (`rho.run/v1`) |
| authorization | `adapters.Policy` | `v2/adapters/shen` |
| append-only progress | `adapters.EventStore` | application-owned store; `adapters/memory` for tests |
| task directory (td) | `adapters.TaskDirectory` | SCG/SQLite/remote adapter; `adapters/memory` for tests |

`RunRequest.Model` is an opaque string. A provider-aware adapter may interpret
`provider/model`; the scheduler must only pass it through. Event `Data` is
opaque bytes for the same reason. This keeps provider credentials and policy
syntax out of graph packages and makes contract tests deterministic.

## DAG projection and ownership

The v2 event stream is the source of truth for goal and obligation lifecycle.
`v2/core.State` is a replayable projection of that stream; it is not a second
database and must never be updated by mutating fields in place. `v2/graph`
derives a deterministic DAG view and next decision from a projected state. It
does not own persistence, dispatch, credentials, or policy decisions.

Ownership is intentionally one-way:

1. An adapter (or CLI import) appends a domain event.
2. `core.Reduce` validates and applies it, producing a new state.
3. `graph`/`core.Reconcile` reads that state and proposes the next decision.
4. An executor/policy adapter performs or rejects the proposal and emits the
resulting observation event.

Legacy SCG files and `td` databases are input/output projections during the
migration, never competing sources of truth. Their adapters must preserve
event order and IDs when importing, and must not silently write back status
changes outside the event append path. This lets v1 and v2 run side by side
while making ownership explicit.

## rho.run/v1 adapter

`v2/adapters/rho` is a thin translation layer over the existing
`pkg/executor` protocol implementation. It validates the adapter's model
syntax, translates requests and limits, and copies events before delivering
them to a v2 sink. The adapter is optional; callers can provide any
`adapters.Runner` implementation. Existing `pkg/executor.LegacyRho` remains
available for compatibility.

The JSONL fixtures under `v2/adapters/rho/testdata` are conformance examples:
`completed.jsonl` covers deltas and a terminal result, while
`invalid_after_terminal.jsonl` ensures consumers reject post-terminal events.
They can be replayed through `rho.Consume` without spawning a process.

## Shen policy adapter

`v2/adapters/shen` depends only on a local `Evaluator` interface, not a Shen SDK
version. A future SDK integration implements that interface and can be swapped
without changing v2 core. Policy failures are returned as errors; a successful
decision is explicit (`Allowed` plus a reason and optional constraints).

## Event and task stores

`EventStore.Append` is append-only and requires strictly increasing sequence
numbers per run. `List` accepts a cursor (`after`) so consumers can resume
without replaying the whole stream. `TaskDirectory` exposes only the task
projection needed by scheduling; richer legacy fields stay inside its adapter.
The in-memory implementations are test fixtures, not durable storage.

## Legacy package inventory

The following packages should be retained during the migration:

- `pkg/model`, `pkg/scg`, `pkg/wave`: stable graph/model APIs and SCG
compatibility. New v2 code should consume a `TaskDirectory` projection
rather than import these packages directly.
- `pkg/executor`: keep `Runner`, `LegacyRho`, and the rho.run/v1 protocol
validator while downstream callers move to `v2/adapters`.
- `internal/db`, `internal/storage`, `internal/config`: retain for the v1 CLI
and implement v2 adapters on top only after persistence semantics are tested.

The following should move behind adapters, then be deprecated once no v1
entrypoint imports them:

- `internal/rho`: legacy process invocation; use `v2/adapters/rho` for new
execution and leave this package as a compatibility shim.
- direct `internal/db` event/session calls: expose them through an
`EventStore` adapter so core code cannot depend on SQLite details.
- direct task-file reads/writes in command packages: expose them through a
`TaskDirectory` adapter.

No package is deleted by this scaffold. A later release can add deprecation
markers and an integrated `scud v2` alias once the graph implementation and
adapter backends are ready; `scudv2` remains the low-risk standalone entrypoint
during migration.

## Migration sequence

1. Add adapter-backed entrypoints and replay the fixtures in CI.
2. Implement durable EventStore and TaskDirectory adapters, preserving v1
locking and event ordering behavior.
3. Run v2 behind the explicit `scudv2` command/config switch; compare
projections and decisions with the v1 scheduler.
4. Migrate callers package-by-package, then deprecate direct legacy imports.

## Opt-in CLI

`cmd/scudv2` is intentionally standalone so the existing `scud` command and
its legacy state files are unaffected. It accepts either a JSON object with an
`events` array (and optional `goal`/`budget`) or a bare JSON event array:

```sh
scudv2 validate goal.json
scudv2 plan goal.json
scudv2 replay goal.json
```

`validate` replays and checks invariants, `plan` prints the deterministic next
decision, and `replay` prints the projected state. The command performs no
agent invocation, provider lookup, policy evaluation, or persistence write.
Loading
Loading