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
62 changes: 49 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,10 +400,38 @@ decision belongs to your Controller.
## Connect your agent

The per-host hooks below wire each agent's native blocking boundary to Pitot.
This wiring is a one-time edit to each host's own configuration; Pitot does not
edit your host config for you. Run `pitot doctor --host HOST` to check whether a
host's hook is correctly configured. Once wired, both `pitot dev` and the manual
runtime flow use the same hook.
Hosts whose hook config lives **in the repository** are wired by Pitot itself:

```bash
pitot init --host claude # also: cursor, codex, gemini
```

That writes exactly one marker-owned entry into the host's repo config
(`.claude/settings.json`, `.cursor/hooks.json`, `.codex/hooks.json`,
`.gemini/settings.json`), pointing at the repo shim — foreign entries are
never touched, and the committed fragment `.pitot/hooks/<host>.fragment.json`
witnesses what was installed. `pitot doctor --host HOST` reports the entry as
FOUND, MISSING, or DRIFTED against that witness; `pitot doctor --host HOST
--fix` restores a drifted entry (Pitot's own entries only — that is the single
mutation doctor ever performs, and only on request).

Hosts configured at **user level** (Kimi, Copilot, Qwen) or via **plugin
files** (OpenCode, Pi) keep the one-time manual edit below — Pitot does not
edit files outside the repository; `pitot init --host kimi` prints the exact
snippet. Once wired, both `pitot dev` and the manual runtime flow use the same
hook.

### Claude Code / Codex

```bash
pitot init --host claude
pitot init --host codex
```

Both wire a `PreToolUse` hook (matcher `Bash`) whose command runs the repo
shim: `"$CLAUDE_PROJECT_DIR"/.pitot/bin/pitot hook claude` (Codex uses the
repo-relative equivalent plus a PowerShell `commandWindows`). Exit `0` allows;
exit `2` blocks with the Controller's reason.

### Kimi Code

Expand Down Expand Up @@ -467,19 +495,27 @@ reason when a Controller rejects the command. See the official

### Cursor

Copy `integrations/cursor/beforeShellExecution` into the repository and point
`.cursor/hooks.json` at it with `failClosed: true`. The bridge returns Cursor's
native `permission: "deny"` envelope, including the Controller message, while
the runtime remains available through `PITOT_RUNTIME`. See Cursor's
```bash
pitot init --host cursor
```

Writes the committed bridge `.pitot/bin/hooks/cursor-beforeShellExecution`
(which execs the repo shim and returns Cursor's native `permission: "deny"`
envelope, including the Controller message) and points `.cursor/hooks.json` at
it with `failClosed: true`. See Cursor's
[hooks documentation](https://cursor.com/docs/agent/hooks).

### Gemini

Copy `integrations/gemini/BeforeTool` to an executable path (or use
`BeforeTool.ps1` on Windows) and register it as a `BeforeTool` command hook for
`run_shell_command`. The bridge translates Pitot rejection into Gemini's
structured `decision: "deny"` and `reason` response so the model receives the
blocked tool result. See the [Gemini CLI hooks reference](https://geminicli.com/docs/hooks/reference/).
```bash
pitot init --host gemini
```

Writes the committed bridge `.pitot/bin/hooks/gemini-BeforeTool` and registers
it as a `BeforeTool` command hook for `run_shell_command`. The bridge
translates Pitot rejection into Gemini's structured `decision: "deny"` and
`reason` response so the model receives the blocked tool result. See the
[Gemini CLI hooks reference](https://geminicli.com/docs/hooks/reference/).

### Qwen Code

Expand Down
25 changes: 15 additions & 10 deletions UPSTREAM.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
".goreleaser.yml": "d375f187624219e9420acd2a40cfae95772ef1fee3abda3e729d6a16ba612032",
"CONTRIBUTING.md": "23728d8a132d62b8adfb2e5c3eb9d9bfcf8a4d04543765b1e22ad8d55424af8f",
"LICENSE": "bd17d47aefaa7406616179288058001b6532881a2517254fe668d7e9c4965cfb",
"README.md": "5788010ce3ba4b7175aab509ff1dc8014f5a4c73ae6e43f07489cdbcedc9e207",
"README.md": "dc3adbcf60ed946537353c1b873a8270c67dde11d7f71cf4cd361d13544f909e",
"adapter-verification.json": "f8ad4e206571650f698826a8b66d8c00822be425e8d2de8ae98d98239e575eb4",
"adapters/adapters.go": "1b46ba131fa3b2c93eed23526330275a3506451ba4bbd4f497e5378dfab2b6a8",
"assets/pitot-boundary.png": "8a0ddb7d81831d94e14813f50ea4ca8670d77417f339ed2f91f0c653bf52f41d",
Expand All @@ -16,24 +16,27 @@
"bridge/bridge.go": "5adfcd3f743cae46e4446a6e030d53464ada97de0261a8588fa2a9fcd62136b8",
"bridge/bridge_test.go": "6dcc6d05f2b39c25955fc0b2d21d3d148dd9d77600fb12799941f86bdb1acb61",
"cmd/generate-schema/main.go": "6e9d0030290d99e36967433f96e38385a122974f899ad9421aac1ef7e50d8fcb",
"cmd/pitot/doctor_host.go": "7ecade40618bfb3510ae8e55fa802361371b4f7fbafedcd61233d19ef46cb219",
"cmd/pitot/doctor_host.go": "3eb21fb3a9b42fa7f2a33ed2ed82ae6de68eb2d084ecef4a9e0dce36e37d8a31",
"cmd/pitot/doctor_host_test.go": "4e6e327f6cf27cf94a0a608e10eb6790d6c11fcd53e6dfd7370007190749952f",
"cmd/pitot/install.go": "46cab87809566f8da47c4d71b8a87618d06cb0829bdabe1c8f734faf53a9f46c",
"cmd/pitot/kimi_control_test.go": "27b38867d4799636a664e3b1726ef55568f5dfd6a9be11f0ce5eca9931d759a4",
"cmd/pitot/kimi_smoke_test.go": "01cbf18312902cac42ec1f2547d35362c2bc920938acea508ba7c7f0638a9473",
"cmd/pitot/main.go": "69d1571a56ae1c024fcd697bb406c3aac0f4774d62b41f3444a949ffe6f4c4d4",
"cmd/pitot/main_test.go": "b381fe30dafe3299c82fe23e1899b64f79c4a2b27059b78823179625e3b6679e",
"cmd/pitot/shim.go": "a2a605b3126d4be7d3e2a587f09259a5f6e808c3a132480a760661cf34c1796f",
"cmd/pitot/main.go": "be069ed59ffe5e8cde26e3b88640dd8ae876fa0c266281ab9ac206be3e8944f1",
"cmd/pitot/main_test.go": "6a97e930a83d15a51713530c092a7e7146c4cb4e4d7eb9f58b97d1577c986c6b",
"cmd/pitot/shim.go": "3a7a4be5bd769010ad947de78188dd17c729388dbf74153d4dbd51fba2121ab1",
"cmd/pitot/substrate_test.go": "b4e7d83aa592f7b5bf4ec059ec552abd62473e51a6634a26428448168898d0a3",
"cmd/pitot/upgrade.go": "3f7adf156b04f9bac4847ed66c5fa69dc0906f482cb1362f60cbf3416a5733fc",
"cmd/pitot/upgrade.go": "2ad6f581318c91c2617e1a65409458cd4de6641c940554b232dbdfd65e0fd221",
"cmd/pitot/version.go": "60ddbabcc8985c5137d5b73e3ba1be0f62fbb9db2a6612027e08314dfd82d618",
"cmd/pitot/workbench.go": "afbba5557974c1a4fb28ed2097ec8bea4d9bb1fa97cda931c9ebf52daec24915",
"cmd/pitot/wire.go": "56c4d410eab4974952c5d7b7c37ad739341ebf292bb21de6143011c0bb1b1f48",
"cmd/pitot/wire_test.go": "66a3c3302780145ebf12da3df1390f61a8d9b82f30b03f6332dd958799e70f7f",
"cmd/pitot/workbench.go": "aa0a270829cdaa2bc7bf84d39a88f703088a9dc1c55057f7a2fd369ba98c9597",
"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.go": "84476c7525833b07222b3b06834ff528f9c496f164bb065d006afad29bbca1fb",
"config/config_test.go": "87d3e5ddc4a3b43c736070de671d03e03ffe29cdd759771526ad27fd9bc0034c",
"config/findroot_test.go": "89d8b31f188325a02bde2dcd77e683c06e9dca4f59c872366c027111147ea8a5",
"config/merge_test.go": "595d2c96ac879cca7c57b77b99504f34629b8aa55b38d515f35ad76c006ef532",
"conformance/conformance.go": "43b692114f45c8b52958e34b35aee1cee339d8321c90f92ab4f5b963e79935bb",
"conformance/conformance_test.go": "83ab0bcc15371265a954d177e4e97d81ad3ea734bbf736a29a54628ef64b52cd",
Expand Down Expand Up @@ -126,12 +129,14 @@
"tests/runtime_capability_driver.py": "54a485fc4f16981f2542d0dbd903a12b80f31f071908a378607f26678aeea07f",
"tests/witness/main.go": "cd56bbd00aa44cc5baf6426c8461a8ebca4a8391518f6acfa2301ac36add7c5f",
"windtunnel/doc.go": "44e0bcde632da73e1f8b98beade3a34ca8e0d0ea79cdfb91d131de290b164fc4",
"windtunnel/windtunnel_test.go": "d34929ffdb1927b2ee27cc79640b333b0571659d231977b91c3a8898cf79bc42"
"windtunnel/windtunnel_test.go": "d34929ffdb1927b2ee27cc79640b333b0571659d231977b91c3a8898cf79bc42",
"wiring/wiring.go": "2cdf286fb26def02c3bad1d6c7e7e4419d366c732faf55ad3bb02a4ae127acdf",
"wiring/wiring_test.go": "ade8b847ecdcbee17439e27d186c7078f39490716df5b56badd43a2a05f01141"
},
"generator": "operatorstack/pitot:project",
"schema_version": 1,
"source": {
"commit": "07c76f27ada2c07a5ae1b0426a121e174ff7763d",
"commit": "015e6ecc9ec71a8d71c29be6b68f84f7f6b17fb4",
"path": "labs/15-pitot",
"repository": "operatorstack/intelligence-flow"
}
Expand Down
20 changes: 16 additions & 4 deletions cmd/pitot/doctor_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"time"

"github.com/operatorstack/pitot/adapters"
"github.com/operatorstack/pitot/config"
"github.com/operatorstack/pitot/wiring"
)

// hostProbe describes what `pitot doctor --host` inspects for a coding agent:
Expand Down Expand Up @@ -44,18 +46,28 @@ var hostProbes = map[adapters.Host]hostProbe{
}

// doctorHost reports whether a host is configured to route its blocking shell
// boundary to Pitot. It never edits configuration — it only inspects and
// reports, returning a non-nil error when a blocking issue is found so callers
// (and CI) get a clear signal.
// boundary to Pitot. It never edits configuration without an explicit --fix —
// plain doctor only inspects and reports, returning a non-nil error when a
// blocking issue is found so callers (and CI) get a clear signal.
func doctorHost(host adapters.Host, stdout, stderr io.Writer) error {
if !adapters.IsSupported(host) {
return fmt.Errorf("pitot doctor: unsupported host %q (want one of: %s)", host, hostList())
}
fmt.Fprintf(stdout, "Pitot %s — host check: %s\n", adapters.AdapterVersion, host)

// Repo-wireable hosts report their wiring state against the fragment
// witness (drift-is-named-not-silently-fixed).
if wiring.Supported(string(host)) {
root := "."
if found, err := config.FindRoot("."); err == nil {
root = found
}
return printWiringStatus(root, string(host), stdout)
}

probe, known := hostProbes[host]
if !known {
fmt.Fprintf(stdout, " host-config inspection is not implemented for %q in this release; run `pitot doctor` for the decoder status\n", host)
fmt.Fprintf(stdout, " host-config inspection is not implemented for %q in this release; run `pitot doctor` for the decoder status, or `pitot init --host %s` for the wiring snippet\n", host, host)
return nil
}

Expand Down
22 changes: 19 additions & 3 deletions cmd/pitot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ func runRequest(ctx context.Context, args []string, stdout io.Writer) error {

func doctor(args []string, stdout, stderr io.Writer) error {
host := ""
fix := false
for i := 0; i < len(args); i++ {
switch args[i] {
case "--host":
Expand All @@ -212,11 +213,21 @@ func doctor(args []string, stdout, stderr io.Writer) error {
}
host = args[i+1]
i++
case "--fix":
fix = true
default:
return fmt.Errorf("pitot doctor: unexpected argument %q", args[i])
}
}
if fix && host == "" {
return errors.New("pitot doctor: --fix requires --host")
}
if host != "" {
if fix {
// The only mutation doctor ever performs, and only on request:
// restore Pitot's own marked entries (foreign config untouched).
return runWireHost(host, true, stdout)
}
return doctorHost(adapters.Host(host), stdout, stderr)
}

Expand Down Expand Up @@ -271,10 +282,14 @@ func runRuntime(ctx context.Context, args []string, stdout, stderr io.Writer) er
var loaded config.Loaded
var err error
if configPath == "" {
loaded, err = config.Discover(".")
if errors.Is(err, config.ErrNoConfig) {
root, rootErr := config.FindRoot(".")
if rootErr != nil {
return errors.New("pitot: no config fragments under .pitot/conf.d (run 'pitot init' to register a controller or consumer, or pass --config PATH)")
}
loaded, err = config.Discover(root)
if err == nil {
anchorDirs(&loaded.Config, root)
}
} else {
loaded, err = config.Load(configPath)
}
Expand Down Expand Up @@ -315,8 +330,9 @@ func usage() string {

usage:
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 init --host HOST [--force]
pitot dev --host HOST -- AGENT [ARGS...]
pitot doctor [--host HOST]
pitot doctor [--host HOST] [--fix]
pitot run [--config PATH] --runtime PATH
pitot hook HOST [--runtime PATH]
pitot request KIND [--data JSON] --runtime PATH
Expand Down
20 changes: 14 additions & 6 deletions cmd/pitot/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,18 @@ controllers:
}

// TestRunDiscoversFragmentsAndHonorsDir proves the tenant model end to end:
// `pitot run` with no --config merges the .pitot/conf.d fragments in the
// working directory, a controller from one tenant resolves explicit requests,
// and a consumer declared with dir: runs in that working directory — its
// relative receipt path lands inside the tenant's own directory.
// `pitot run` with no --config discovers the .pitot/conf.d fragments by
// walking up from a SUBDIRECTORY to the owning root
// (discovery-walks-up-to-owned-roots-only), a controller from one tenant
// resolves explicit requests, and a consumer declared with dir: runs in its
// root-anchored working directory — its relative receipt path lands inside
// the tenant's own directory regardless of where pitot was invoked.
func TestRunDiscoversFragmentsAndHonorsDir(t *testing.T) {
t.Setenv("PITOT_RUNTIME", "")
helper := buildTestRole(t) // build before chdir: it compiles from the package dir
runtimePath := filepath.Join(t.TempDir(), "runtime.json")
t.Chdir(t.TempDir())
root := t.TempDir()
t.Chdir(root)

controllerFragment := fmt.Sprintf(`controllers:
release.approval:
Expand Down Expand Up @@ -175,6 +178,11 @@ func TestRunDiscoversFragmentsAndHonorsDir(t *testing.T) {
if err := os.MkdirAll("tenant-b", 0o755); err != nil {
t.Fatal(err)
}
// Invoke from a subdirectory: discovery must walk up to the owning root.
if err := os.MkdirAll("apps/web", 0o755); err != nil {
t.Fatal(err)
}
t.Chdir(filepath.Join(root, "apps", "web"))

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Expand Down Expand Up @@ -210,7 +218,7 @@ func TestRunDiscoversFragmentsAndHonorsDir(t *testing.T) {
if err := runWithIO(context.Background(), []string{"hook", "claude", "--runtime", runtimePath}, strings.NewReader(payload), &hookOut, &hookErr); err != nil {
t.Fatalf("hook err=%v stderr=%s", err, hookErr.String())
}
receipt := filepath.Join("tenant-b", "receipt.jsonl")
receipt := filepath.Join(root, "tenant-b", "receipt.jsonl")
deadline := time.Now().Add(5 * time.Second)
for {
if data, err := os.ReadFile(receipt); err == nil && strings.Contains(string(data), `"type":"action.requested"`) {
Expand Down
7 changes: 6 additions & 1 deletion cmd/pitot/shim.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"

"github.com/operatorstack/pitot/config"
"github.com/operatorstack/pitot/hydrate"
)

Expand Down Expand Up @@ -198,7 +199,11 @@ exit $LASTEXITCODE
// pin, slot, shim, and pin-vs-running drift.
func printHydrationStatus(stdout io.Writer) {
fmt.Fprintln(stdout, "hydration:")
pin, err := hydrate.Pin(".")
root := "."
if found, err := config.FindRoot("."); err == nil {
root = found
}
pin, err := hydrate.Pin(root)
if err != nil {
fmt.Fprintf(stdout, " pin: none (%s absent or invalid)\n", hydrate.PinPath)
return
Expand Down
16 changes: 10 additions & 6 deletions cmd/pitot/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,12 @@ func runUpgrade(ctx context.Context, args []string, stdout, stderr io.Writer) er
}
}

root := "."
if found, err := config.FindRoot("."); err == nil {
root = found
}
current := "none"
if pin, err := hydrate.Pin("."); err == nil {
if pin, err := hydrate.Pin(root); err == nil {
current = pin
}
if target == "" {
Expand Down Expand Up @@ -79,10 +83,10 @@ func runUpgrade(ctx context.Context, args []string, stdout, stderr io.Writer) er
return err
}
// Every tenant must still hold under the new binary before the pin moves.
if err := validateTenantsFor(target, slot); err != nil {
if err := validateTenantsFor(root, target, slot); err != nil {
return err
}
if err := hydrate.WritePin(".", target); err != nil {
if err := hydrate.WritePin(root, target); err != nil {
return err
}
fmt.Fprintf(stdout, "pinned %s -> %s (%s rewritten — commit this diff; every clone hydrates %s on its next invocation)\n", current, target, hydrate.PinPath, target)
Expand All @@ -92,8 +96,8 @@ func runUpgrade(ctx context.Context, args []string, stdout, stderr io.Writer) er
// validateTenantsFor re-checks the tenancy contract against the upgrade
// target: the merged config must still validate, and every fragment's
// requires_protocol must be spoken by the new binary.
func validateTenantsFor(version, slot string) error {
if _, err := config.Discover("."); err != nil {
func validateTenantsFor(root, version, slot string) error {
if _, err := config.Discover(root); err != nil {
if errors.Is(err, config.ErrNoConfig) {
return nil // no tenants registered; nothing to preserve
}
Expand All @@ -103,7 +107,7 @@ func validateTenantsFor(version, slot string) error {
if err != nil {
return fmt.Errorf("pitot upgrade: could not determine the protocol of %s: %w", version, err)
}
floors, err := fragmentProtocolFloors(".")
floors, err := fragmentProtocolFloors(root)
if err != nil {
return err
}
Expand Down
Loading
Loading