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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 68 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,21 +201,21 @@ pitot doctor

## Quickstart

**1. Scaffold a Controller.** `pitot init` writes a runnable project — source, a
package manifest, and `.pitot.yaml` — and never overwrites existing files unless
you pass `--force`. Pick a starting template with `--template`:
**1. Scaffold a Controller.** `pitot init` writes a runnable project — source
and a package manifest — and registers it as one tenant fragment under
`.pitot/conf.d/`. It never overwrites existing files unless you pass `--force`.
Pick a starting template with `--template`:

```bash
pitot init --template shell-policy --language go --dir ./kimi-policy
pitot init --template shell-policy --language go --dir kimi-policy
```

```
Initialized go controller (shell-policy) in ./kimi-policy
Files written: .pitot.yaml, go.mod, main.go
Initialized go controller (shell-policy) in kimi-policy
Files written: .pitot/conf.d/kimi-policy.yaml, kimi-policy/go.mod, kimi-policy/main.go
Next:
1. cd ./kimi-policy
2. Configure a supported host hook (see: pitot doctor --host HOST).
3. Run: pitot dev --host HOST -- AGENT [ARGS...]
1. Configure a supported host hook (see: pitot doctor --host HOST).
2. Run: pitot dev --host HOST -- AGENT [ARGS...]
example: pitot dev --host kimi -- kimi -p "<prompt>"
```

Expand All @@ -226,10 +226,11 @@ detects the language from the current directory or prompts you to choose. The
four first-class languages (`python`, `typescript`, `go`, `rust`) each generate a
complete project that builds after installing dependencies.

**2. Run your agent behind it.** `pitot dev` starts the runtime and the
Controllers declared in `.pitot.yaml`, waits until the runtime is ready, then
launches the agent you name after `--` with `PITOT_RUNTIME` set so its host hook
finds the runtime. It prints each decision as the agent makes it:
**2. Run your agent behind it.** `pitot dev` discovers every fragment under
`.pitot/conf.d/`, starts the runtime and the declared Controllers, waits until
the runtime is ready, then launches the agent you name after `--` with
`PITOT_RUNTIME` set so its host hook finds the runtime. It prints each decision
as the agent makes it:

```bash
pitot dev --host kimi -- kimi -p "Run: PITOT_DENY_ME=1 echo nope"
Expand All @@ -250,16 +251,61 @@ already be wired to `pitot hook HOST` (see **Connect your agent** and
temporary path and is removed on exit, so concurrent `pitot dev` sessions never
collide.

**3. Swap the agent.** The same project — the same Controller and `.pitot.yaml` —
works with any other supported host whose hook is wired. Change only `--host` and
the agent command:
**3. Swap the agent.** The same project — the same Controller and the same
fragment — works with any other supported host whose hook is wired. Change only
`--host` and the agent command:

```bash
pitot dev --host cursor -- cursor-agent -p "Run: PITOT_DENY_ME=1 echo nope"
```

The boundary is language- and agent-neutral: one Controller, every agent.

## Multiple tools, one Pitot

Configuration is tenant-partitioned: every tool or person that registers
processes with Pitot owns exactly one fragment in `.pitot/conf.d/`, and no
tenant ever edits another tenant's file. The effective configuration is the
deterministic merge of all fragments in filename order:

```
.pitot/
conf.d/
boatstack.yaml # a tool's controller, written by its installer
interlock.yaml # another tool's controller, different request kind
my-policy.yaml # your own, scaffolded by `pitot init`
```

Each fragment is a complete, strictly parsed mini-config declaring
`controllers:` and/or `consumers:`. Merge rules:

- **Consumers always compose.** Any number of tenants can observe
`action.requested` events.
- **A request kind has one owner.** Two fragments claiming the same kind (for
example `shell`) fail discovery with an error naming both files — a loud,
attributable conflict instead of two tools silently fighting over one
blocking hook. Controller and consumer ids must also be unique across
fragments.
- **`requires_protocol: "1"`** optionally pins the protocol version a fragment
was written against; a fragment this binary cannot honor fails discovery.
- **`dir:`** sets a process's working directory (relative to the repository
root), so each tenant's command stays project-relative:

```yaml
controllers:
shell:
id: local-shell-policy
command: ["go", "run", "main.go"]
dir: "kimi-policy"
deadline_ms: 2000
on_timeout: deny
on_unavailable: deny
```

Installing a second Pitot-based tool is therefore additive by construction: it
drops its own fragment next to yours, `pitot run`/`pitot dev` merge them, and
uninstalling it is deleting its fragment.

## Advanced: manual runtime

`pitot dev` is the recommended path. If you need to manage the runtime yourself
Expand All @@ -269,9 +315,13 @@ descriptor:

```bash
export PITOT_RUNTIME="${XDG_RUNTIME_DIR:-$TMPDIR}/pitot/project.json"
pitot run --config .pitot.yaml --runtime "$PITOT_RUNTIME"
pitot run --runtime "$PITOT_RUNTIME"
```

With no `--config`, `pitot run` discovers and merges the repository's
`.pitot/conf.d/` fragments. Pass `--config PATH` to override discovery with one
explicit file (useful for tests and ad-hoc runtimes).

Start coding-agent CLIs from the same environment. Their `pitot hook HOST`
commands discover the authenticated runtime through `PITOT_RUNTIME`. Without
that variable or `--runtime PATH`, hooks remain observation-only for backwards
Expand All @@ -282,7 +332,7 @@ On Windows, set the descriptor in the launching PowerShell session:

```powershell
$env:PITOT_RUNTIME = Join-Path $env:LOCALAPPDATA "Pitot\project.json"
pitot run --config .pitot.yaml --runtime $env:PITOT_RUNTIME
pitot run --runtime $env:PITOT_RUNTIME
```

## Supported hosts
Expand Down
27 changes: 14 additions & 13 deletions UPSTREAM.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"files": {
"CONTRIBUTING.md": "23728d8a132d62b8adfb2e5c3eb9d9bfcf8a4d04543765b1e22ad8d55424af8f",
"README.md": "bd662302b629066b630dbb4c274174a4df61e98ab699417b481d7a48590de40b",
"README.md": "e4a7587107f7db1eb75859f62f6abfb780c99e2b210c808dda3aaa25612eb172",
"adapter-verification.json": "f8ad4e206571650f698826a8b66d8c00822be425e8d2de8ae98d98239e575eb4",
"adapters/adapters.go": "1b46ba131fa3b2c93eed23526330275a3506451ba4bbd4f497e5378dfab2b6a8",
"assets/pitot-boundary.png": "8a0ddb7d81831d94e14813f50ea4ca8670d77417f339ed2f91f0c653bf52f41d",
Expand All @@ -16,17 +16,18 @@
"cmd/generate-schema/main.go": "6e9d0030290d99e36967433f96e38385a122974f899ad9421aac1ef7e50d8fcb",
"cmd/pitot/doctor_host.go": "7ecade40618bfb3510ae8e55fa802361371b4f7fbafedcd61233d19ef46cb219",
"cmd/pitot/doctor_host_test.go": "4e6e327f6cf27cf94a0a608e10eb6790d6c11fcd53e6dfd7370007190749952f",
"cmd/pitot/kimi_control_test.go": "b3803a9bbdecf5f7e9a3dca90e317bdb94b4ba9bfa21d369d5a152d6742eac63",
"cmd/pitot/kimi_smoke_test.go": "6c2b92a8d3257955617d1387bc3f788846e0be091742c074a762f2b5cd04fbdf",
"cmd/pitot/main.go": "27d00919d7cc687e2b58c930024aba2ae7009a768af0abd9e73abfe123f1c8c3",
"cmd/pitot/main_test.go": "544997295e0c4b75ef8f3d698b3de0883153f671b6b8f62057cc6e3452d6dc93",
"cmd/pitot/workbench.go": "70497ca0fd5579d8c1df5350e037cb46096038449293814f995b8c209a99b215",
"cmd/pitot/workbench_build_test.go": "8e9ae497a03c9f1ca0a1fc9ebf821df3a1869993cbb2cd568da3996d437551ec",
"cmd/pitot/workbench_contract_test.go": "f88ba5a34d16d1a18fd2a4fed54c7b4cbb62fb3ffbd0eb6091e2143ba89234c5",
"cmd/pitot/workbench_dev_test.go": "9693e84f24facd7d97cefcc92d95c0e6950d9422a7c23f8b07487bbd35df2eac",
"cmd/pitot/workbench_test.go": "457caa11cd4b73c1fb4e0dad806b3050b196ddac690a8125f1615a4c695cc073",
"config/config.go": "e6666567d0c0cca41de69361e8f1243adda1ec0a54a9300b39a84d2290bff319",
"cmd/pitot/kimi_control_test.go": "27b38867d4799636a664e3b1726ef55568f5dfd6a9be11f0ce5eca9931d759a4",
"cmd/pitot/kimi_smoke_test.go": "01cbf18312902cac42ec1f2547d35362c2bc920938acea508ba7c7f0638a9473",
"cmd/pitot/main.go": "9baae94a571c20b7c2d4e11985026ebaa8fe256372ce70c3198a73cc82d8390b",
"cmd/pitot/main_test.go": "b381fe30dafe3299c82fe23e1899b64f79c4a2b27059b78823179625e3b6679e",
"cmd/pitot/workbench.go": "976c81951c565da1a897637566f662c493b2fe6d279debe852dc783b6fac1db4",
"cmd/pitot/workbench_build_test.go": "8d5c5c35e8cbd59e21cfdd7e206d6b5b769892ab27a99fe5d87b13b1a31d0714",
"cmd/pitot/workbench_contract_test.go": "5e465f3d3f8b93ffaacfb4738279d369b13514bf7613b8611952f3cd26896586",
"cmd/pitot/workbench_dev_test.go": "abffe81e1a25f086d7f3c2f1c32986ee93618bbfc87220bdfda840ec6c6466bf",
"cmd/pitot/workbench_test.go": "3c561498dfee4aacf6935fe4b0bfe4449c3961c169c04f1f13e81a38b22d0914",
"config/config.go": "fa734117191ab941cfa92db82a9a121368fd604d7e556649c1b14b924b20b7f7",
"config/config_test.go": "87d3e5ddc4a3b43c736070de671d03e03ffe29cdd759771526ad27fd9bc0034c",
"config/merge_test.go": "595d2c96ac879cca7c57b77b99504f34629b8aa55b38d515f35ad76c006ef532",
"conformance/conformance.go": "43b692114f45c8b52958e34b35aee1cee339d8321c90f92ab4f5b963e79935bb",
"conformance/conformance_test.go": "83ab0bcc15371265a954d177e4e97d81ad3ea734bbf736a29a54628ef64b52cd",
"conformance/fixtures/negative.jsonl": "503ea76988df595d96ebf695f991b8ea6c892be4a578522dff4ddb0d39b647e4",
Expand Down Expand Up @@ -59,7 +60,7 @@
"runtime/descriptor_windows.go": "2d9ffefe3af0154fa8042de6b67460d4e86dd3f4cdd9e986f180f7d0c535c9a5",
"runtime/request.go": "198c44fd6c547022a15b6d0d48e4d0130fa8afb687994365115576e4d874550d",
"runtime/request_test.go": "86d8a2feb4ec72e8ed675b9567da2d1f5d628950eeec907c10b9cc1675aa1904",
"runtime/runtime.go": "e0624a16ac9f79e8080246042912c73b1588f1b9aeb4f9f95b3711c7acef7e81",
"runtime/runtime.go": "b90072bf119c9121e3d185fa27e8ac372ec9dcb33c9f38fea8050c314b9dd5e4",
"runtime/runtime_test.go": "afd78d122af20148bf30d0db873ff002544189df0dfec0f6167b8cf5cd0d42b1",
"runtime/transport.go": "83e2218fb28474e875dafa6943bc5b665acef0565aaf5955fa88b1b4fd21614e",
"runtime/transport_test.go": "9b69f590f1e258470adea249b3ac6d4a00f1001f10bb08dfa7b56c6e2d6709ae",
Expand Down Expand Up @@ -120,7 +121,7 @@
"generator": "operatorstack/pitot:project",
"schema_version": 1,
"source": {
"commit": "44cdd76953435a74daac53faa5917bbb19deb014",
"commit": "4cd27ab7a10e85184157f71249708254fbf7c208",
"path": "labs/15-pitot",
"repository": "operatorstack/intelligence-flow"
}
Expand Down
42 changes: 30 additions & 12 deletions cmd/pitot/kimi_control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,45 @@ import (
func buildGeneratedShellPolicy(t *testing.T) (binPath, configWithBinary string) {
t.Helper()

proj := filepath.Join(t.TempDir(), "shell-policy-proj")
var out, errb bytes.Buffer
if err := runInit([]string{"--language", "go", "--template", "shell-policy", "--dir", proj}, strings.NewReader(""), &out, &errb); err != nil {
t.Fatalf("init shell-policy: %v\n%s", err, errb.String())
// Resolve the in-tree module root (cmd/pitot -> module root) before leaving
// the package directory, so the generated project can resolve the SDK with
// a filesystem replace.
moduleRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}

// The generated config must register the controller under the shell kind.
cfg, err := os.ReadFile(filepath.Join(proj, ".pitot.yaml"))
// Scaffold inside a scratch repository root so the tenant fragment lands in
// a temporary .pitot/conf.d, then restore the caller's working directory.
root := t.TempDir()
previous, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(cfg), "shell:") || !strings.Contains(string(cfg), "local-shell-policy") {
t.Fatalf("generated config does not register the shell-policy controller under shell:\n%s", cfg)
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
defer func() {
if err := os.Chdir(previous); err != nil {
t.Fatal(err)
}
}()

var out, errb bytes.Buffer
if err := runInit([]string{"--language", "go", "--template", "shell-policy", "--dir", "shell-policy-proj"}, strings.NewReader(""), &out, &errb); err != nil {
t.Fatalf("init shell-policy: %v\n%s", err, errb.String())
}
proj := filepath.Join(root, "shell-policy-proj")

// Resolve the in-tree module root (cmd/pitot -> module root) and add a
// filesystem replace so the generated project resolves the SDK locally.
moduleRoot, err := filepath.Abs(filepath.Join("..", ".."))
// The generated fragment must register the controller under the shell kind
// with the tenant-scoped id (the fragment name).
cfg, err := os.ReadFile(filepath.Join(root, ".pitot", "conf.d", "shell-policy-proj.yaml"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(cfg), "shell:") || !strings.Contains(string(cfg), "shell-policy-proj") {
t.Fatalf("generated fragment does not register the shell-policy controller under shell:\n%s", cfg)
}
gomod := filepath.Join(proj, "go.mod")
existing, err := os.ReadFile(gomod)
if err != nil {
Expand All @@ -69,7 +87,7 @@ func buildGeneratedShellPolicy(t *testing.T) (binPath, configWithBinary string)
// per-spawn `go run` compile and any working-directory coupling.
configWithBinary = fmt.Sprintf(`controllers:
shell:
id: local-shell-policy
id: shell-policy-proj
command: [%q]
deadline_ms: 2000
on_timeout: deny
Expand Down
4 changes: 1 addition & 3 deletions cmd/pitot/kimi_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,7 @@ func TestKimiSmokeRealCLI(t *testing.T) {
_, configBody := buildGeneratedShellPolicy(t)

proj := t.TempDir()
if err := os.WriteFile(filepath.Join(proj, ".pitot.yaml"), []byte(configBody), 0o600); err != nil {
t.Fatal(err)
}
writeFragment(t, proj, "shell-policy", configBody)

// A private Kimi home whose config.toml wires the PreToolUse hook to pitot.
kimiHome := t.TempDir()
Expand Down
22 changes: 16 additions & 6 deletions cmd/pitot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,16 +254,22 @@ func runRuntime(ctx context.Context, args []string, stdout, stderr io.Writer) er
return fmt.Errorf("pitot: unexpected argument %q", args[i])
}
}
if configPath == "" {
return errors.New("pitot: run requires --config PATH")
}
if runtimePath == "" {
runtimePath = os.Getenv("PITOT_RUNTIME")
}
if runtimePath == "" {
return errors.New("pitot: run requires --runtime PATH or PITOT_RUNTIME")
}
loaded, err := config.Load(configPath)
var loaded config.Loaded
var err error
if configPath == "" {
loaded, err = config.Discover(".")
if errors.Is(err, config.ErrNoConfig) {
return errors.New("pitot: no config fragments under .pitot/conf.d (run 'pitot init' to register a controller or consumer, or pass --config PATH)")
}
} else {
loaded, err = config.Load(configPath)
}
if err != nil {
return err
}
Expand Down Expand Up @@ -300,12 +306,16 @@ func usage() string {
return `pitot — the open sensor and control transport for coding-agent tooling

usage:
pitot init [--language python|typescript|go|rust] [--role consumer|controller] [--template shell-policy|release-approval|blank-controller|blank-consumer] [--dir PATH] [--force]
pitot init [--language python|typescript|go|rust] [--role consumer|controller] [--template shell-policy|release-approval|blank-controller|blank-consumer] [--dir PATH] [--fragment NAME] [--force]
pitot dev --host HOST -- AGENT [ARGS...]
pitot doctor [--host HOST]
pitot run --config PATH --runtime PATH
pitot run [--config PATH] --runtime PATH
pitot hook HOST [--runtime PATH]
pitot request KIND [--data JSON] --runtime PATH

configuration is tenant-partitioned: each tool or user registers its processes
in its own fragment under .pitot/conf.d/; the runtime merges every fragment and
rejects collisions. --config PATH overrides discovery with one explicit file.
`
}

Expand Down
Loading
Loading