diff --git a/.gitignore b/.gitignore index 05981100..d55fe33e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ /eval /flare /gate +/org /review /reviewfindings /runway diff --git a/AGENTS.md b/AGENTS.md index 46c7af03..16eb3516 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,9 @@ Orientation block you can point an agent at to ground it fast. `gate` (the merge-authorization boundary — grants, the verifier ladder, the 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), 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 6286a050..92c28ea8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,9 @@ Orientation block you can point an agent at to ground it fast. decision for a parked escalation and drives `gate resolve` to close the 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), 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/AGENTS.md b/cmd/org/AGENTS.md new file mode 100644 index 00000000..5addd96e --- /dev/null +++ b/cmd/org/AGENTS.md @@ -0,0 +1,52 @@ +# org — the Baton home + +The runtime for role continuity chains. The kernel — record spine, kind set, +fold, every admission law — is `contracts/org` and is imported as types and +laws, never re-decided here. This tool owns what a pure kernel cannot: where +chains live, when records are stamped and locked, and how a fresh session +re-enters a role. + +Design context: `docs/features/org/vision.md` (PR #245, the org TDD). The +system name there is Baton; this binary is its first runtime slice. + +## What it is + +- **State**: `$ORG_STATE` (default `~/dev/org/state`) holds one JSONL chain + per role at `//chain.jsonl`, plus + 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. +- **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), + `log`, `verify`, `blob`. +- **Hooks** (`hooks/`): `sessionstart-boot.sh` injects `org boot` output into + a session whose cwd maps to a role (`$ORG_STATE/roles.map`); + `stop-mark.sh` appends a mechanical `mark` when a session stops. Both + fail-open: no mapping, no binary, no chain — exit 0, empty output. + +## Invariants + +- The home adds no judgment. A record refused by the kernel is refused here + with the kernel's reason on stderr; the chain does not grow. +- Checkpoints are distilled by a host, never demanded of the working agent. + The Stop hook writes a `mark`; a mark at the tip renders the boot index + `degraded`, which is the honest state. +- The boot index is an index: pointers plus hooks, byte-budgeted + (`-max-bytes`, default 2048), shedding depth (last-word excerpt, held list) + but never the headline, the charter line, or a dangling obligation. +- Liveness is derived from the writer's own declared `next_due`, never from + self-report at read time. + +## Exit codes (load-bearing seam) + +0 ok · 1 kernel refusal (stderr carries the reason id, e.g. `dangling_claim`) +· 2 usage · 4 error. A refusal is the substrate working, not a failure. + +## Checks + +``` +gofmt -l ./cmd/org && go vet ./cmd/org/... +golangci-lint run ./cmd/org/... +go test ./cmd/org/... +``` diff --git a/cmd/org/CLAUDE.md b/cmd/org/CLAUDE.md new file mode 100644 index 00000000..5addd96e --- /dev/null +++ b/cmd/org/CLAUDE.md @@ -0,0 +1,52 @@ +# org — the Baton home + +The runtime for role continuity chains. The kernel — record spine, kind set, +fold, every admission law — is `contracts/org` and is imported as types and +laws, never re-decided here. This tool owns what a pure kernel cannot: where +chains live, when records are stamped and locked, and how a fresh session +re-enters a role. + +Design context: `docs/features/org/vision.md` (PR #245, the org TDD). The +system name there is Baton; this binary is its first runtime slice. + +## What it is + +- **State**: `$ORG_STATE` (default `~/dev/org/state`) holds one JSONL chain + per role at `//chain.jsonl`, plus + 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. +- **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), + `log`, `verify`, `blob`. +- **Hooks** (`hooks/`): `sessionstart-boot.sh` injects `org boot` output into + a session whose cwd maps to a role (`$ORG_STATE/roles.map`); + `stop-mark.sh` appends a mechanical `mark` when a session stops. Both + fail-open: no mapping, no binary, no chain — exit 0, empty output. + +## Invariants + +- The home adds no judgment. A record refused by the kernel is refused here + with the kernel's reason on stderr; the chain does not grow. +- Checkpoints are distilled by a host, never demanded of the working agent. + The Stop hook writes a `mark`; a mark at the tip renders the boot index + `degraded`, which is the honest state. +- The boot index is an index: pointers plus hooks, byte-budgeted + (`-max-bytes`, default 2048), shedding depth (last-word excerpt, held list) + but never the headline, the charter line, or a dangling obligation. +- Liveness is derived from the writer's own declared `next_due`, never from + self-report at read time. + +## Exit codes (load-bearing seam) + +0 ok · 1 kernel refusal (stderr carries the reason id, e.g. `dangling_claim`) +· 2 usage · 4 error. A refusal is the substrate working, not a failure. + +## Checks + +``` +gofmt -l ./cmd/org && go vet ./cmd/org/... +golangci-lint run ./cmd/org/... +go test ./cmd/org/... +``` diff --git a/cmd/org/README.md b/cmd/org/README.md new file mode 100644 index 00000000..710b2bb6 --- /dev/null +++ b/cmd/org/README.md @@ -0,0 +1,62 @@ +# org + +The Baton home: role continuity chains for agent sessions. A **role** is a +durable office (`lead:agentic-development`) with an append-only hash chain; a +**session** is a disposable incarnation that attaches to it, acts, and leaves +a record. The next session starts where the last one stopped, and two +sessions cannot silently reach different conclusions about the same thing — +the chain's compare-and-swap refuses the second writer. + +The kernel (record spine, state machine, admission laws, the fold) is +[`contracts/org`](../../contracts/org); this binary is its runtime. + +## Quickstart + +```sh +go install ./cmd/org + +# the operator charters a role once +org charter -role lead:agentic-development \ + -scope dossier:org -scope github:itsHabib/workbench \ + -tier T2 -supervisor human:mh -cycle-ceiling 3 \ + -retire-when "org loop merged into steward" + +# a session becomes the incarnation, works, and leaves a record +org attach -role lead:agentic-development -next-due 4h +org assign -role lead:agentic-development -work dossier:org/p1/t3 -pin "task body" +org claim -role lead:agentic-development -work dossier:org/p1/t3 +org yield -role lead:agentic-development -work dossier:org/p1/t3 -body "where I stopped" +org checkpoint -role lead:agentic-development -body "SESSION END: …" + +# the next session reads the index the last one left +org boot -role lead:agentic-development +org status +``` + +`org boot` is the re-entry surface: a byte-capped index (default 2048) of the +role's charter, held work, obligations, liveness, and the last incarnation's +final word — pointers with hooks, not a context dump. Depth is read lazily +(`org blob `, `org log`). + +Refusals are the substrate working: claim work you don't hold → exit 1, +`work_not_held`. A supervisor `takeover` mid-claim leaves a **dangling +obligation** the successor must discharge before claiming anything — silent +disappearance of work is not representable. + +## Harness wiring + +Two hooks close the loop for Claude Code sessions (both fail-open): + +- `hooks/sessionstart-boot.sh` — injects `org boot` into a fresh session when + its cwd maps to a role in `$ORG_STATE/roles.map` + (` `, longest prefix wins). +- `hooks/stop-mark.sh` — appends a mechanical `mark` when a session stops; + the next boot renders `degraded` until someone distills a checkpoint. + +Install snippets are in each script's header. State lives at `$ORG_STATE` +(default `~/dev/org/state`). + +## Exit codes + +`0` ok · `1` the kernel refused the record (stderr names the reason) · +`2` usage · `4` error. diff --git a/cmd/org/hooks/sessionstart-boot.sh b/cmd/org/hooks/sessionstart-boot.sh new file mode 100755 index 00000000..0d61f22b --- /dev/null +++ b/cmd/org/hooks/sessionstart-boot.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# SessionStart hook: inject a role's boot index into a fresh session. +# +# The session's cwd is mapped to a role via $ORG_STATE/roles.map, one line per +# binding: +# +# +# /Users/mh/dev/workbench mh lead:agentic-development +# +# Longest matching prefix wins. No mapping, no org binary, no chain — all exit +# 0 with no output: re-entry is an offer, never a gate on starting a session. +# +# Install (in ~/.claude/settings.json under hooks): +# "SessionStart": [{"hooks": [{"type": "command", +# "command": "ORG_STATE=\"$HOME/dev/org/state\" bash \"$HOME/dev/workbench/cmd/org/hooks/sessionstart-boot.sh\"", +# "timeout": 5}]}] +set -euo pipefail + +ORG_BIN="${ORG_BIN:-org}" +ORG_STATE="${ORG_STATE:-$HOME/dev/org/state}" +MAP="$ORG_STATE/roles.map" + +command -v "$ORG_BIN" >/dev/null 2>&1 || exit 0 +command -v jq >/dev/null 2>&1 || exit 0 +[ -f "$MAP" ] || exit 0 + +input="$(cat)" +cwd="$(jq -r '.cwd // empty' <<<"$input" 2>/dev/null)" || exit 0 +[ -n "$cwd" ] || exit 0 + +tenant="" role="" best=0 +while read -r prefix map_tenant map_role; do + case "$prefix" in ''|'#'*) continue ;; esac + case "$cwd" in "$prefix"*) ;; *) continue ;; esac + if [ "${#prefix}" -gt "$best" ]; then + best="${#prefix}" tenant="$map_tenant" role="$map_role" + fi +done <"$MAP" +[ -n "$role" ] || exit 0 + +boot="$("$ORG_BIN" boot -state "$ORG_STATE" -tenant "$tenant" -role "$role" \ + -max-bytes "${ORG_BOOT_BUDGET:-2048}" 2>/dev/null)" || exit 0 +[ -n "$boot" ] || exit 0 + +jq -n --arg ctx "$boot" \ + '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $ctx}}' diff --git a/cmd/org/hooks/stop-mark.sh b/cmd/org/hooks/stop-mark.sh new file mode 100755 index 00000000..03a4df38 --- /dev/null +++ b/cmd/org/hooks/stop-mark.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Stop hook: append a mechanical mark to the role's chain when a session ends. +# +# A mark is the host's observation, not the model's claim: session id, turn +# count, transcript path. It is deliberately NOT a checkpoint — distillation +# needs a model, and a record the working agent is required to write is a verb +# wearing a costume. The fold renders a mark tip as Degraded, which is the +# honest state: activity happened and nobody has distilled it yet. +# +# Same roles.map contract as sessionstart-boot.sh; fail-open throughout. +# +# Install (in ~/.claude/settings.json under hooks): +# "Stop": [{"hooks": [{"type": "command", +# "command": "ORG_STATE=\"$HOME/dev/org/state\" bash \"$HOME/dev/workbench/cmd/org/hooks/stop-mark.sh\"", +# "timeout": 10}]}] +set -euo pipefail + +ORG_BIN="${ORG_BIN:-org}" +ORG_STATE="${ORG_STATE:-$HOME/dev/org/state}" +MAP="$ORG_STATE/roles.map" + +command -v "$ORG_BIN" >/dev/null 2>&1 || exit 0 +command -v jq >/dev/null 2>&1 || exit 0 +[ -f "$MAP" ] || exit 0 + +input="$(cat)" +# Re-entrancy: a Stop hook that makes the agent continue re-fires Stop. +[ "$(jq -r '.stop_hook_active // false' <<<"$input" 2>/dev/null)" = "true" ] && exit 0 +cwd="$(jq -r '.cwd // empty' <<<"$input" 2>/dev/null)" || exit 0 +session="$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null)" || exit 0 +transcript="$(jq -r '.transcript_path // empty' <<<"$input" 2>/dev/null)" || exit 0 +[ -n "$cwd" ] || exit 0 + +tenant="" role="" best=0 +while read -r prefix map_tenant map_role; do + case "$prefix" in ''|'#'*) continue ;; esac + case "$cwd" in "$prefix"*) ;; *) continue ;; esac + if [ "${#prefix}" -gt "$best" ]; then + best="${#prefix}" tenant="$map_tenant" role="$map_role" + fi +done <"$MAP" +[ -n "$role" ] || exit 0 + +turns="" +if [ -n "$transcript" ] && [ -f "$transcript" ]; then + turns="$(jq -rs '[.[] | select(.type == "assistant")] | length' "$transcript" 2>/dev/null || true)" +fi + +body="session ${session:0:8} stopped in $cwd" +[ -n "$turns" ] && body="$body after $turns assistant turns" +body="$body; transcript $transcript" + +printf '%s' "$body" | "$ORG_BIN" mark -state "$ORG_STATE" -tenant "$tenant" \ + -role "$role" -body - >/dev/null 2>&1 || exit 0 diff --git a/cmd/org/internal/home/home.go b/cmd/org/internal/home/home.go new file mode 100644 index 00000000..3bc7091e --- /dev/null +++ b/cmd/org/internal/home/home.go @@ -0,0 +1,293 @@ +// Package home is the org runtime's mechanism layer: chains on disk, one +// writer at a time, bodies as content-addressed blobs. +// +// The layer split is deliberate. Every law about WHAT may extend a chain lives +// in contracts/org (the fold); this package owns only WHERE records live and +// HOW an append is serialized. It decides nothing — a record this package +// writes was admitted by org.Advance under the lock, and a record refused +// there is refused here with the kernel's own reason. +// +// # Layout +// +// ///chain.jsonl one record per line, append-only +// /blobs/sha256- erasable bodies, content-addressed +// +// Role ids carry colons (lead:agentic-development); the on-disk directory +// replaces them with "--" so paths stay friendly to every tool that splits on +// colons. +package home + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/itsHabib/workbench/contracts/org" +) + +// Home is one state root: every chain and blob under one directory. +type Home struct { + root string +} + +// Open returns a Home rooted at dir, creating it if absent. +func Open(dir string) (*Home, error) { + if dir == "" { + return nil, fmt.Errorf("state dir is empty; set -state or ORG_STATE") + } + if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0o755); err != nil { + return nil, fmt.Errorf("create state root: %w", err) + } + return &Home{root: dir}, nil +} + +// Root reports the state directory this home is rooted at. +func (h *Home) Root() string { return h.root } + +// dirFor maps a tenant and role to the chain directory. +func (h *Home) dirFor(tenant, role string) string { + return filepath.Join(h.root, tenant, strings.ReplaceAll(role, ":", "--")) +} + +// chainPath is the JSONL file holding a role's records. +func (h *Home) chainPath(tenant, role string) string { + return filepath.Join(h.dirFor(tenant, role), "chain.jsonl") +} + +// Roles enumerates every chain under the home as (tenant, role) pairs, derived +// from the directory layout rather than an index that could drift from it. +func (h *Home) Roles() ([][2]string, error) { + tenants, err := os.ReadDir(h.root) + if err != nil { + return nil, fmt.Errorf("read state root: %w", err) + } + var out [][2]string + for _, t := range tenants { + if !t.IsDir() || t.Name() == "blobs" { + continue + } + roles, err := os.ReadDir(filepath.Join(h.root, t.Name())) + if err != nil { + return nil, fmt.Errorf("read tenant %s: %w", t.Name(), err) + } + for _, r := range roles { + if !r.IsDir() { + continue + } + out = append(out, [2]string{t.Name(), strings.ReplaceAll(r.Name(), "--", ":")}) + } + } + return out, nil +} + +// Load reads and folds a role's chain. A missing chain is not an error: it +// folds to the zero state, which is exactly what the kernel says an empty +// chain means. +func (h *Home) Load(tenant, role string) ([]org.Record, org.RoleState, error) { + records, err := readChain(h.chainPath(tenant, role)) + if err != nil { + return nil, org.RoleState{}, err + } + state, err := org.Reduce(records) + if err != nil { + return nil, org.RoleState{}, fmt.Errorf("chain for %s/%s does not fold: %w", tenant, role, err) + } + return records, state, nil +} + +// Draft is what a verb asks the home to append: the transition and its +// structural parameter. Everything positional — seq, prev, fence, incarnation, +// class, timestamp — is the home's to fill, exactly as a real writer would. +type Draft struct { + Kind string + Subject org.Subject + Terms *org.Terms + Refs []org.Ref + // Body is optional prose. Non-empty stores a blob and stamps its digest. + Body []byte + BodyClass string + // NextDue, when set, declares the writer's own next-append deadline. + NextDue time.Time + // Incarnation optionally presents the writer's identity explicitly. Empty + // lets the home write as the current holder, which is the single-operator + // posture this POC runs in; a multi-writer home would require it. + Incarnation string +} + +// Append admits and appends one record under the chain's lock, returning the +// record as written and the state after it. +func (h *Home) Append(tenant, role string, d Draft) (org.Record, org.RoleState, error) { + dir := h.dirFor(tenant, role) + if err := os.MkdirAll(dir, 0o755); err != nil { + return org.Record{}, org.RoleState{}, fmt.Errorf("create chain dir: %w", err) + } + unlock, err := lock(filepath.Join(dir, "lock")) + if err != nil { + return org.Record{}, org.RoleState{}, err + } + defer unlock() + + _, state, err := h.Load(tenant, role) + if err != nil { + return org.Record{}, org.RoleState{}, err + } + r, err := h.draft(tenant, role, state, d) + if err != nil { + return org.Record{}, org.RoleState{}, err + } + next, err := org.Advance(state, r) + if err != nil { + return org.Record{}, org.RoleState{}, err + } + if err := appendLine(h.chainPath(tenant, role), r); err != nil { + return org.Record{}, org.RoleState{}, err + } + return r, next, nil +} + +// draft fills the positional spine the way the kernel's own writer fixture +// does: seq and prev from the folded tip, fence carried forward, incarnation +// as the holder unless the kind mints one or the caller presented an identity. +func (h *Home) draft(tenant, role string, state org.RoleState, d Draft) (org.Record, error) { + class, known := org.DeclaredClass(d.Kind) + if !known { + return org.Record{}, fmt.Errorf("unknown kind %q", d.Kind) + } + r := org.Record{ + V: org.Version, Scheme: org.Scheme, + Seq: state.Seq + 1, Prev: state.Tip, + Tenant: tenant, Role: role, + Kind: d.Kind, KindClass: class, + Fence: state.Fence, + At: time.Now().UTC().Format(time.RFC3339), + Subject: d.Subject, + Terms: d.Terms, + Refs: d.Refs, + } + // A takeover or revoke advances the fence to its own position, which is + // what kills every credential minted before it. + if d.Kind == org.KindTakeover || d.Kind == org.KindRevoke { + r.Fence = r.Seq + } + if !mints(d.Kind) { + r.Incarnation = state.Holder + if d.Incarnation != "" { + r.Incarnation = d.Incarnation + } + } + if !d.NextDue.IsZero() { + r.NextDue = d.NextDue.UTC().Format(time.RFC3339) + } + if len(d.Body) > 0 { + digest, err := h.PutBlob(d.Body) + if err != nil { + return org.Record{}, err + } + r.BodyDigest = digest + r.BodyClass = d.BodyClass + if r.BodyClass == "" { + r.BodyClass = "narrative" + } + } + 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 { + return kind == org.KindCharter || kind == org.KindAttach || kind == org.KindTakeover +} + +// PutBlob stores a body content-addressed and returns its digest. +func (h *Home) PutBlob(body []byte) (string, error) { + digest := org.DigestBytes(body) + path := filepath.Join(h.root, "blobs", strings.ReplaceAll(digest, ":", "-")) + if _, err := os.Stat(path); err == nil { + return digest, nil + } + if err := os.WriteFile(path, body, 0o644); err != nil { + return "", fmt.Errorf("store blob: %w", err) + } + return digest, nil +} + +// Blob reads a body back by digest. A missing blob is reported as erased, not +// as an error in the chain: erasability is the design, and callers render the +// absence. +func (h *Home) Blob(digest string) ([]byte, bool, error) { + path := filepath.Join(h.root, "blobs", strings.ReplaceAll(digest, ":", "-")) + body, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("read blob: %w", err) + } + return body, true, nil +} + +// readChain decodes a JSONL chain file. Missing file folds as empty. +func readChain(path string) ([]org.Record, error) { + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read chain: %w", err) + } + var records []org.Record + for i, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + if line == "" { + continue + } + var r org.Record + if err := json.Unmarshal([]byte(line), &r); err != nil { + return nil, fmt.Errorf("chain line %d: %w", i+1, err) + } + records = append(records, r) + } + return records, nil +} + +// appendLine writes one record as a JSON line and syncs it, so a record the +// caller was told about is on disk. +func appendLine(path string, r org.Record) error { + line, err := json.Marshal(r) + if err != nil { + return fmt.Errorf("encode record: %w", err) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("open chain: %w", err) + } + defer f.Close() + if _, err := f.Write(append(line, '\n')); err != nil { + return fmt.Errorf("append record: %w", err) + } + if err := f.Sync(); err != nil { + return fmt.Errorf("sync chain: %w", err) + } + return f.Close() +} + +// lock takes an exclusive flock on path, returning the release. The lock file +// is separate from the chain so a reader never contends with the append fsync. +func lock(path string) (func(), error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, fmt.Errorf("open lock: %w", err) + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + f.Close() + return nil, fmt.Errorf("lock chain: %w", err) + } + return func() { + syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + f.Close() + }, nil +} diff --git a/cmd/org/internal/home/home_test.go b/cmd/org/internal/home/home_test.go new file mode 100644 index 00000000..166be333 --- /dev/null +++ b/cmd/org/internal/home/home_test.go @@ -0,0 +1,187 @@ +package home + +import ( + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/itsHabib/workbench/contracts/org" +) + +const ( + tenant = "acme" + role = "lead:platform" + work = "github:acme/api#88" +) + +func open(t *testing.T) *Home { + t.Helper() + h, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open home: %v", err) + } + return h +} + +func charter(t *testing.T, h *Home) { + t.Helper() + _, _, err := h.Append(tenant, role, Draft{ + Kind: org.KindCharter, + Terms: &org.Terms{Scope: []string{work}, Supervisors: []string{"human:op"}, MinReader: 1}, + }) + if err != nil { + t.Fatalf("charter: %v", err) + } +} + +func mustAppend(t *testing.T, h *Home, d Draft) org.RoleState { + t.Helper() + _, state, err := h.Append(tenant, role, d) + if err != nil { + t.Fatalf("append %s: %v", d.Kind, err) + } + return state +} + +// TestLifecycleFoldsBack drives the full loop through the home and re-loads it +// from disk, proving that what Append admitted is what Load folds. +func TestLifecycleFoldsBack(t *testing.T) { + h := open(t) + charter(t, h) + mustAppend(t, h, Draft{Kind: org.KindAttach, NextDue: time.Now().Add(time.Hour)}) + mustAppend(t, h, Draft{Kind: org.KindAssign, Subject: org.Subject{Work: work, Digest: org.DigestBytes([]byte("v1"))}}) + mustAppend(t, h, Draft{Kind: org.KindClaim, Subject: org.Subject{Work: work}}) + mustAppend(t, h, Draft{Kind: org.KindNote, Body: []byte("progress")}) + state := mustAppend(t, h, Draft{Kind: org.KindYield, Subject: org.Subject{Work: work}}) + if state.Phase != org.PhaseHeld { + t.Fatalf("after yield, phase = %s, want %s", state.Phase, org.PhaseHeld) + } + + records, loaded, err := h.Load(tenant, role) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(records) != 6 { + t.Fatalf("loaded %d records, want 6", len(records)) + } + if loaded.Tip != state.Tip || loaded.Seq != state.Seq { + t.Fatalf("loaded state (seq %d, tip %s) differs from appended (seq %d, tip %s)", + loaded.Seq, loaded.Tip, state.Seq, state.Tip) + } + if !loaded.Holds(work) { + t.Fatalf("loaded state lost the held work %s", work) + } +} + +// TestRefusalCarriesKernelReason proves the home adds no judgment of its own: +// a record the kernel refuses surfaces the kernel's reason, and the chain does +// not grow. +func TestRefusalCarriesKernelReason(t *testing.T) { + h := open(t) + charter(t, h) + mustAppend(t, h, Draft{Kind: org.KindAttach}) + + _, _, err := h.Append(tenant, role, Draft{Kind: org.KindClaim, Subject: org.Subject{Work: "jira:NOPE-1"}}) + if got := org.RefusalReason(err); got != org.ReasonWorkNotHeld { + t.Fatalf("refusal reason = %q, want %q (err: %v)", got, org.ReasonWorkNotHeld, err) + } + records, _, err := h.Load(tenant, role) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(records) != 2 { + t.Fatalf("a refused record reached the chain: %d records, want 2", len(records)) + } +} + +// TestConcurrentAppendsSerialize hammers one chain from many goroutines. Every +// append lands or is refused under the lock; the survivors must be contiguous +// and fold clean. This is the serialized-ownership property the switchboard +// experiment measured, here as a regression test. +func TestConcurrentAppendsSerialize(t *testing.T) { + h := open(t) + charter(t, h) + mustAppend(t, h, Draft{Kind: org.KindAttach}) + + const writers = 8 + var wg sync.WaitGroup + for i := 0; i < writers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + h.Append(tenant, role, Draft{Kind: org.KindNote, Body: []byte("concurrent")}) + }() + } + wg.Wait() + + records, state, err := h.Load(tenant, role) + if err != nil { + t.Fatalf("chain corrupted by concurrent appends: %v", err) + } + if len(records) != 2+writers { + t.Fatalf("%d records, want %d", len(records), 2+writers) + } + if state.Seq != int64(2+writers) { + t.Fatalf("state.Seq = %d, want %d", state.Seq, 2+writers) + } +} + +// TestBlobRoundTripAndErasure proves bodies are content-addressed, readable +// back, and that erasing one leaves the chain folding — erasability is the +// design, not a failure. +func TestBlobRoundTripAndErasure(t *testing.T) { + h := open(t) + charter(t, h) + mustAppend(t, h, Draft{Kind: org.KindAttach}) + r, _, err := h.Append(tenant, role, Draft{Kind: org.KindCheckpoint, Body: []byte("the conclusion")}) + if err != nil { + t.Fatalf("checkpoint: %v", err) + } + + body, found, err := h.Blob(r.BodyDigest) + if err != nil || !found { + t.Fatalf("blob read: found=%v err=%v", found, err) + } + if string(body) != "the conclusion" { + t.Fatalf("blob = %q", body) + } + + path := filepath.Join(h.Root(), "blobs", "sha256-"+r.BodyDigest[len("sha256:"):]) + if err := os.Remove(path); err != nil { + t.Fatalf("erase blob: %v", err) + } + if _, found, err := h.Blob(r.BodyDigest); err != nil || found { + t.Fatalf("erased blob: found=%v err=%v, want found=false, nil", found, err) + } + if _, _, err := h.Load(tenant, role); err != nil { + t.Fatalf("chain must fold after body erasure: %v", err) + } +} + +// TestTakeoverAdvancesFence proves the home stamps a takeover at its own chain +// position, which is what kills credentials minted before it. +func TestTakeoverAdvancesFence(t *testing.T) { + h := open(t) + charter(t, h) + mustAppend(t, h, Draft{Kind: org.KindAttach}) + state := mustAppend(t, h, Draft{Kind: org.KindTakeover, Subject: org.Subject{Party: "human:op"}}) + if state.Fence != state.Seq { + t.Fatalf("fence = %d after takeover at seq %d; a takeover must advance the fence", state.Fence, state.Seq) + } +} + +// TestRolesEnumeratesLayout proves the role listing derives from the directory +// layout, colon mapping included. +func TestRolesEnumeratesLayout(t *testing.T) { + h := open(t) + charter(t, h) + pairs, err := h.Roles() + if err != nil { + t.Fatalf("roles: %v", err) + } + if len(pairs) != 1 || pairs[0] != [2]string{tenant, role} { + t.Fatalf("roles = %v, want [[%s %s]]", pairs, tenant, role) + } +} diff --git a/cmd/org/internal/render/render.go b/cmd/org/internal/render/render.go new file mode 100644 index 00000000..b554a1b5 --- /dev/null +++ b/cmd/org/internal/render/render.go @@ -0,0 +1,292 @@ +// Package render turns folded role state into the two read surfaces the org +// runtime serves: the boot index a session starts from, and the board an +// operator glances at. +// +// The boot index is an INDEX, not a context dump. Every line is a pointer plus +// just enough of a hook to decide whether to follow it; the depth — full +// charter prose, task bodies, old checkpoints — stays behind the pointers and +// is read lazily by whoever needs it. That is what keeps the standing cost of +// re-entry (bytes injected into every turn of every session) decoupled from +// how much a role knows. +package render + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/itsHabib/workbench/contracts/org" +) + +// Boot is the boot index for one role: what a fresh incarnation must see +// before its first action. +type Boot struct { + Tenant string `json:"tenant"` + Role string `json:"role"` + Phase org.Phase `json:"phase"` + Seq int64 `json:"seq"` + Tip string `json:"tip"` + Holder string `json:"holder,omitempty"` + Terms org.Terms `json:"terms"` + Active string `json:"active,omitempty"` + // Dangling is an inherited, unresolved claim. It renders first: the kernel + // refuses every new claim until it is discharged, so a session that skims + // only one line must skim this one. + Dangling string `json:"dangling,omitempty"` + Held []org.Assignment `json:"held,omitempty"` + OpenIntents []string `json:"open_intents,omitempty"` + OpenEscalations []string `json:"open_escalations,omitempty"` + NextDue string `json:"next_due,omitempty"` + Late bool `json:"late,omitempty"` + Degraded bool `json:"degraded,omitempty"` + // LastWord is the most recent advisory body on the chain — the previous + // incarnation's distilled conclusion, or its mechanical mark. + LastWord *LastWord `json:"last_word,omitempty"` +} + +// LastWord locates and excerpts the newest advisory record with a body. +type LastWord struct { + Kind string `json:"kind"` + Seq int64 `json:"seq"` + At string `json:"at"` + Digest string `json:"body_digest"` + // Excerpt is the head of the body, cut to the boot byte budget. Erased + // bodies render as erased rather than silently vanishing. + Excerpt string `json:"excerpt,omitempty"` + Erased bool `json:"erased,omitempty"` +} + +// BlobReader resolves a body digest to its content. It reports found=false for +// an erased blob, which is a legitimate state, not an error. +type BlobReader interface { + Blob(digest string) (body []byte, found bool, err error) +} + +// NewBoot assembles the boot index from a folded state and its chain. +func NewBoot(state org.RoleState, records []org.Record, blobs BlobReader, now time.Time) (Boot, error) { + b := Boot{ + Tenant: state.Tenant, Role: state.Role, + Phase: state.Phase, Seq: state.Seq, Tip: state.Tip, + Holder: state.Holder, Terms: state.Terms, + Active: state.Active, Dangling: state.Dangling, + Held: state.Held, + OpenIntents: state.OpenIntents, OpenEscalations: state.OpenEscalations, + NextDue: state.NextDue, Degraded: state.Degraded, + } + b.Late = late(state.NextDue, now) + last, err := lastWord(records, blobs) + if err != nil { + return Boot{}, err + } + b.LastWord = last + return b, nil +} + +// late reports whether a declared next-append deadline has passed. An +// unparseable deadline is treated as late: a writer that garbles its own +// liveness declaration should read as dead, not as immortal. +func late(nextDue string, now time.Time) bool { + if nextDue == "" { + return false + } + due, err := time.Parse(time.RFC3339, nextDue) + if err != nil { + return true + } + return now.After(due) +} + +// lastWord scans the chain backwards for the newest advisory record carrying a +// body, skipping structural records: a claim terminal is a fact, not a word. +func lastWord(records []org.Record, blobs BlobReader) (*LastWord, error) { + for i := len(records) - 1; i >= 0; i-- { + r := records[i] + if r.KindClass != org.ClassAdvisory || r.BodyDigest == "" { + continue + } + w := &LastWord{Kind: r.Kind, Seq: r.Seq, At: r.At, Digest: r.BodyDigest} + body, found, err := blobs.Blob(r.BodyDigest) + if err != nil { + return nil, err + } + if !found { + w.Erased = true + return w, nil + } + w.Excerpt = string(body) + return w, nil + } + return nil, nil +} + +// JSON renders the boot index as one JSON document. +func (b Boot) JSON() ([]byte, error) { return json.MarshalIndent(b, "", " ") } + +// Text renders the boot index for injection, within a byte budget. +// +// The budget is enforced by shedding depth, not by cutting mid-line: first the +// last-word excerpt shrinks, then the held list collapses to a count. The +// headline, the dangling obligation, and the charter line are never shed — +// they are the lines whose absence changes what a session does. +func (b Boot) Text(budget int) string { + if budget <= 0 { + budget = 2048 + } + for { + s := b.text() + if len(s) <= budget { + return s + } + if !b.shed() { + return s[:budget] + } + } +} + +// shed drops the cheapest remaining depth, reporting false when nothing more +// can go. +func (b *Boot) shed() bool { + switch { + // The cut must land strictly below its own guard, or shedding stops making + // progress and Text spins: 160 bytes + a 3-byte ellipsis is 163. + case b.LastWord != nil && len(b.LastWord.Excerpt) > 163: + b.LastWord.Excerpt = b.LastWord.Excerpt[:160] + "…" + case b.LastWord != nil && b.LastWord.Excerpt != "": + b.LastWord.Excerpt = "" + case len(b.Held) > 0: + b.Held = nil + default: + return false + } + return true +} + +func (b Boot) text() string { + var sb strings.Builder + fmt.Fprintf(&sb, "# baton boot — %s @ %s\n", b.Role, b.Tenant) + fmt.Fprintf(&sb, "phase: %s · seq %d · holder %s\n", b.Phase, b.Seq, shortOr(b.Holder, "none")) + fmt.Fprintf(&sb, "charter: tier %s · scope %s · supervisors %s\n", + orDash(b.Terms.Tier), joinOr(b.Terms.Scope, "-"), joinOr(b.Terms.Supervisors, "-")) + if b.Terms.Retire != "" { + fmt.Fprintf(&sb, "retire-when: %s\n", b.Terms.Retire) + } + if b.Dangling != "" { + fmt.Fprintf(&sb, "OBLIGATION: a predecessor's claim on %s is unresolved — yield, complete or abandon it before claiming anything\n", b.Dangling) + } + if b.Active != "" { + fmt.Fprintf(&sb, "active: %s\n", b.Active) + } + if len(b.Held) > 0 { + works := make([]string, 0, len(b.Held)) + for _, a := range b.Held { + works = append(works, a.Work) + } + fmt.Fprintf(&sb, "held (%d): %s\n", len(works), strings.Join(works, " · ")) + } + if len(b.OpenIntents) > 0 { + fmt.Fprintf(&sb, "open effects (%d): %s — resolve before claiming\n", len(b.OpenIntents), strings.Join(b.OpenIntents, " · ")) + } + if len(b.OpenEscalations) > 0 { + fmt.Fprintf(&sb, "open escalations (%d): %s\n", len(b.OpenEscalations), strings.Join(shortAll(b.OpenEscalations), " · ")) + } + if b.NextDue != "" { + mark := "" + if b.Late { + mark = " (LATE — the last writer is past its own deadline)" + } + fmt.Fprintf(&sb, "next-due: %s%s\n", b.NextDue, mark) + } + if b.LastWord != nil { + fmt.Fprintf(&sb, "last word: %s @ seq %d, %s", b.LastWord.Kind, b.LastWord.Seq, b.LastWord.At) + switch { + case b.LastWord.Erased: + sb.WriteString(" (body erased)\n") + case b.LastWord.Excerpt != "": + fmt.Fprintf(&sb, "\n> %s\n", strings.ReplaceAll(strings.TrimSpace(b.LastWord.Excerpt), "\n", "\n> ")) + default: + fmt.Fprintf(&sb, " (body %s — read it with: org blob %s)\n", short(b.LastWord.Digest), short(b.LastWord.Digest)) + } + } + if b.Degraded { + sb.WriteString("degraded: the tip is a mechanical mark, not a distilled checkpoint — this resume is thinner than it looks\n") + } + return sb.String() +} + +// Row is one role on the board. +type Row struct { + Tenant string `json:"tenant"` + Role string `json:"role"` + Phase org.Phase `json:"phase"` + Active string `json:"active,omitempty"` + Held int `json:"held"` + Open int `json:"open"` + Late bool `json:"late,omitempty"` + Seq int64 `json:"seq"` +} + +// NewRow summarizes one folded role for the board. +func NewRow(state org.RoleState, now time.Time) Row { + open := len(state.OpenIntents) + len(state.OpenEscalations) + if state.Dangling != "" { + open++ + } + return Row{ + Tenant: state.Tenant, Role: state.Role, Phase: state.Phase, + Active: state.Active, Held: len(state.Held), Open: open, + Late: late(state.NextDue, now), Seq: state.Seq, + } +} + +// Board renders rows as an aligned text table. +func Board(rows []Row) string { + var sb strings.Builder + fmt.Fprintf(&sb, "%-8s %-32s %-9s %-36s %4s %4s %s\n", + "TENANT", "ROLE", "PHASE", "ACTIVE", "HELD", "OPEN", "LIVENESS") + for _, r := range rows { + liveness := "ok" + if r.Late { + liveness = "LATE" + } + fmt.Fprintf(&sb, "%-8s %-32s %-9s %-36s %4d %4d %s\n", + r.Tenant, r.Role, r.Phase, orDash(r.Active), r.Held, r.Open, liveness) + } + return sb.String() +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} + +func joinOr(ss []string, empty string) string { + if len(ss) == 0 { + return empty + } + return strings.Join(ss, ", ") +} + +func shortOr(s, empty string) string { + if s == "" { + return empty + } + return short(s) +} + +func shortAll(ss []string) []string { + out := make([]string, 0, len(ss)) + for _, s := range ss { + out = append(out, short(s)) + } + return out +} + +func short(digest string) string { + if len(digest) <= 14 { + return digest + } + return digest[:14] + "…" +} diff --git a/cmd/org/internal/render/render_test.go b/cmd/org/internal/render/render_test.go new file mode 100644 index 00000000..0758c43d --- /dev/null +++ b/cmd/org/internal/render/render_test.go @@ -0,0 +1,127 @@ +package render + +import ( + "strings" + "testing" + "time" + + "github.com/itsHabib/workbench/contracts/org" +) + +var now = time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC) + +func state() org.RoleState { + return org.RoleState{ + Tenant: "acme", Role: "lead:platform", Phase: org.PhaseHeld, + Seq: 8, Tip: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Holder: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + Terms: org.Terms{Scope: []string{"github:acme/api"}, Tier: "T2", Supervisors: []string{"human:op"}}, + Held: []org.Assignment{ + {Work: "github:acme/api#88"}, {Work: "jira:PROJ-412"}, + }, + } +} + +type blobs map[string]string + +func (b blobs) Blob(digest string) ([]byte, bool, error) { + body, ok := b[digest] + return []byte(body), ok, nil +} + +// TestBootLeadsWithObligation proves the dangling claim renders before +// anything shedable: it is the one line a skimming session must not miss. +func TestBootLeadsWithObligation(t *testing.T) { + s := state() + s.Dangling = "github:acme/api#88" + b, err := NewBoot(s, nil, blobs{}, now) + if err != nil { + t.Fatalf("boot: %v", err) + } + text := b.Text(2048) + if !strings.Contains(text, "OBLIGATION: a predecessor's claim on github:acme/api#88") { + t.Fatalf("boot text lacks the obligation line:\n%s", text) + } + tiny := b.Text(220) + if !strings.Contains(tiny, "OBLIGATION") { + t.Fatalf("shedding dropped the obligation line:\n%s", tiny) + } +} + +// TestBootShedsDepthNotHeadline proves the budget is met by shrinking the last +// word and the held list, never the headline or charter. +func TestBootShedsDepthNotHeadline(t *testing.T) { + records := []org.Record{{ + Seq: 8, Kind: org.KindCheckpoint, KindClass: org.ClassAdvisory, + At: "2026-08-24T11:00:00Z", BodyDigest: "sha256:cccc", + }} + store := blobs{"sha256:cccc": strings.Repeat("a long conclusion. ", 200)} + b, err := NewBoot(state(), records, store, now) + if err != nil { + t.Fatalf("boot: %v", err) + } + full := b.Text(1 << 20) + if !strings.Contains(full, "a long conclusion.") { + t.Fatalf("full boot lacks the last word body:\n%s", full) + } + small := b.Text(400) + if len(small) > 400 { + t.Fatalf("budget 400 produced %d bytes", len(small)) + } + for _, keep := range []string{"# baton boot", "charter: tier T2", "phase: held"} { + if !strings.Contains(small, keep) { + t.Fatalf("shedding dropped %q:\n%s", keep, small) + } + } +} + +// TestBootRendersErasedBody proves an erased blob is said out loud rather than +// silently missing. +func TestBootRendersErasedBody(t *testing.T) { + records := []org.Record{{ + Seq: 8, Kind: org.KindCheckpoint, KindClass: org.ClassAdvisory, + At: "2026-08-24T11:00:00Z", BodyDigest: "sha256:gone", + }} + b, err := NewBoot(state(), records, blobs{}, now) + if err != nil { + t.Fatalf("boot: %v", err) + } + if !strings.Contains(b.Text(2048), "(body erased)") { + t.Fatalf("erased body not rendered:\n%s", b.Text(2048)) + } +} + +// TestLateDerivation proves liveness is derived from the declared deadline, +// and that a garbled deadline reads as dead rather than immortal. +func TestLateDerivation(t *testing.T) { + cases := []struct { + nextDue string + want bool + }{ + {"", false}, + {now.Add(time.Hour).Format(time.RFC3339), false}, + {now.Add(-time.Hour).Format(time.RFC3339), true}, + {"not-a-timestamp", true}, + } + for _, c := range cases { + if got := late(c.nextDue, now); got != c.want { + t.Fatalf("late(%q) = %v, want %v", c.nextDue, got, c.want) + } + } +} + +// TestRowCountsObligations proves the board's OPEN column counts intents, +// escalations and a dangling claim together — everything a human must look at. +func TestRowCountsObligations(t *testing.T) { + s := state() + s.Dangling = "github:acme/api#88" + s.OpenIntents = []string{"effect-1"} + s.OpenEscalations = []string{"sha256:dddd"} + row := NewRow(s, now) + if row.Open != 3 { + t.Fatalf("open = %d, want 3", row.Open) + } + if !strings.Contains(Board([]Row{row}), "lead:platform") { + t.Fatalf("board lacks the role row") + } +} diff --git a/cmd/org/main.go b/cmd/org/main.go new file mode 100644 index 00000000..9a58eb6a --- /dev/null +++ b/cmd/org/main.go @@ -0,0 +1,493 @@ +// Command org is the Baton home: the runtime that keeps role continuity +// chains on disk and lets sessions act as roles. +// +// The kernel — what a role is, which record may extend a chain, what the fold +// means — lives in contracts/org and is imported as types and laws, never +// wrapped or re-decided here. This binary owns the three things a pure kernel +// cannot: WHERE chains live (a state directory of JSONL files + content- +// addressed blobs), WHEN a record is stamped (the home's clock, the home's +// lock), and HOW a fresh session re-enters (the boot index, byte-capped for +// injection). +// +// Verbs map one-to-one onto record kinds; a session's lifecycle is: +// +// org attach -role lead:x # become the incarnation (refused if held) +// org boot -role lead:x # the index a session starts from +// org claim -role lead:x -work dossier:org/p1/t3 +// org note -role lead:x -body "found the bug in ..." +// org yield -role lead:x -work dossier:org/p1/t3 -body "..." +// org release -role lead:x # hand the role back cleanly +// +// Exit codes are a load-bearing seam: 0 ok · 1 the kernel refused the record +// (stderr carries the reason id) · 2 usage · 4 error. A refusal is not an +// error: it is the substrate doing its one job. +package main + +import ( + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/itsHabib/workbench/cmd/org/internal/home" + "github.com/itsHabib/workbench/cmd/org/internal/render" + "github.com/itsHabib/workbench/contracts/org" +) + +const ( + codeOK = 0 + codeRefused = 1 + codeUsage = 2 + codeError = 4 +) + +func main() { os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } + +// verbs maps each verb to its handler. Write verbs append exactly one record; +// read verbs never take the lock. +var verbs = map[string]func(*env, []string) error{ + "charter": cmdCharter, + "attach": cmdAttach, + "assign": cmdAssign, + "unassign": cmdWork(org.KindUnassign), + "claim": cmdWork(org.KindClaim), + "yield": cmdWork(org.KindYield), + "complete": cmdWork(org.KindComplete), + "abandon": cmdWork(org.KindAbandon), + "release": cmdBare(org.KindRelease), + "retire": cmdBare(org.KindRetire), + "seal": cmdBare(org.KindSeal), + "takeover": cmdParty(org.KindTakeover), + "revoke": cmdParty(org.KindRevoke), + "delegate": cmdParty(org.KindDelegate), + "intent": cmdIntent, + "resolve": cmdResolve, + "escalate": cmdBare(org.KindEscalation), + "note": cmdAdvisory(org.KindNote), + "mark": cmdAdvisory(org.KindMark), + "checkpoint": cmdAdvisory(org.KindCheckpoint), + "report": cmdAdvisory(org.KindReport), + "message": cmdAdvisory(org.KindMessage), + "boot": cmdBoot, + "status": cmdStatus, + "log": cmdLog, + "verify": cmdVerify, + "blob": cmdBlob, +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) < 1 { + usage(stderr) + return codeUsage + } + cmd, ok := verbs[args[0]] + if !ok { + fmt.Fprintf(stderr, "org: unknown verb %q\n", args[0]) + usage(stderr) + return codeUsage + } + e := &env{stdin: stdin, stdout: stdout, stderr: stderr} + err := cmd(e, args[1:]) + if err == nil { + return codeOK + } + if org.RefusalReason(err) != "" { + // The kernel's refusal message already names the reason id; print it + // verbatim so scripted callers grep one string. + fmt.Fprintf(stderr, "%v\n", err) + return codeRefused + } + fmt.Fprintf(stderr, "org: %v\n", err) + return codeError +} + +func usage(w io.Writer) { + fmt.Fprintln(w, `usage: org [flags] + +lifecycle charter · attach · release · retire · takeover · revoke · delegate +work assign · unassign · claim · yield · complete · abandon +obligations intent · resolve · escalate · seal +narrative note · mark · checkpoint · report · message (-body "…" | -body -) +read boot · status · log · verify · blob + +every verb: -state (or ORG_STATE) · -tenant (or ORG_TENANT) · -role `) +} + +// env carries the streams so every handler is testable without the process. +type env struct { + stdin io.Reader + stdout, stderr io.Writer +} + +// scope is the flag set every verb shares: which home, which chain. +type scope struct { + fs *flag.FlagSet + state string + tenant string + role string +} + +func newScope(name string) *scope { + s := &scope{fs: flag.NewFlagSet(name, flag.ContinueOnError)} + 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") + return s +} + +func defaultState() string { + home, err := os.UserHomeDir() + if err != nil { + return "org-state" + } + return filepath.Join(home, "dev", "org", "state") +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func (s *scope) open(args []string, needRole bool) (*home.Home, error) { + if err := s.fs.Parse(args); err != nil { + return nil, err + } + if needRole && s.role == "" { + return nil, fmt.Errorf("-role is required") + } + return home.Open(s.state) +} + +// body reads a -body flag value: "-" is stdin, anything else is literal. +func body(e *env, v string) ([]byte, error) { + if v == "" { + return nil, nil + } + if v != "-" { + return []byte(v), nil + } + b, err := io.ReadAll(e.stdin) + if err != nil { + return nil, fmt.Errorf("read body from stdin: %w", err) + } + 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. +func appendAndReport(e *env, h *home.Home, s *scope, d home.Draft) error { + r, state, err := h.Append(s.tenant, s.role, d) + if err != nil { + return err + } + digest, err := org.DigestOf(r) + if err != nil { + return err + } + fmt.Fprintf(e.stdout, "%s seq %d %s (phase %s)\n", r.Kind, r.Seq, digest, state.Phase) + return nil +} + +func cmdCharter(e *env, args []string) error { + s := newScope("charter") + var scopes, supervisors, effects multi + tier := s.fs.String("tier", "", "risk tier ceiling, e.g. T2") + retire := s.fs.String("retire-when", "", "the condition under which this role retires") + spend := s.fs.Int64("spend-ceiling", 0, "spend ceiling") + cycles := s.fs.Int64("cycle-ceiling", 0, "review-cycle ceiling") + concurrency := s.fs.Int64("concurrency-ceiling", 0, "concurrent incarnation ceiling") + s.fs.Var(&scopes, "scope", "work reference this role owns (repeatable)") + s.fs.Var(&supervisors, "supervisor", "role that may take this one over (repeatable)") + s.fs.Var(&effects, "effect-class", "effect class this role may perform (repeatable)") + h, err := s.open(args, true) + if err != nil { + return err + } + return appendAndReport(e, h, s, home.Draft{ + Kind: org.KindCharter, + Terms: &org.Terms{ + Scope: scopes, Tier: *tier, Supervisors: supervisors, + EffectClasses: effects, Retire: *retire, + SpendCeiling: *spend, CycleCeiling: *cycles, ConcurrencyCeiling: *concurrency, + MinReader: 1, + }, + }) +} + +func cmdAttach(e *env, args []string) error { + s := newScope("attach") + due := s.fs.Duration("next-due", 0, "declare the next append deadline, e.g. 90m") + h, err := s.open(args, true) + if err != nil { + return err + } + d := home.Draft{Kind: org.KindAttach} + if *due > 0 { + d.NextDue = time.Now().Add(*due) + } + r, state, err := h.Append(s.tenant, s.role, d) + if err != nil { + return err + } + fmt.Fprintf(e.stdout, "attached: incarnation %s seq %d (phase %s)\n", state.Holder, r.Seq, state.Phase) + return nil +} + +func cmdAssign(e *env, args []string) error { + s := newScope("assign") + work := s.fs.String("work", "", "work URI, e.g. dossier:org/p1/t3 or github:owner/repo#88") + digest := s.fs.String("digest", "", "content digest pinning the work item (sha256:…)") + pin := s.fs.String("pin", "", "text to pin instead of -digest; its sha256 becomes the digest") + party := s.fs.String("party", "", "assignee role, empty when self-assigned") + h, err := s.open(args, true) + if err != nil { + return err + } + if *digest == "" && *pin == "" { + return fmt.Errorf("-digest or -pin is required: an unpinned assignment cannot detect drift") + } + if *digest == "" { + *digest = org.DigestBytes([]byte(*pin)) + } + return appendAndReport(e, h, s, home.Draft{ + Kind: org.KindAssign, + Subject: org.Subject{Work: *work, Digest: *digest, Party: *party}, + }) +} + +// cmdWork covers the kinds whose subject is one work URI: claim and its +// terminals, and unassign. Terminals accept an optional narrative body. +func cmdWork(kind string) func(*env, []string) error { + return func(e *env, args []string) error { + s := newScope(kind) + work := s.fs.String("work", "", "work URI") + text := s.fs.String("body", "", `narrative body ("-" reads stdin)`) + due := s.fs.Duration("next-due", 0, "declare the next append deadline") + h, err := s.open(args, true) + if err != nil { + return err + } + if *work == "" { + return fmt.Errorf("-work is required") + } + b, err := body(e, *text) + if err != nil { + return err + } + d := home.Draft{Kind: kind, Subject: org.Subject{Work: *work}, Body: b} + if *due > 0 { + d.NextDue = time.Now().Add(*due) + } + return appendAndReport(e, h, s, d) + } +} + +// cmdBare covers the kinds with no subject: release, retire, seal, escalation. +func cmdBare(kind string) func(*env, []string) error { + return func(e *env, args []string) error { + s := newScope(kind) + text := s.fs.String("body", "", `narrative body ("-" reads stdin)`) + h, err := s.open(args, true) + if err != nil { + return err + } + b, err := body(e, *text) + if err != nil { + return err + } + return appendAndReport(e, h, s, home.Draft{Kind: kind, Body: b}) + } +} + +// cmdParty covers the kinds whose subject is another role: takeover, revoke, +// delegate. +func cmdParty(kind string) func(*env, []string) error { + return func(e *env, args []string) error { + s := newScope(kind) + party := s.fs.String("party", "", "the other role: supervisor on takeover, child on delegate") + h, err := s.open(args, true) + if err != nil { + return err + } + return appendAndReport(e, h, s, home.Draft{Kind: kind, Subject: org.Subject{Party: *party}}) + } +} + +func cmdIntent(e *env, args []string) error { + s := newScope("intent") + effect := s.fs.String("effect", "", "effect id being opened") + h, err := s.open(args, true) + if err != nil { + return err + } + if *effect == "" { + return fmt.Errorf("-effect is required") + } + return appendAndReport(e, h, s, home.Draft{Kind: org.KindIntentRef, Subject: org.Subject{Effect: *effect}}) +} + +func cmdResolve(e *env, args []string) error { + s := newScope("resolve") + effect := s.fs.String("effect", "", "open effect id to close") + target := s.fs.String("target", "", "open escalation digest to close") + text := s.fs.String("body", "", `narrative body ("-" reads stdin)`) + h, err := s.open(args, true) + if err != nil { + return err + } + b, err := body(e, *text) + if err != nil { + return err + } + return appendAndReport(e, h, s, home.Draft{ + Kind: org.KindResolution, Subject: org.Subject{Effect: *effect, Target: *target}, Body: b, + }) +} + +// cmdAdvisory covers narrative kinds. Body is required: an advisory record +// with nothing to say is not worth a chain position. +func cmdAdvisory(kind string) func(*env, []string) error { + return func(e *env, args []string) error { + s := newScope(kind) + text := s.fs.String("body", "", `the narrative ("-" reads stdin)`) + class := s.fs.String("body-class", "narrative", "blob retention class") + due := s.fs.Duration("next-due", 0, "declare the next append deadline") + h, err := s.open(args, true) + if err != nil { + return err + } + b, err := body(e, *text) + if err != nil { + return err + } + if len(b) == 0 { + return fmt.Errorf("-body is required for %s", kind) + } + d := home.Draft{Kind: kind, Body: b, BodyClass: *class} + if *due > 0 { + d.NextDue = time.Now().Add(*due) + } + return appendAndReport(e, h, s, d) + } +} + +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") + h, err := s.open(args, true) + if err != nil { + return err + } + records, state, err := h.Load(s.tenant, s.role) + if err != nil { + return err + } + if state.Phase == org.PhaseVoid { + return fmt.Errorf("no chain for %s/%s under %s", s.tenant, s.role, h.Root()) + } + b, err := render.NewBoot(state, records, h, time.Now()) + if err != nil { + return err + } + if !*asJSON { + fmt.Fprint(e.stdout, b.Text(*budget)) + return nil + } + out, err := b.JSON() + if err != nil { + return err + } + fmt.Fprintln(e.stdout, string(out)) + return nil +} + +func cmdStatus(e *env, args []string) error { + s := newScope("status") + h, err := s.open(args, false) + if err != nil { + return err + } + pairs, err := h.Roles() + if err != nil { + return err + } + if len(pairs) == 0 { + fmt.Fprintln(e.stdout, "no roles chartered") + return nil + } + rows := make([]render.Row, 0, len(pairs)) + for _, p := range pairs { + _, state, err := h.Load(p[0], p[1]) + if err != nil { + return err + } + rows = append(rows, render.NewRow(state, time.Now())) + } + fmt.Fprint(e.stdout, render.Board(rows)) + return nil +} + +func cmdLog(e *env, args []string) error { + s := newScope("log") + n := s.fs.Int("n", 20, "records to show, newest last") + h, err := s.open(args, true) + if err != nil { + return err + } + records, _, err := h.Load(s.tenant, s.role) + if err != nil { + return err + } + start := max(0, len(records)-*n) + for _, r := range records[start:] { + subject := strings.TrimSpace(strings.Join([]string{r.Subject.Work, r.Subject.Party, r.Subject.Effect}, " ")) + fmt.Fprintf(e.stdout, "%4d %-11s %-10s %s\n", r.Seq, r.Kind, r.At, subject) + } + return nil +} + +func cmdVerify(e *env, args []string) error { + s := newScope("verify") + h, err := s.open(args, true) + if err != nil { + return err + } + records, state, err := h.Load(s.tenant, s.role) + if err != nil { + return err + } + fmt.Fprintf(e.stdout, "ok: %d records fold to phase %s, tip %s\n", len(records), state.Phase, state.Tip) + return nil +} + +func cmdBlob(e *env, args []string) error { + s := newScope("blob") + h, err := s.open(args, false) + if err != nil { + return err + } + if s.fs.Arg(0) == "" { + return fmt.Errorf("usage: org blob ") + } + bodyBytes, found, err := h.Blob(s.fs.Arg(0)) + if err != nil { + return err + } + if !found { + return fmt.Errorf("blob %s is erased or unknown", s.fs.Arg(0)) + } + _, err = e.stdout.Write(bodyBytes) + return err +} + +// multi is a repeatable string flag. +type multi []string + +func (m *multi) String() string { return strings.Join(*m, ",") } +func (m *multi) Set(v string) error { *m = append(*m, v); return nil } diff --git a/cmd/org/main_test.go b/cmd/org/main_test.go new file mode 100644 index 00000000..14321a8d --- /dev/null +++ b/cmd/org/main_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +// exec runs one verb against a state dir and returns exit code and streams. +func exec(t *testing.T, state string, args ...string) (int, string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + full := append(args, "-state", state) + code := run(full, strings.NewReader(""), &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +// TestVerbLoopExitCodes drives the CLI end to end through a temp state dir and +// pins the exit-code seam: 0 ok, 1 kernel refusal, 2 usage. +func TestVerbLoopExitCodes(t *testing.T) { + state := t.TempDir() + role := []string{"-tenant", "acme", "-role", "lead:platform"} + step := func(wantCode int, args ...string) (string, string) { + t.Helper() + code, out, errOut := exec(t, state, append(args, role...)...) + if code != wantCode { + t.Fatalf("%v: exit %d, want %d (stderr: %s)", args, code, wantCode, errOut) + } + return out, errOut + } + + step(0, "charter", "-scope", "github:acme/api", "-tier", "T2", "-supervisor", "human:op") + step(0, "attach") + step(0, "assign", "-work", "github:acme/api#88", "-pin", "the ticket body") + step(0, "claim", "-work", "github:acme/api#88") + step(0, "checkpoint", "-body", "half way through") + step(0, "yield", "-work", "github:acme/api#88") + + // A refusal is exit 1 and names the kernel's reason on stderr. + _, errOut := step(1, "claim", "-work", "jira:NOPE-1") + if !strings.Contains(errOut, "work_not_held") { + t.Fatalf("refusal stderr lacks the reason id: %s", errOut) + } + + out, _ := step(0, "boot") + for _, want := range []string{"# baton boot — lead:platform @ acme", "held (1)", "half way through"} { + if !strings.Contains(out, want) { + t.Fatalf("boot lacks %q:\n%s", want, out) + } + } + + out, _ = step(0, "status") + if !strings.Contains(out, "lead:platform") { + t.Fatalf("status lacks the role:\n%s", out) + } + + step(0, "verify") + + if code, _, _ := exec(t, state, "nonsense"); code != codeUsage { + t.Fatalf("unknown verb: exit %d, want %d", code, codeUsage) + } +} + +// TestBootRefusesVoidChain pins the empty-chain read: booting a role that was +// never chartered is an error, not an empty index. +func TestBootRefusesVoidChain(t *testing.T) { + code, _, errOut := exec(t, t.TempDir(), "boot", "-tenant", "acme", "-role", "lead:ghost") + if code != codeError { + t.Fatalf("exit %d, want %d", code, codeError) + } + if !strings.Contains(errOut, "no chain") { + t.Fatalf("stderr: %s", errOut) + } +}