diff --git a/.gitignore b/.gitignore index d55fe33e..97db980f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ /flare /gate /org +/org-mcp /review /reviewfindings /runway diff --git a/AGENTS.md b/AGENTS.md index a837fef1..6bf2317c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,8 +39,10 @@ Orientation block you can point an agent at to ground it fast. hash-chained decision log; exit codes 0 pass / 1 blocked / 2 parked / 3 refused / 4 error are a load-bearing seam), `org` (the Baton home — role continuity chains over `contracts/org`: - attach/claim/yield lifecycle, the byte-capped `org boot` re-entry index, and - the SessionStart/Stop hook scripts that wire sessions to roles), + attach/claim/yield lifecycle, the byte-capped `org boot` re-entry index, + the SessionStart/Stop hook scripts that wire sessions to roles, and + operator context.d boot sources; `org-mcp` is its stdio MCP surface, + shelling the binary), plus `local`'s CLIs (`local`, `eval`). - `docs/DESIGN.md` — the repo charter. `FOLLOWUPS.md` — the lazy-migration queue and deferred decisions (the engineering debt this codebase owes). diff --git a/CLAUDE.md b/CLAUDE.md index 8683108c..b6c1fd50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,8 +45,10 @@ Orientation block you can point an agent at to ground it fast. agent→human→agent loop, shelling gate and never importing it; a contract+seam, not a plane — see `docs/features/escalation-plane/spec.md`), `org` (the Baton home — role continuity chains over `contracts/org`: - attach/claim/yield lifecycle, the byte-capped `org boot` re-entry index, and - the SessionStart/Stop hook scripts that wire sessions to roles), + attach/claim/yield lifecycle, the byte-capped `org boot` re-entry index, + the SessionStart/Stop hook scripts that wire sessions to roles, and + operator context.d boot sources; `org-mcp` is its stdio MCP surface, + shelling the binary), plus `local`'s CLIs (`local`, `eval`). - `docs/DESIGN.md` — the repo charter. `FOLLOWUPS.md` — the lazy-migration queue and deferred decisions (the engineering debt this codebase owes). diff --git a/cmd/org-mcp/AGENTS.md b/cmd/org-mcp/AGENTS.md new file mode 100644 index 00000000..65e57978 --- /dev/null +++ b/cmd/org-mcp/AGENTS.md @@ -0,0 +1,45 @@ +# org-mcp + +The MCP surface of the Baton home: a stdio JSON-RPC 2.0 server exposing the +org verbs as native agent tools (`org_boot`, `org_status`, `org_attach`, +`org_claim`, `org_yield`, `org_complete`, `org_abandon`, `org_assign`, +`org_unassign`, `org_intent`, `org_resolve`, `org_escalate`, `org_note`, +`org_checkpoint`, `org_release`, `org_verify`). + +A workbench tenant: the binary is `cmd/org-mcp`, guts private under +`cmd/org-mcp/internal/server`. It **shells the org binary** (`ORG_BIN`, +default `org`) and composes on its exit-code + JSON-receipt seam — it never +imports the home, owns no state, and makes no decision (charter boundary law; +same posture as console/escalate over gate). + +## Invariants + +- **The verb table IS the allowlist.** Role-structure verbs — charter, + takeover, revoke, retire, recharter, delegate — have no entry and cannot be + reached over MCP. Reshaping the org stays a deliberate operator act at the + CLI. +- **Refusals surface, never vanish.** org exit 1 maps to an `isError` tool + result `{code: "refused", reason: , detail: }` so + a driving agent can branch on `dangling_claim` exactly as a script branches + on the exit code. Exit ≥2 maps to `{code: "error", exit, detail}`. +- **One resolution.** State and tenant come from the server's own + environment (`ORG_STATE` / `ORG_TENANT`), inherited by every child org + process — the CLI and MCP surfaces cannot disagree about which home they + speak to. +- Verb handlers are pure translation (MCP arguments → CLI flags); the CLI's + flag parsing and the kernel's laws stay the single source of truth. + +## Register + +```json +{ "mcpServers": { "org": { "command": "org-mcp", + "env": { "ORG_STATE": "/Users/you/dev/org/state" } } } } +``` + +## Checks + +``` +gofmt -l ./cmd/org-mcp && go vet ./cmd/org-mcp/... +golangci-lint run ./cmd/org-mcp/... +go test ./cmd/org-mcp/... +``` diff --git a/cmd/org-mcp/CLAUDE.md b/cmd/org-mcp/CLAUDE.md new file mode 100644 index 00000000..65e57978 --- /dev/null +++ b/cmd/org-mcp/CLAUDE.md @@ -0,0 +1,45 @@ +# org-mcp + +The MCP surface of the Baton home: a stdio JSON-RPC 2.0 server exposing the +org verbs as native agent tools (`org_boot`, `org_status`, `org_attach`, +`org_claim`, `org_yield`, `org_complete`, `org_abandon`, `org_assign`, +`org_unassign`, `org_intent`, `org_resolve`, `org_escalate`, `org_note`, +`org_checkpoint`, `org_release`, `org_verify`). + +A workbench tenant: the binary is `cmd/org-mcp`, guts private under +`cmd/org-mcp/internal/server`. It **shells the org binary** (`ORG_BIN`, +default `org`) and composes on its exit-code + JSON-receipt seam — it never +imports the home, owns no state, and makes no decision (charter boundary law; +same posture as console/escalate over gate). + +## Invariants + +- **The verb table IS the allowlist.** Role-structure verbs — charter, + takeover, revoke, retire, recharter, delegate — have no entry and cannot be + reached over MCP. Reshaping the org stays a deliberate operator act at the + CLI. +- **Refusals surface, never vanish.** org exit 1 maps to an `isError` tool + result `{code: "refused", reason: , detail: }` so + a driving agent can branch on `dangling_claim` exactly as a script branches + on the exit code. Exit ≥2 maps to `{code: "error", exit, detail}`. +- **One resolution.** State and tenant come from the server's own + environment (`ORG_STATE` / `ORG_TENANT`), inherited by every child org + process — the CLI and MCP surfaces cannot disagree about which home they + speak to. +- Verb handlers are pure translation (MCP arguments → CLI flags); the CLI's + flag parsing and the kernel's laws stay the single source of truth. + +## Register + +```json +{ "mcpServers": { "org": { "command": "org-mcp", + "env": { "ORG_STATE": "/Users/you/dev/org/state" } } } } +``` + +## Checks + +``` +gofmt -l ./cmd/org-mcp && go vet ./cmd/org-mcp/... +golangci-lint run ./cmd/org-mcp/... +go test ./cmd/org-mcp/... +``` diff --git a/cmd/org-mcp/README.md b/cmd/org-mcp/README.md new file mode 100644 index 00000000..7d09bab1 --- /dev/null +++ b/cmd/org-mcp/README.md @@ -0,0 +1,8 @@ +# org-mcp + +MCP surface for the Baton home — the org verbs as native agent tools, over +stdio JSON-RPC. Shells the [`org`](../org) binary; owns no state; the verb +allowlist excludes every role-structure verb (charter/takeover/revoke/retire/ +delegate), so reshaping the org stays an operator act. + +See [CLAUDE.md](CLAUDE.md) for invariants and registration. diff --git a/cmd/org-mcp/internal/server/server.go b/cmd/org-mcp/internal/server/server.go new file mode 100644 index 00000000..fc71741b --- /dev/null +++ b/cmd/org-mcp/internal/server/server.go @@ -0,0 +1,231 @@ +// Package server is the org-mcp stdio server: a JSON-RPC 2.0 / MCP surface +// over the org CLI, so an agent session gets the Baton verbs as native tools. +// +// It composes through org's CLI seam — shelling the binary, reading its JSON +// receipts and its exit codes — and never imports the home. That is the +// workbench boundary law doing its job: this package is transport + verb +// allowlist, org owns the append protocol, contracts/org owns the law. A +// kernel refusal (exit 1) comes back as an isError tool result carrying the +// refusal reason, so the driving agent sees `dangling_claim` and corrects, +// exactly as it would on the command line. +// +// The verb table IS the allowlist. Role-structure verbs — charter, takeover, +// revoke, retire, recharter, delegate — have no entry, so they cannot be +// reached over MCP; reshaping the org stays a deliberate operator act. +package server + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os/exec" + "regexp" + "strconv" +) + +// maxLine bounds one framed JSON-RPC message. +const maxLine = 1 << 20 + +const protocolVersion = "2024-11-05" + +// Runner executes the org binary with args and returns its streams and exit +// code. It exists so tests swap the process boundary for a fake; the server +// has no other side effects. +type Runner func(ctx context.Context, args []string) (stdout, stderr []byte, code int, err error) + +// Shell returns the production Runner for one resolved org binary. +func Shell(bin string) Runner { + return func(ctx context.Context, args []string) ([]byte, []byte, int, error) { + var out, errBuf bytes.Buffer + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Stdout, cmd.Stderr = &out, &errBuf + err := cmd.Run() + if err == nil { + return out.Bytes(), errBuf.Bytes(), 0, nil + } + var exit *exec.ExitError + if errors.As(err, &exit) { + return out.Bytes(), errBuf.Bytes(), exit.ExitCode(), nil + } + return nil, nil, 0, fmt.Errorf("run %s: %w", bin, err) + } +} + +// Server dispatches MCP messages to org verbs through its Runner. +type Server struct { + run Runner +} + +// New returns a Server over the given Runner. +func New(run Runner) *Server { return &Server{run: run} } + +// Serve runs the read-dispatch-write loop over in/out until EOF. +func (s *Server) Serve(ctx context.Context, in io.Reader, out io.Writer) error { + scanner := bufio.NewScanner(in) + scanner.Buffer(make([]byte, 0, 64*1024), maxLine) + enc := json.NewEncoder(out) + for scanner.Scan() { + if ctx.Err() != nil { + return nil + } + resp, respond := s.handleMessage(ctx, scanner.Bytes()) + if !respond { + continue + } + if err := enc.Encode(resp); err != nil { + return fmt.Errorf("org-mcp: write response: %w", err) + } + } + return scanner.Err() +} + +const ( + codeParseError = -32700 + codeMethodNotFound = -32601 + codeInvalidParams = -32602 +) + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (s *Server) handleMessage(ctx context.Context, line []byte) (rpcResponse, bool) { + var req rpcRequest + if err := json.Unmarshal(line, &req); err != nil { + return errorResponse(json.RawMessage("null"), codeParseError, "parse error"), true + } + if len(req.ID) == 0 { + return rpcResponse{}, false + } + return s.dispatch(ctx, req), true +} + +func (s *Server) dispatch(ctx context.Context, req rpcRequest) rpcResponse { + switch req.Method { + case "initialize": + return okResponse(req.ID, map[string]any{ + "protocolVersion": protocolVersion, + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": "org-mcp", "version": "0.1.0"}, + }) + case "ping": + return okResponse(req.ID, struct{}{}) + case "tools/list": + return okResponse(req.ID, toolsListResult()) + case "tools/call": + return s.handleToolsCall(ctx, req) + default: + return errorResponse(req.ID, codeMethodNotFound, "method not found: "+req.Method) + } +} + +type toolCallParams struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +type toolResult struct { + Content []textContent `json:"content"` + IsError bool `json:"isError,omitempty"` +} + +type textContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +func (s *Server) handleToolsCall(ctx context.Context, req rpcRequest) rpcResponse { + var p toolCallParams + if err := json.Unmarshal(req.Params, &p); err != nil { + return errorResponse(req.ID, codeInvalidParams, "invalid tools/call params") + } + v, ok := lookupVerb(p.Name) + if !ok { + return errorResponse(req.ID, codeMethodNotFound, "unknown verb: "+p.Name) + } + args, err := v.args(p.Arguments) + if err != nil { + return okResponse(req.ID, errorResult(map[string]any{"code": "invalid_params", "detail": err.Error()})) + } + return okResponse(req.ID, s.invoke(ctx, args)) +} + +// invoke runs the org binary and maps its exit-code seam onto tool results: +// 0 → the receipt/JSON on stdout; 1 → isError with the kernel's refusal +// reason; anything else → isError with the error class and stderr. +func (s *Server) invoke(ctx context.Context, args []string) toolResult { + stdout, stderr, code, err := s.run(ctx, args) + if err != nil { + return errorResult(map[string]any{"code": "exec_failed", "detail": err.Error()}) + } + switch code { + case 0: + return textResult(string(stdout)) + case 1: + return errorResult(map[string]any{ + "code": "refused", "reason": refusalReason(stderr), "detail": string(bytes.TrimSpace(stderr)), + }) + default: + return errorResult(map[string]any{ + "code": "error", "exit": code, "detail": string(bytes.TrimSpace(stderr)), + }) + } +} + +// refusalPattern extracts the reason id from the kernel's refusal message +// ("org: dangling_claim at seq 11: …"); refusalPrefix is the fallback for a +// refusal-shaped line without the seq clause, so a future message-format +// tweak degrades to a coarser reason instead of an empty one. +var ( + refusalPattern = regexp.MustCompile(`\b([a-z_]+) at seq \d+`) + refusalPrefix = regexp.MustCompile(`^org: ([a-z_]+):`) +) + +func refusalReason(stderr []byte) string { + if m := refusalPattern.FindSubmatch(stderr); m != nil { + return string(m[1]) + } + if m := refusalPrefix.FindSubmatch(stderr); m != nil { + return string(m[1]) + } + return "" +} + +func textResult(text string) toolResult { + return toolResult{Content: []textContent{{Type: "text", Text: text}}} +} + +func errorResult(v any) toolResult { + data, _ := json.Marshal(v) + return toolResult{Content: []textContent{{Type: "text", Text: string(data)}}, IsError: true} +} + +func okResponse(id json.RawMessage, result any) rpcResponse { + return rpcResponse{JSONRPC: "2.0", ID: id, Result: result} +} + +func errorResponse(id json.RawMessage, code int, msg string) rpcResponse { + return rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}} +} + +// itoa is strconv.Itoa under a name that keeps call sites short in verbs.go. +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/cmd/org-mcp/internal/server/server_test.go b/cmd/org-mcp/internal/server/server_test.go new file mode 100644 index 00000000..593f2366 --- /dev/null +++ b/cmd/org-mcp/internal/server/server_test.go @@ -0,0 +1,152 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" +) + +// fake returns a Runner that records the args it was called with and replies +// with a canned process result. +func fake(t *testing.T, gotArgs *[]string, stdout, stderr string, code int) Runner { + t.Helper() + return func(_ context.Context, args []string) ([]byte, []byte, int, error) { + *gotArgs = args + return []byte(stdout), []byte(stderr), code, nil + } +} + +// call drives one tools/call through the full serve loop and returns the tool +// result. +func call(t *testing.T, run Runner, name string, args map[string]any) toolResult { + t.Helper() + rawArgs, _ := json.Marshal(args) + params, _ := json.Marshal(toolCallParams{Name: name, Arguments: rawArgs}) + req, _ := json.Marshal(rpcRequest{JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call", Params: params}) + + var out bytes.Buffer + if err := New(run).Serve(context.Background(), bytes.NewReader(append(req, '\n')), &out); err != nil { + t.Fatalf("serve: %v", err) + } + var resp struct { + Result toolResult `json:"result"` + } + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("decode response %q: %v", out.String(), err) + } + return resp.Result +} + +// TestClaimTranslatesToCLI proves the verb layer is pure translation: the MCP +// arguments become the CLI invocation, and the receipt comes back verbatim. +func TestClaimTranslatesToCLI(t *testing.T) { + var got []string + receipt := `{"kind":"claim","seq":5,"phase":"active"}` + res := call(t, fake(t, &got, receipt, "", 0), "org_claim", + map[string]any{"role": "lead:platform", "work": "github:acme/api#88", "incarnation": "sha256:abc"}) + + want := []string{"claim", "-role", "lead:platform", "-json", "-incarnation", "sha256:abc", "-work", "github:acme/api#88"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("cli args = %v, want %v", got, want) + } + if res.IsError || res.Content[0].Text != receipt { + t.Fatalf("result = %+v", res) + } +} + +// TestRefusalSurfacesReason proves the exit-code seam maps onto isError with +// the kernel's reason id extracted, so a driving agent can branch on it. +func TestRefusalSurfacesReason(t *testing.T) { + var got []string + stderr := "org: dangling_claim at seq 11: a predecessor's claim on github:acme/api#88 is unresolved; yield, complete or abandon it first" + res := call(t, fake(t, &got, "", stderr, 1), "org_claim", + map[string]any{"role": "lead:platform", "work": "github:acme/api#88"}) + + if !res.IsError { + t.Fatalf("refusal did not set isError: %+v", res) + } + var body struct { + Code string `json:"code"` + Reason string `json:"reason"` + } + if err := json.Unmarshal([]byte(res.Content[0].Text), &body); err != nil { + t.Fatalf("decode error body: %v", err) + } + if body.Code != "refused" || body.Reason != "dangling_claim" { + t.Fatalf("error body = %+v", body) + } +} + +// TestMissingArgumentIsToolError proves a missing required member never +// reaches the process boundary. +func TestMissingArgumentIsToolError(t *testing.T) { + ran := false + run := func(context.Context, []string) ([]byte, []byte, int, error) { + ran = true + return nil, nil, 0, nil + } + res := call(t, run, "org_claim", map[string]any{"role": "lead:platform"}) + if !res.IsError || ran { + t.Fatalf("missing work: isError=%v ran=%v", res.IsError, ran) + } + if !strings.Contains(res.Content[0].Text, "work is required") { + t.Fatalf("error body: %s", res.Content[0].Text) + } +} + +// TestAllowlistExcludesStructureVerbs pins the surface: the lifecycle and work +// verbs are present, and the org-reshaping verbs are unreachable. +func TestAllowlistExcludesStructureVerbs(t *testing.T) { + names := map[string]bool{} + for _, v := range verbs { + names[v.name] = true + } + for _, want := range []string{"org_boot", "org_status", "org_attach", "org_claim", "org_yield", "org_checkpoint"} { + if !names[want] { + t.Fatalf("surface lacks %s", want) + } + } + for _, banned := range []string{"org_charter", "org_takeover", "org_revoke", "org_retire", "org_recharter", "org_delegate"} { + if names[banned] { + t.Fatalf("%s must not be reachable over MCP", banned) + } + } + if _, ok := lookupVerb("org_charter"); ok { + t.Fatal("lookupVerb resolved a banned verb") + } +} + +// TestHandshakeAndUnknowns pins the protocol frame: initialize answers, +// notifications stay silent, unknown methods and verbs are MethodNotFound. +func TestHandshakeAndUnknowns(t *testing.T) { + lines := []string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize"}`, + `{"jsonrpc":"2.0","method":"notifications/initialized"}`, + `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`, + `{"jsonrpc":"2.0","id":3,"method":"no/such"}`, + `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"org_frobnicate","arguments":{}}}`, + } + var out bytes.Buffer + run := func(context.Context, []string) ([]byte, []byte, int, error) { return nil, nil, 0, nil } + err := New(run).Serve(context.Background(), strings.NewReader(strings.Join(lines, "\n")+"\n"), &out) + if err != nil { + t.Fatalf("serve: %v", err) + } + responses := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(responses) != 4 { + t.Fatalf("%d responses, want 4 (notification must be silent):\n%s", len(responses), out.String()) + } + if !strings.Contains(responses[0], `"org-mcp"`) { + t.Fatalf("initialize: %s", responses[0]) + } + if !strings.Contains(responses[1], "org_boot") { + t.Fatalf("tools/list lacks org_boot: %s", responses[1]) + } + for i, wantErr := range map[int]string{2: "method not found", 3: "unknown verb"} { + if !strings.Contains(responses[i], wantErr) { + t.Fatalf("response %d lacks %q: %s", i, wantErr, responses[i]) + } + } +} diff --git a/cmd/org-mcp/internal/server/verbs.go b/cmd/org-mcp/internal/server/verbs.go new file mode 100644 index 00000000..212e754d --- /dev/null +++ b/cmd/org-mcp/internal/server/verbs.go @@ -0,0 +1,314 @@ +package server + +import ( + "encoding/json" + "fmt" +) + +// verb is one MCP tool: its wire name, description, argument schema, and the +// translation from MCP arguments to org CLI arguments. Translation is all a +// verb does — the CLI's own flag parsing and the kernel's laws stay the single +// source of truth. +type verb struct { + name string + description string + schema json.RawMessage + args func(json.RawMessage) ([]string, error) +} + +// callArgs is the superset of arguments the org verbs accept over MCP. Which +// members a verb requires is enforced per-verb in its args func; everything +// else is ignored, so one decode covers every tool. +type callArgs struct { + Role string `json:"role"` + Work string `json:"work"` + Body string `json:"body"` + Pin string `json:"pin"` + Digest string `json:"digest"` + Effect string `json:"effect"` + Target string `json:"target"` + Incarnation string `json:"incarnation"` + NextDue string `json:"next_due"` + MaxBytes int `json:"max_bytes"` +} + +func decode(raw json.RawMessage) (callArgs, error) { + var a callArgs + if len(raw) == 0 { + return a, nil + } + if err := json.Unmarshal(raw, &a); err != nil { + return a, fmt.Errorf("decode arguments: %w", err) + } + return a, nil +} + +// base builds the shared CLI prefix: the verb, the chain, the identity, and a +// JSON receipt. State and tenant resolve from the server process environment +// (ORG_STATE / ORG_TENANT), which the child inherits. +func base(cliVerb string, a callArgs) []string { + args := []string{cliVerb, "-role", a.Role, "-json"} + if a.Incarnation != "" { + args = append(args, "-incarnation", a.Incarnation) + } + if a.NextDue != "" { + args = append(args, "-next-due", a.NextDue) + } + return args +} + +// need returns an error naming the first missing required member. +func need(pairs ...[2]string) error { + for _, p := range pairs { + if p[1] == "" { + return fmt.Errorf("%s is required", p[0]) + } + } + return nil +} + +// roleVerb covers the verbs whose only required argument is the role. +func roleVerb(cliVerb string) func(json.RawMessage) ([]string, error) { + return func(raw json.RawMessage) ([]string, error) { + a, err := decode(raw) + if err != nil { + return nil, err + } + if err := need([2]string{"role", a.Role}); err != nil { + return nil, err + } + args := base(cliVerb, a) + if a.Body != "" { + args = append(args, "-body", a.Body) + } + return args, nil + } +} + +// workVerb covers claim and its terminals: role + work, optional body. +func workVerb(cliVerb string) func(json.RawMessage) ([]string, error) { + return func(raw json.RawMessage) ([]string, error) { + a, err := decode(raw) + if err != nil { + return nil, err + } + if err := need([2]string{"role", a.Role}, [2]string{"work", a.Work}); err != nil { + return nil, err + } + args := append(base(cliVerb, a), "-work", a.Work) + if a.Body != "" { + args = append(args, "-body", a.Body) + } + return args, nil + } +} + +// bodyVerb covers the advisory verbs: role + body, both required. +func bodyVerb(cliVerb string) func(json.RawMessage) ([]string, error) { + return func(raw json.RawMessage) ([]string, error) { + a, err := decode(raw) + if err != nil { + return nil, err + } + if err := need([2]string{"role", a.Role}, [2]string{"body", a.Body}); err != nil { + return nil, err + } + return append(base(cliVerb, a), "-body", a.Body), nil + } +} + +const roleOnly = `{"type":"object","properties":{"role":{"type":"string"},"body":{"type":"string"},"incarnation":{"type":"string"},"next_due":{"type":"string"}},"required":["role"]}` +const roleWork = `{"type":"object","properties":{"role":{"type":"string"},"work":{"type":"string"},"body":{"type":"string"},"incarnation":{"type":"string"},"next_due":{"type":"string"}},"required":["role","work"]}` +const roleBody = `{"type":"object","properties":{"role":{"type":"string"},"body":{"type":"string"},"incarnation":{"type":"string"},"next_due":{"type":"string"}},"required":["role","body"]}` + +// verbs is the exposed surface, and the list IS the allowlist: charter, +// takeover, revoke, retire, recharter and delegate have no entry, so the org's +// structure cannot be reshaped over MCP. +var verbs = []verb{ + { + name: "org_boot", + description: "Render a role's re-entry index: charter, held work, obligations (a predecessor's dangling claim first), liveness, and the last incarnation's final word. Read this before acting as a role.", + schema: json.RawMessage(`{"type":"object","properties":{"role":{"type":"string"},"max_bytes":{"type":"integer"}},"required":["role"]}`), + args: func(raw json.RawMessage) ([]string, error) { + a, err := decode(raw) + if err != nil { + return nil, err + } + if err := need([2]string{"role", a.Role}); err != nil { + return nil, err + } + args := []string{"boot", "-role", a.Role} + if a.MaxBytes > 0 { + args = append(args, "-max-bytes", itoa(a.MaxBytes)) + } + return args, nil + }, + }, + { + name: "org_status", + description: "The org board: every role's phase, active work, held count, open obligations, and liveness.", + schema: json.RawMessage(`{"type":"object","properties":{}}`), + args: func(json.RawMessage) ([]string, error) { + return []string{"status", "-json"}, nil + }, + }, + { + name: "org_attach", + description: "Become a role's incarnation (refused while another holds it). Returns the incarnation id in the receipt's holder field; present it on later writes. Optionally declare next_due (e.g. \"4h\") — your own liveness deadline.", + schema: json.RawMessage(roleOnly), + args: roleVerb("attach"), + }, + { + name: "org_release", + description: "Hand the role back cleanly (no active claim may be open).", + schema: json.RawMessage(roleOnly), + args: roleVerb("release"), + }, + { + name: "org_assign", + description: "Add a work item to the role's held set. Requires work (a URI with a scheme, e.g. github:owner/repo#88 or dossier:proj/phase/task) and pin or digest to fix the item's content, so a rewritten ticket reads as drift.", + schema: json.RawMessage(`{"type":"object","properties":{"role":{"type":"string"},"work":{"type":"string"},"pin":{"type":"string"},"digest":{"type":"string"},"incarnation":{"type":"string"}},"required":["role","work"]}`), + args: func(raw json.RawMessage) ([]string, error) { + a, err := decode(raw) + if err != nil { + return nil, err + } + if err := need([2]string{"role", a.Role}, [2]string{"work", a.Work}); err != nil { + return nil, err + } + args := append(base("assign", a), "-work", a.Work) + if a.Digest != "" { + args = append(args, "-digest", a.Digest) + } + if a.Pin != "" { + args = append(args, "-pin", a.Pin) + } + return args, nil + }, + }, + { + name: "org_unassign", + description: "Drop a held work item (refused while it is the active claim).", + schema: json.RawMessage(roleWork), + args: workVerb("unassign"), + }, + { + name: "org_claim", + description: "Make one held work item the active claim. Refused if a predecessor's dangling claim or an open effect is unresolved — discharge those first.", + schema: json.RawMessage(roleWork), + args: workVerb("claim"), + }, + { + name: "org_yield", + description: "End the active (or dangling) claim with the work unfinished and still held. Body is where you stopped — the next incarnation reads it.", + schema: json.RawMessage(roleWork), + args: workVerb("yield"), + }, + { + name: "org_complete", + description: "End the active (or dangling) claim asserting the work is done; the item leaves the held set.", + schema: json.RawMessage(roleWork), + args: workVerb("complete"), + }, + { + name: "org_abandon", + description: "End the active (or dangling) claim and drop the work, explicitly and on the record.", + schema: json.RawMessage(roleWork), + args: workVerb("abandon"), + }, + { + name: "org_intent", + description: "Record that an effect (a merge, a deploy, a send) is about to be attempted against this role. One outstanding effect at a time; resolve it before claiming further.", + schema: json.RawMessage(`{"type":"object","properties":{"role":{"type":"string"},"effect":{"type":"string"},"incarnation":{"type":"string"}},"required":["role","effect"]}`), + args: func(raw json.RawMessage) ([]string, error) { + a, err := decode(raw) + if err != nil { + return nil, err + } + if err := need([2]string{"role", a.Role}, [2]string{"effect", a.Effect}); err != nil { + return nil, err + } + return append(base("intent", a), "-effect", a.Effect), nil + }, + }, + { + name: "org_resolve", + description: "Close an open effect (effect=) or an open escalation (target=), with an optional narrative body.", + schema: json.RawMessage(`{"type":"object","properties":{"role":{"type":"string"},"effect":{"type":"string"},"target":{"type":"string"},"body":{"type":"string"},"incarnation":{"type":"string"}},"required":["role"]}`), + args: func(raw json.RawMessage) ([]string, error) { + a, err := decode(raw) + if err != nil { + return nil, err + } + if err := need([2]string{"role", a.Role}); err != nil { + return nil, err + } + args := base("resolve", a) + if a.Effect != "" { + args = append(args, "-effect", a.Effect) + } + if a.Target != "" { + args = append(args, "-target", a.Target) + } + if a.Body != "" { + args = append(args, "-body", a.Body) + } + return args, nil + }, + }, + { + name: "org_escalate", + description: "Open a question for a human against this role; it stays an obligation until a resolution closes it.", + schema: json.RawMessage(roleBody), + args: bodyVerb("escalate"), + }, + { + name: "org_note", + description: "Free narrative on the chain — findings, decisions, where things stand.", + schema: json.RawMessage(roleBody), + args: bodyVerb("note"), + }, + { + name: "org_checkpoint", + description: "The distilled state of this session's work on the role: what happened, what is open, what the next incarnation must know. Write one before stopping.", + schema: json.RawMessage(roleBody), + args: bodyVerb("checkpoint"), + }, + { + name: "org_verify", + description: "Refold the role's chain and verify it: record count, phase, tip.", + schema: json.RawMessage(`{"type":"object","properties":{"role":{"type":"string"}},"required":["role"]}`), + args: func(raw json.RawMessage) ([]string, error) { + a, err := decode(raw) + if err != nil { + return nil, err + } + if err := need([2]string{"role", a.Role}); err != nil { + return nil, err + } + return []string{"verify", "-role", a.Role, "-json"}, nil + }, + }, +} + +func lookupVerb(name string) (verb, bool) { + for _, v := range verbs { + if v.name == name { + return v, true + } + } + return verb{}, false +} + +// toolsListResult renders the registry as an MCP tools/list result. +func toolsListResult() map[string]any { + tools := make([]map[string]any, 0, len(verbs)) + for _, v := range verbs { + tools = append(tools, map[string]any{ + "name": v.name, + "description": v.description, + "inputSchema": v.schema, + }) + } + return map[string]any{"tools": tools} +} diff --git a/cmd/org-mcp/main.go b/cmd/org-mcp/main.go new file mode 100644 index 00000000..10e954c6 --- /dev/null +++ b/cmd/org-mcp/main.go @@ -0,0 +1,46 @@ +// Command org-mcp is the MCP surface of the Baton home: a stdio JSON-RPC +// server exposing the org verbs as native tools, so any agent session can +// boot, claim, yield and checkpoint a role without knowing the CLI exists. +// +// It shells the org binary (ORG_BIN, default "org") and inherits ORG_STATE / +// ORG_TENANT from its own environment — the same resolution the CLI does, so +// the two surfaces can never disagree about which home they speak to. It owns +// no state and makes no decision: the CLI's exit-code seam is the whole +// contract, and a kernel refusal surfaces to the agent as an isError result +// carrying the refusal reason. +// +// Register in .mcp.json: +// +// { "mcpServers": { "org": { "command": "org-mcp", +// "env": { "ORG_STATE": "/Users/you/dev/org/state" } } } } +package main + +import ( + "context" + "fmt" + "os" + + "github.com/itsHabib/workbench/cmd/org-mcp/internal/server" +) + +func main() { + bin := os.Getenv("ORG_BIN") + if bin == "" { + bin = "org" + } + fmt.Fprintf(os.Stderr, "org-mcp: serving over stdio, shelling %s (state %s)\n", bin, stateHint()) + s := server.New(server.Shell(bin)) + if err := s.Serve(context.Background(), os.Stdin, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "org-mcp:", err) + os.Exit(1) + } +} + +// stateHint reports where the child org processes will resolve their state, +// for the startup line only — resolution itself stays the CLI's. +func stateHint() string { + if v := os.Getenv("ORG_STATE"); v != "" { + return v + } + return "default (~/dev/org/state)" +} diff --git a/cmd/org/AGENTS.md b/cmd/org/AGENTS.md index 5addd96e..9907cf2c 100644 --- a/cmd/org/AGENTS.md +++ b/cmd/org/AGENTS.md @@ -16,6 +16,16 @@ system name there is Baton; this binary is its first runtime slice. content-addressed erasable bodies under `blobs/`. Appends are serialized by an flock over the fold→admit→append critical section; admission is `org.Advance`, so nothing reaches a chain that the kernel would refuse. +- **Receipts**: every write verb takes `-json` and emits a machine receipt + (kind, seq, digest, phase, tip, holder, active, dangling, held, fence); + `status`/`verify`/`boot` speak JSON too. Identity: `-incarnation` (or + `ORG_INCARNATION`) presents the writer's id from attach; `-strict` (or + `ORG_STRICT`) refuses the write-as-holder default. +- **Operator context**: files dropped in + `$ORG_STATE///context.d/` ride the boot output, sorted, + under `-context-bytes` (default 4096), truncating with a pointer to the + directory. The dumbest mechanism that works: writing a file is publishing, + deleting it is revocation. - **Verbs** map one-to-one onto record kinds (charter, attach, claim, yield, complete, abandon, assign, takeover, revoke, seal, note, checkpoint, …) plus read verbs: `boot` (the byte-capped re-entry index), `status` (the board), diff --git a/cmd/org/CLAUDE.md b/cmd/org/CLAUDE.md index 5addd96e..9907cf2c 100644 --- a/cmd/org/CLAUDE.md +++ b/cmd/org/CLAUDE.md @@ -16,6 +16,16 @@ system name there is Baton; this binary is its first runtime slice. content-addressed erasable bodies under `blobs/`. Appends are serialized by an flock over the fold→admit→append critical section; admission is `org.Advance`, so nothing reaches a chain that the kernel would refuse. +- **Receipts**: every write verb takes `-json` and emits a machine receipt + (kind, seq, digest, phase, tip, holder, active, dangling, held, fence); + `status`/`verify`/`boot` speak JSON too. Identity: `-incarnation` (or + `ORG_INCARNATION`) presents the writer's id from attach; `-strict` (or + `ORG_STRICT`) refuses the write-as-holder default. +- **Operator context**: files dropped in + `$ORG_STATE///context.d/` ride the boot output, sorted, + under `-context-bytes` (default 4096), truncating with a pointer to the + directory. The dumbest mechanism that works: writing a file is publishing, + deleting it is revocation. - **Verbs** map one-to-one onto record kinds (charter, attach, claim, yield, complete, abandon, assign, takeover, revoke, seal, note, checkpoint, …) plus read verbs: `boot` (the byte-capped re-entry index), `status` (the board), diff --git a/cmd/org/internal/home/home.go b/cmd/org/internal/home/home.go index 3bc7091e..46fc6c58 100644 --- a/cmd/org/internal/home/home.go +++ b/cmd/org/internal/home/home.go @@ -173,7 +173,7 @@ func (h *Home) draft(tenant, role string, state org.RoleState, d Draft) (org.Rec if d.Kind == org.KindTakeover || d.Kind == org.KindRevoke { r.Fence = r.Seq } - if !mints(d.Kind) { + if !MintsIdentity(d.Kind) { r.Incarnation = state.Holder if d.Incarnation != "" { r.Incarnation = d.Incarnation @@ -196,10 +196,12 @@ func (h *Home) draft(tenant, role string, state org.RoleState, d Draft) (org.Rec return r, nil } -// mints reports the kinds whose own digest becomes an identity, and which -// therefore carry no incarnation of their own. Revoke is not among them: it is -// written by (or as) the displaced holder, and only advances the fence. -func mints(kind string) bool { +// MintsIdentity reports the kinds whose own digest becomes an identity, and +// which therefore carry no incarnation of their own. Revoke is not among them: +// it is written by (or as) the displaced holder, and only advances the fence. +// Exported because the CLI's identity policy branches on the same fact and a +// second copy of this list is how the two would drift. +func MintsIdentity(kind string) bool { return kind == org.KindCharter || kind == org.KindAttach || kind == org.KindTakeover } @@ -231,6 +233,47 @@ func (h *Home) Blob(digest string) ([]byte, bool, error) { return body, true, nil } +// ContextFile is one operator-authored boot source: a file dropped into the +// role's context.d directory. +type ContextFile struct { + Name string + Body []byte +} + +// Context reads the role's context.d — the operator's own boot sources. This +// is deliberately the dumbest mechanism that works: whatever files are there, +// sorted by name, injected at boot. No schema, no registry; an operator who +// wants the next session to know something writes a file, and deleting the +// file is the whole revocation story. A missing directory means no context. +func (h *Home) Context(tenant, role string) ([]ContextFile, error) { + dir := filepath.Join(h.dirFor(tenant, role), "context.d") + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read context.d: %w", err) + } + var out []ContextFile + for _, e := range entries { + if e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + body, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, fmt.Errorf("read context.d/%s: %w", e.Name(), err) + } + out = append(out, ContextFile{Name: e.Name(), Body: body}) + } + return out, nil +} + +// ContextDir reports where a role's operator context lives, so a renderer can +// point a reader at the full files after truncating. +func (h *Home) ContextDir(tenant, role string) string { + return filepath.Join(h.dirFor(tenant, role), "context.d") +} + // readChain decodes a JSONL chain file. Missing file folds as empty. func readChain(path string) ([]org.Record, error) { raw, err := os.ReadFile(path) diff --git a/cmd/org/main.go b/cmd/org/main.go index 9a58eb6a..50368609 100644 --- a/cmd/org/main.go +++ b/cmd/org/main.go @@ -24,6 +24,7 @@ package main import ( + "encoding/json" "flag" "fmt" "io" @@ -122,12 +123,16 @@ type env struct { stdout, stderr io.Writer } -// scope is the flag set every verb shares: which home, which chain. +// scope is the flag set every verb shares: which home, which chain, which +// identity, and how to speak. type scope struct { - fs *flag.FlagSet - state string - tenant string - role string + fs *flag.FlagSet + state string + tenant string + role string + incarnation string + strict bool + asJSON bool } func newScope(name string) *scope { @@ -135,6 +140,11 @@ func newScope(name string) *scope { s.fs.StringVar(&s.state, "state", envOr("ORG_STATE", defaultState()), "state directory") s.fs.StringVar(&s.tenant, "tenant", envOr("ORG_TENANT", "mh"), "tenant id") s.fs.StringVar(&s.role, "role", "", "role id, e.g. lead:agentic-development") + s.fs.StringVar(&s.incarnation, "incarnation", os.Getenv("ORG_INCARNATION"), + "present the writer's incarnation id (the digest attach printed); defaults to writing as the current holder") + s.fs.BoolVar(&s.strict, "strict", os.Getenv("ORG_STRICT") != "", + "refuse to write without an explicitly presented incarnation") + s.fs.BoolVar(&s.asJSON, "json", false, "emit a JSON receipt instead of text") return s } @@ -178,9 +188,31 @@ func body(e *env, v string) ([]byte, error) { return b, nil } -// appendAndReport appends one draft and prints the record's position, which is -// all a scripted caller needs to correlate with the chain. +// receipt is the machine-readable result of one append: the record's identity +// plus the state summary a caller decides its next move from. It is the JSON +// half of the exit-code seam. +type receipt struct { + Kind string `json:"kind"` + Seq int64 `json:"seq"` + Digest string `json:"digest"` + Phase org.Phase `json:"phase"` + Tip string `json:"tip"` + Holder string `json:"holder,omitempty"` + Active string `json:"active,omitempty"` + Dangling string `json:"dangling,omitempty"` + Held int `json:"held"` + Fence int64 `json:"fence"` +} + +// appendAndReport applies the identity policy, appends one draft, and prints +// the receipt — text for a human, `-json` for a machine. func appendAndReport(e *env, h *home.Home, s *scope, d home.Draft) error { + if s.strict && s.incarnation == "" && !home.MintsIdentity(d.Kind) { + return fmt.Errorf("strict mode: %s requires -incarnation (or ORG_INCARNATION); writing as the holder is disabled", d.Kind) + } + if d.Incarnation == "" { + d.Incarnation = s.incarnation + } r, state, err := h.Append(s.tenant, s.role, d) if err != nil { return err @@ -189,7 +221,24 @@ func appendAndReport(e *env, h *home.Home, s *scope, d home.Draft) error { if err != nil { return err } - fmt.Fprintf(e.stdout, "%s seq %d %s (phase %s)\n", r.Kind, r.Seq, digest, state.Phase) + if !s.asJSON { + fmt.Fprintf(e.stdout, "%s seq %d %s (phase %s)\n", r.Kind, r.Seq, digest, state.Phase) + return nil + } + return printJSON(e, receipt{ + Kind: r.Kind, Seq: r.Seq, Digest: digest, + Phase: state.Phase, Tip: state.Tip, Holder: state.Holder, + Active: state.Active, Dangling: state.Dangling, + Held: len(state.Held), Fence: state.Fence, + }) +} + +func printJSON(e *env, v any) error { + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + fmt.Fprintln(e.stdout, string(out)) return nil } @@ -234,6 +283,17 @@ func cmdAttach(e *env, args []string) error { if err != nil { return err } + if s.asJSON { + digest, err := org.DigestOf(r) + if err != nil { + return err + } + return printJSON(e, receipt{ + Kind: r.Kind, Seq: r.Seq, Digest: digest, Phase: state.Phase, + Tip: state.Tip, Holder: state.Holder, Active: state.Active, + Dangling: state.Dangling, Held: len(state.Held), Fence: state.Fence, + }) + } fmt.Fprintf(e.stdout, "attached: incarnation %s seq %d (phase %s)\n", state.Holder, r.Seq, state.Phase) return nil } @@ -378,8 +438,8 @@ func cmdAdvisory(kind string) func(*env, []string) error { func cmdBoot(e *env, args []string) error { s := newScope("boot") - asJSON := s.fs.Bool("json", false, "emit JSON instead of text") - budget := s.fs.Int("max-bytes", 2048, "text byte budget; depth is shed to fit") + budget := s.fs.Int("max-bytes", 2048, "boot-index byte budget; depth is shed to fit") + ctxBudget := s.fs.Int("context-bytes", 4096, "byte budget for operator context.d sources") h, err := s.open(args, true) if err != nil { return err @@ -395,18 +455,48 @@ func cmdBoot(e *env, args []string) error { if err != nil { return err } - if !*asJSON { - fmt.Fprint(e.stdout, b.Text(*budget)) - return nil - } - out, err := b.JSON() + files, err := h.Context(s.tenant, s.role) if err != nil { return err } - fmt.Fprintln(e.stdout, string(out)) + if s.asJSON { + return printJSON(e, bootJSON(b, files)) + } + fmt.Fprint(e.stdout, b.Text(*budget)) + fmt.Fprint(e.stdout, contextText(files, *ctxBudget, h.ContextDir(s.tenant, s.role))) return nil } +// bootJSON pairs the boot index with the operator context for machine callers. +func bootJSON(b render.Boot, files []home.ContextFile) map[string]any { + ctx := make([]map[string]string, 0, len(files)) + for _, f := range files { + ctx = append(ctx, map[string]string{"name": f.Name, "body": string(f.Body)}) + } + return map[string]any{"boot": b, "context": ctx} +} + +// contextText renders the operator's context.d sources under one budget, +// naming the directory when it truncates so the full files stay one read away. +func contextText(files []home.ContextFile, budget int, dir string) string { + if len(files) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("\n## operator context (context.d)\n") + spent := 0 + for _, f := range files { + entry := fmt.Sprintf("### %s\n%s\n", f.Name, strings.TrimSpace(string(f.Body))) + if spent+len(entry) > budget { + fmt.Fprintf(&sb, "… context truncated at %d bytes — read the rest in %s\n", budget, dir) + break + } + sb.WriteString(entry) + spent += len(entry) + } + return sb.String() +} + func cmdStatus(e *env, args []string) error { s := newScope("status") h, err := s.open(args, false) @@ -429,6 +519,9 @@ func cmdStatus(e *env, args []string) error { } rows = append(rows, render.NewRow(state, time.Now())) } + if s.asJSON { + return printJSON(e, rows) + } fmt.Fprint(e.stdout, render.Board(rows)) return nil } @@ -462,6 +555,11 @@ func cmdVerify(e *env, args []string) error { if err != nil { return err } + if s.asJSON { + return printJSON(e, map[string]any{ + "ok": true, "records": len(records), "phase": state.Phase, "tip": state.Tip, + }) + } fmt.Fprintf(e.stdout, "ok: %d records fold to phase %s, tip %s\n", len(records), state.Phase, state.Tip) return nil } diff --git a/cmd/org/main_test.go b/cmd/org/main_test.go index 14321a8d..d838365f 100644 --- a/cmd/org/main_test.go +++ b/cmd/org/main_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "os" + "path/filepath" "strings" "testing" ) @@ -72,3 +74,60 @@ func TestBootRefusesVoidChain(t *testing.T) { t.Fatalf("stderr: %s", errOut) } } + +// TestBootInjectsOperatorContext proves the context.d sources ride the boot +// output, sorted, and truncate with a pointer to the directory. +func TestBootInjectsOperatorContext(t *testing.T) { + state := t.TempDir() + role := []string{"-tenant", "acme", "-role", "lead:platform"} + if code, _, errOut := exec(t, state, append([]string{"charter", "-scope", "github:acme/api"}, role...)...); code != 0 { + t.Fatalf("charter: %s", errOut) + } + ctxDir := filepath.Join(state, "acme", "lead--platform", "context.d") + if err := os.MkdirAll(ctxDir, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(ctxDir, "10-mission.md"), []byte("ship the org loop"), 0o644) + os.WriteFile(filepath.Join(ctxDir, "20-rules.md"), []byte("two fix-rounds, then the judge"), 0o644) + + code, out, errOut := exec(t, state, append([]string{"boot"}, role...)...) + if code != 0 { + t.Fatalf("boot: %s", errOut) + } + mission := strings.Index(out, "ship the org loop") + rules := strings.Index(out, "two fix-rounds") + if mission < 0 || rules < 0 || mission > rules { + t.Fatalf("context missing or unordered (mission %d, rules %d):\n%s", mission, rules, out) + } + + _, out, _ = exec(t, state, append([]string{"boot", "-context-bytes", "40"}, role...)...) + if !strings.Contains(out, "context truncated at 40 bytes") { + t.Fatalf("no truncation note:\n%s", out) + } +} + +// TestStrictIdentityPolicy pins the -strict seam: a write without a presented +// incarnation is refused before the append, a presented-but-stale incarnation +// is the kernel's stale_incarnation refusal, and the minting kinds stay exempt. +func TestStrictIdentityPolicy(t *testing.T) { + state := t.TempDir() + role := []string{"-tenant", "acme", "-role", "lead:platform"} + if code, _, e := exec(t, state, append([]string{"charter", "-scope", "github:acme/api", "-strict"}, role...)...); code != 0 { + t.Fatalf("strict charter must stay exempt (minting kind): %s", e) + } + if code, _, e := exec(t, state, append([]string{"attach", "-strict"}, role...)...); code != 0 { + t.Fatalf("strict attach must stay exempt (minting kind): %s", e) + } + + code, _, errOut := exec(t, state, append([]string{"assign", "-strict", "-work", "github:acme/api#88", "-pin", "x"}, role...)...) + if code != codeError || !strings.Contains(errOut, "strict mode") { + t.Fatalf("strict write without incarnation: exit %d, stderr %s", code, errOut) + } + + code, _, errOut = exec(t, state, append([]string{"assign", "-incarnation", + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "-work", "github:acme/api#88", "-pin", "x"}, role...)...) + if code != codeRefused || !strings.Contains(errOut, "stale_incarnation") { + t.Fatalf("stale presented incarnation: exit %d, stderr %s", code, errOut) + } +}