Clean agent rules

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-04-19 11:42:07 +02:00
parent aee77184df
commit ab52dc0a34
17 changed files with 570 additions and 868 deletions

48
contrib/claude/agent.md Normal file
View File

@@ -0,0 +1,48 @@
# Agent (`pkg/agent`)
LLM agent orchestration framework.
## Agent construction
```go
agent := agent.NewAgent(
"agent-name",
"System instructions here",
agent.WithTools(tool1, tool2),
agent.WithHandoffs(otherAgent),
agent.WithModel(model),
)
```
Functional options: `WithTools`, `WithHandoffs`, `WithInstructions`, `WithModel`, `WithModelSettings`, `WithMCPServers`, `WithInputGuardrails`, `WithOutputGuardrails`, `WithApproval`, `WithSession`.
## Execution
```go
result, err := agent.Run(ctx, messages)
result.FinalMessage().Text() // final output
result.LastAgent // agent that produced the result
```
Typed output via `RunTyped[T](ctx, agent, messages)` — validates against JSON Schema.
## Tool interface
```go
type Tool interface {
Name() string
Description() string
Parameters() jsonschema.Schema
Execute(ctx context.Context, input json.RawMessage) (string, error)
}
```
## Agent-as-tool
`agent.AsTool(name, description)` wraps an agent as a tool for composition.
## Limits
- Max turns: 10 (default)
- Max tool depth: 16 (default)
- Depth tracking prevents infinite recursion in handoffs

View File

@@ -0,0 +1,12 @@
# API Surface Rules
Every feature must be exposed through **all four interfaces**: GraphQL, MCP, CLI, and n8n. When adding a new endpoint or editing an existing type, keep all four in sync:
- **GraphQL** — `pkg/server/api/console/v1/graphql/*.graphql` (+ codegen) — see [`contrib/claude/graphql.md`](graphql.md)
- **MCP** — `pkg/server/api/mcp/v1/` (+ codegen) — see [`contrib/claude/mcp.md`](mcp.md)
- **CLI** — `pkg/cmd/` — see [`contrib/claude/cli.md`](cli.md)
- **n8n** — `packages/n8n-node/` — see [`contrib/claude/n8n.md`](n8n.md)
If you add a mutation in GraphQL, add the corresponding MCP tool, CLI command, and n8n node. If you rename or change a type, update it everywhere.
Every new Go API endpoint must have end-to-end tests in `e2e/`.

View File

@@ -5,6 +5,8 @@ CLI commands use [cobra](https://github.com/spf13/cobra) with `pkg/cmd/cmdutil.F
## Directory structure
```
cmd/prb/main.go # Binary entry point
pkg/cmd/root/ # Root command, registers all subcommands
pkg/cmd/<resource>/<resource>.go # Group command, wires verbs
pkg/cmd/<resource>/list/list.go # List verb
pkg/cmd/<resource>/create/create.go # Create verb
@@ -14,6 +16,7 @@ pkg/cmd/<resource>/delete/delete.go # Delete verb
pkg/cmd/cmdutil/ # Factory, flags, output helpers
pkg/cmd/iostreams/ # Terminal I/O abstraction
pkg/cli/api/ # GraphQL client, pagination
pkg/cli/config/ # Config file management (hosts, tokens, default org)
```
Register group commands in `pkg/cmd/root/root.go` with `cmd.AddCommand()`.

View File

@@ -2,6 +2,9 @@
All raw SQL lives in `pkg/coredata` — never in service, handler, or resolver packages. One file per entity, with companion `*_filter.go` and `*_order_field.go` files when needed.
- Database: `go.gearno.de/kit/pg`
- UUID: `go.gearno.de/crypto/uuid` (never use `github.com/google/uuid`)
## Entity struct pattern
Every entity uses `gid.GID` for its ID, `db` tags for pgx mapping, and `CreatedAt`/`UpdatedAt` timestamps. The `tenant_id` column exists in the database but is **never** stored on the Go struct — it is injected at query time via `Scoper`.

84
contrib/claude/gid.md Normal file
View File

@@ -0,0 +1,84 @@
# GID — Global Identifiers (`pkg/gid`)
Every entity ID in the system is a 24-byte tenant-scoped GID, serialized as base64url in the database, JSON, and API surfaces.
## GID layout (24 bytes / 192 bits)
| Bytes | Size | Content |
|-------|------|---------|
| 0–7 | 8 bytes | Tenant ID |
| 8–9 | 2 bytes | Entity type (`uint16`) |
| 10–17 | 8 bytes | Timestamp (milliseconds since epoch) |
| 18–23 | 6 bytes | Random data |
## Creating a GID
GIDs are created in the **service layer** (e.g. `pkg/probo/*_service.go`), not in coredata `Insert` methods. The entity type constant comes from `pkg/coredata/entity_type_reg.go`:
```go
assetID := gid.New(s.svc.scope.GetTenantID(), coredata.AssetEntityType)
asset := &coredata.Asset{
ID: assetID,
OrganizationID: req.OrganizationID,
Name: req.Name,
CreatedAt: now,
UpdatedAt: now,
}
err := asset.Insert(ctx, conn, s.svc.scope)
```
`gid.New` panics on random source failure (should never happen). Use `gid.NewGID` if you need the error.
## Extracting fields
```go
id.TenantID() // TenantID (first 8 bytes)
id.EntityType() // uint16 (bytes 8–9)
id.Timestamp() // time.Time (bytes 10–17)
```
## Parsing and serialization
- `gid.ParseGID(encoded)` — base64url string to GID
- `gid.String()` — GID to base64url string
- Implements `sql.Scanner`, `driver.Valuer`, `MarshalText`, `UnmarshalText`
- `gid.Nil` — zero-value GID
## TenantID
`TenantID` is an 8-byte type with its own layout:
| Bytes | Size | Content |
|-------|------|---------|
| 0–2 | 3 bytes | Machine ID (random per process) |
| 3–5 | 3 bytes | Timestamp (truncated Unix seconds) |
| 6–7 | 2 bytes | Atomic counter |
Create with `gid.NewTenantID()`. Check with `tenantID.IsValid()` (non-nil). Same serialization interfaces as GID (base64url, SQL scanner/valuer).
## Entity type registry
All entity type constants live in `pkg/coredata/entity_type_reg.go` as sequential `uint16` values:
```go
const (
OrganizationEntityType uint16 = 0
FrameworkEntityType uint16 = 1
MeasureEntityType uint16 = 2
// ...
_ uint16 = 8 // PeopleEntityType - removed
// ...
)
```
**Never reuse removed type numbers.** Use `_` placeholders with a comment noting what was removed. New types get the next available number.
`NewEntityFromID(id gid.GID) (any, bool)` switches on `id.EntityType()` and returns a pointer to the concrete coredata struct with `ID: id` set, or `nil, false` for unknown types. Add a case here when registering a new entity type.
## New entity checklist (GID-related steps)
1. Add `FooEntityType uint16 = N` in the `const` block in `entity_type_reg.go` (next sequential number)
2. Add a `case FooEntityType` in `NewEntityFromID` returning `&Foo{ID: id}, true`
3. In the service `Create` method, call `gid.New(scope.GetTenantID(), coredata.FooEntityType)` to generate the ID before `Insert`

View File

@@ -1,6 +1,40 @@
# Go Style
Layout and readability rules for Go source. (Error handling, naming, and imports are covered in `AGENTS.md` / other guides.)
## Project and dependencies
- HTTP server: `go.gearno.de/kit/httpserver`
- HTTP client: `go.gearno.de/kit/httpclient`
- Tracing: OpenTelemetry (`go.opentelemetry.io/otel`)
- Pointers: Go 1.26 — use `new(expr)` to create pointers to values (e.g. `new(1)`, `new("foo")`, `new(time.Now())`). Use `go.gearno.de/x/ref` only for dereference helpers (`ref.UnrefOrZero`, etc.)
## Grouped declarations
Use `type ()`, `const ()`, and `var ()` blocks to group related declarations. Use explicit typed values for string enums, not `iota`.
```go
type (
CreateFooRequest struct {
Name string
Active bool
}
UpdateFooRequest struct {
ID gid.GID
Name *string
Active *bool
}
)
const (
NameMaxLength = 100
ContentMaxLength = 5000
)
var (
_ Reader = (*FileReader)(nil)
_ Writer = (*FileWriter)(nil)
)
```
## Call expressions and argument lists
@@ -44,3 +78,113 @@ svc, err := foo.NewService(ctx, db, logger, foo.Config{
```
The same rule applies to **method calls** `x.M(a1, …)` — the receiver is already bound; the rule applies to the **argument list** after the method name.
## Import ordering
Two groups separated by a blank line: stdlib, then everything else (third-party and internal sorted together alphabetically).
```go
import (
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/trust"
)
```
## Receiver names
Short receivers: usually single-letter matching the type (`s` for Service, `c` for Client, `p` for Provider).
## Error handling
Wrap errors with `fmt.Errorf` using lowercase messages starting with `cannot`:
```go
return nil, fmt.Errorf("cannot load trust center: %w", err)
return nil, fmt.Errorf("cannot create SAML service: %w", err)
```
Sentinel errors in grouped `var ()` blocks. Custom error types implement `Unwrap() error`. Use `errors.Is` for sentinel checks. Use `errors.AsType[T](err)` (generic form) instead of `errors.As(err, &ptr)` for type assertions:
```go
// Good
if e, ok := errors.AsType[*ValidationError](err); ok {
// use e
}
// Bad — avoid the two-argument form
var ve *ValidationError
if errors.As(err, &ve) {
// use ve
}
```
## Naming
- Constructors: `New*` (e.g. `NewService`, `NewServer`, `NewBridge`)
- Config structs: `*Config` suffix (e.g. `APIConfig`, `PgConfig`, `TrustCenterConfig`)
- Request structs: `*Request` suffix (e.g. `UpdateTrustCenterRequest`)
- Unexported types for internal data: lowercase (e.g. `vendorInfo`, `ctxKey`)
## Functional options and Config structs
Use `Config` structs when a constructor has many required parameters. Use functional options (`With*` functions) for optional configuration.
```go
type Option func(*Bridge)
func WithDryRun(dryRun bool) Option {
return func(s *Bridge) {
s.dryRun = dryRun
}
}
func NewBridge(provider provider.Provider, client *scimclient.Client, opts ...Option) *Bridge {
s := &Bridge{provider: provider, scimClient: client}
for _, opt := range opts {
opt(s)
}
return s
}
```
## Interfaces
Define interfaces in the consumer package. Keep them small. Verify satisfaction at compile time:
```go
var (
_ unit.Configurable = (*Implm)(nil)
_ unit.Runnable = (*Implm)(nil)
)
```
## Context
Always first parameter. Private struct keys for context values:
```go
type ctxKey struct{ name string }
var trustCenterIDKey = &ctxKey{name: "trust_center_id"}
```
## Logging
`go.gearno.de/kit/log` — named, context-aware structured logging with typed fields. **Never log PII, PHI, or other sensitive data** (e.g. emails, names, passwords, tokens, health records). Log opaque identifiers (IDs, request IDs) instead.
```go
l.InfoCtx(
ctx,
"HTTP request to trust center custom domain, redirecting to HTTPS",
log.String("domain", domain),
log.String("path", r.URL.Path),
log.String("to", httpsURL),
)
```

View File

@@ -1,5 +1,7 @@
# Go Testing
Test library: `github.com/stretchr/testify` (`require` for fatal, `assert` for non-fatal).
## Package naming
Black-box test packages (`package foo_test`). White-box (`package foo`) only when testing unexported functions.

81
contrib/claude/make.md Normal file
View File

@@ -0,0 +1,81 @@
# GNUmakefile
The project uses a `GNUmakefile` at the root. Builds run with `--jobs=$(nproc)` by default.
## Everyday targets
| Target | Purpose |
|---|---|
| `make build` | Build `bin/probod`, `bin/prb`, and `bin/probod-bootstrap` (includes frontend apps, codegen, and Relay) |
| `SKIP_APPS=1 make build` | Build without frontend apps (faster for backend-only work) |
| `make test` | Run tests with race detection and coverage |
| `make test MODULE=./pkg/foo` | Run tests for a single module |
| `make test-verbose` | Tests with verbose output |
| `make test-short` | Short tests only |
| `make test-bench` | Run benchmarks |
| `make test-e2e` | Run console end-to-end tests (requires `bin/probod`) |
| `make lint` | Run all linters: `vet` + `go-fmt` + `go-fix` + `go-lint` + `npm-lint` |
| `make fmt` | Format Go code (`go fmt ./...`) |
| `make clean` | Remove all build artifacts, `node_modules`, generated files, and coverage |
| `make help` | List targets with `##` doc comments |
## Infrastructure
| Target | Purpose |
|---|---|
| `make stack-up` | Start Docker Compose infra (Postgres, Pebble, Keycloak, etc.) |
| `make stack-down` | Stop Docker Compose infra |
| `make stack-ps` | List running containers |
| `make psql` | Open a `psql` shell to the dev Postgres database |
## Codegen
`make generate` runs all code generation (GraphQL + MCP + Relay). Individual codegen is driven by `go generate`:
- `go generate ./pkg/server/api/console/v1` — Console GraphQL (gqlgen)
- `go generate ./pkg/server/api/connect/v1` — Connect GraphQL (gqlgen)
- `go generate ./pkg/server/api/trust/v1` — Trust GraphQL (gqlgen)
- `go generate ./pkg/server/api/mcp/v1` — MCP (mcpgen)
- `go generate ./pkg/llm` — LLM model registry from OpenRouter (`make genmodels`)
`make relay` merges split `.graphql` schema files and runs `relay-compiler`.
## Coverage
| Target | Purpose |
|---|---|
| `make coverage-report` | Unit test HTML coverage report (`coverage.html`) |
| `make test-e2e-coverage` | E2E coverage report (`coverage-e2e.html`) |
| `make coverage-combined` | Combined unit + e2e report (`coverage-combined.html`) |
## Docker
| Target | Purpose |
|---|---|
| `make docker-build` | Build the Docker image (`ghcr.io/getprobo/probo`) |
| `make sbom` | Source SBOM (CycloneDX) |
| `make sbom-docker` | Docker image SBOM |
| `make scan` | Vulnerability scan (Grype) on source + Docker |
| `make scan-license` | License compliance scan (Trivy) |
## Sandbox (Lima)
| Target | Purpose |
|---|---|
| `make sandbox-create` | Create a Lima sandbox VM for this worktree |
| `make sandbox-start` | Start the VM |
| `make sandbox-stop` | Stop (hibernate) the VM |
| `make sandbox-delete` | Delete the VM |
| `make sandbox-ssh` | Open a shell in the VM |
| `make sandbox-status` | Show VM status and IP |
## Overridable variables
| Variable | Default | Purpose |
|---|---|---|
| `SKIP_APPS` | (unset) | Set to `1` to skip frontend app builds |
| `CGO_ENABLED` | `0` | Enable/disable CGO |
| `GOOS` | (host) | Cross-compile target OS |
| `TEST_FLAGS` | `-race -cover -coverprofile=coverage.out` | Extra flags passed to `go test` |
| `DOCKER_BUILD_FLAGS` | (empty) | Extra flags for `docker build` |
| `E2E_CONFIG` | `e2e/console/testdata/config.yaml` | E2E test config path |

221
contrib/claude/n8n.md Normal file
View File

@@ -0,0 +1,221 @@
# n8n Node (`packages/n8n-node`)
Community node package `@probo/n8n-nodes-probo` exposing the Probo API as n8n operations. One `Probo` node with many resources; each resource maps to a set of GraphQL operations against the Console or Connect API.
## Directory structure
```
packages/n8n-node/
credentials/ProboApi.credentials.ts # API key credential (Bearer token)
nodes/Probo/
Probo.node.ts # Node class — resource picker, dispatch
Probo.node.json # n8n codex metadata
GenericFunctions.ts # GraphQL request helpers, pagination
actions/
index.ts # Resource registry, dispatch, field aggregators
<resource>/
index.ts # Operation dropdown + spread descriptions + re-exports
create.operation.ts # One file per operation
get.operation.ts
getAll.operation.ts
update.operation.ts
delete.operation.ts
...
```
## Resource registration
Two places must be updated when adding a resource:
**1. `actions/index.ts`** — import the module and add it to the `resources` map:
```typescript
import * as myresource from './myresource';
export const resources: Record<string, ResourceModule> = {
// ... existing resources ...
myresource: myresource as ResourceModule,
};
```
**2. `Probo.node.ts`** — add a Resource dropdown entry in the `properties` array:
```typescript
{
name: 'My Resource',
value: 'myresource',
description: 'Manage my resources',
},
```
The `value` must match the key in `resources` and the `displayOptions.show.resource` in every operation file.
## Per-resource file pattern
### `<resource>/index.ts`
1. Import each `*.operation.ts` as a namespace.
2. Export `description` — the operation dropdown (gated with `displayOptions.show.resource`) plus all spread operation descriptions.
3. Re-export each operation module with a name matching its `operation` value.
```typescript
import * as createOp from './create.operation';
import * as getOp from './get.operation';
import * as getAllOp from './getAll.operation';
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['myresource'],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a new resource',
action: 'Create a resource',
},
// ... more operations ...
],
default: 'create',
},
...createOp.description,
...getOp.description,
...getAllOp.description,
];
export {
createOp as create,
getOp as get,
getAllOp as getAll,
};
```
Export names (`create`, `get`, `getAll`, etc.) **must match** the operation `value` strings — `getExecuteFunction` uses them as keys.
### `<resource>/<verb>.operation.ts`
Each file exports `description` (field definitions) and `execute` (the handler):
```typescript
export const description: INodeProperties[] = [
{
displayName: 'Organization ID',
name: 'organizationId',
type: 'string',
displayOptions: {
show: {
resource: ['myresource'],
operation: ['create'],
},
},
default: '',
required: true,
},
// ... more fields ...
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['myresource'],
operation: ['create'],
},
},
options: [
// optional field definitions
],
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
const name = this.getNodeParameter('name', itemIndex) as string;
const query = `
mutation CreateMyResource($input: CreateMyResourceInput!) {
createMyResource(input: $input) {
myResourceEdge {
node {
id
name
}
}
}
}
`;
const responseData = await proboApiRequest.call(this, query, {
input: { organizationId, name },
});
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}
```
## GraphQL helpers
All helpers live in `GenericFunctions.ts`.
| Helper | API endpoint | Use case |
|--------|-------------|----------|
| `proboApiRequest` | `/api/console/v1/graphql` | Single mutations and queries |
| `proboConnectApiRequest` | `/api/connect/v1/graphql` | Organization/user operations (IAM) |
| `proboApiRequestAllItems` | Console API | Cursor-paginated list queries |
| `proboConnectApiRequestAllItems` | Connect API | Cursor-paginated list queries (IAM) |
| `proboApiMultipartRequest` | Console API | File upload mutations (multipart/form-data) |
### Pagination (`proboApiRequestAllItems`)
Caller supplies a `getConnection` function that navigates from the raw GraphQL response to the Relay connection object (must have `edges` and `pageInfo`):
```typescript
const items = await proboApiRequestAllItems.call(
this,
query,
{ organizationId },
(response) => {
const data = response?.data as IDataObject | undefined;
const node = data?.node as IDataObject | undefined;
return node?.myResources as IDataObject | undefined;
},
returnAll,
limit,
);
```
Internal page size is 100. When `returnAll` is false, stops at `limit`.
### Update pattern
For nullable fields, empty string means "clear the value":
```typescript
if (additionalFields.description !== undefined) {
input.description = additionalFields.description === '' ? null : additionalFields.description;
}
```
## Adding a new resource — checklist
1. **Directory** — create `nodes/Probo/actions/<resource>/` with `index.ts` and one `*.operation.ts` per operation
2. **Operations** — each file exports `description` (fields gated with `displayOptions`) and `execute` (reads params, calls GraphQL, returns `{ json, pairedItem }`)
3. **Index** — `<resource>/index.ts` defines the operation dropdown, spreads all descriptions, re-exports ops with matching value names
4. **Register** — import and add to `resources` map in `actions/index.ts`
5. **Node** — add Resource dropdown entry in `Probo.node.ts` properties
6. **Verify** — `npx n8n-node lint` must pass