diff --git a/scaffolds/maddox-think/.env.example b/scaffolds/maddox-think/.env.example new file mode 100644 index 0000000..317d5c3 --- /dev/null +++ b/scaffolds/maddox-think/.env.example @@ -0,0 +1,22 @@ +# Alchemy / Cloudflare deploy credentials +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_API_TOKEN= + +# Slack application +SLACK_SIGNING_SECRET= +SLACK_BOT_TOKEN= + +# Shared company memory +GBRAIN_DATABASE_URL= + +# Model provider. Start with Workers AI; add provider API keys only when selected. +MODEL_PROVIDER=workers-ai +PRIMARY_MODEL=@cf/moonshotai/kimi-k2.7-code +FALLBACK_MODEL=@cf/openai/gpt-oss-120b + +# Optional integrations, introduced component-by-component +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY= +GITHUB_INSTALLATION_ID= +LINEAR_API_KEY= +SENTRY_AUTH_TOKEN= diff --git a/scaffolds/maddox-think/.gitignore b/scaffolds/maddox-think/.gitignore new file mode 100644 index 0000000..079b10f --- /dev/null +++ b/scaffolds/maddox-think/.gitignore @@ -0,0 +1,8 @@ +.alchemy/ +.dev.vars +.env +node_modules/ +coverage/ +dist/ +.wrangler/ +pnpm-debug.log* diff --git a/scaffolds/maddox-think/.prettierignore b/scaffolds/maddox-think/.prettierignore new file mode 100644 index 0000000..761017c --- /dev/null +++ b/scaffolds/maddox-think/.prettierignore @@ -0,0 +1,6 @@ +.alchemy/ +.wrangler/ +node_modules/ +coverage/ +dist/ +pnpm-lock.yaml diff --git a/scaffolds/maddox-think/AGENTS.md b/scaffolds/maddox-think/AGENTS.md new file mode 100644 index 0000000..e47ecf2 --- /dev/null +++ b/scaffolds/maddox-think/AGENTS.md @@ -0,0 +1,40 @@ +# Agent Guide + +This repository is a Cloudflare-native Think application whose business logic is written with Effect. + +## Non-negotiable boundaries + +- `MaddoxConversationAgent` must extend `Think`; keep the class thin. +- Think callbacks may call Effect programs through `MaddoxRuntime`; they do not contain integration logic. +- Effect Schema is the authority for domain and external data. Zod exists only at the AI SDK / Think tool boundary. +- Every external response is decoded before it crosses an adapter boundary. +- Domain failures are `Schema.TaggedErrorClass` values. Do not use message-string matching. +- Never log, persist, return to the model, or place in workspace files any credential. +- Read-only capabilities are Think tools. External mutations are Think Actions. +- Every mutation has a stable idempotency key and explicit permission. +- Durable Objects, Workflows, R2, D1, AI bindings, secrets, routes, and domains are declared only in `alchemy.run.ts` or modules imported by it. +- No Wrangler configuration is committed. +- Think SQLite is thread-local runtime state. Shared company knowledge belongs behind the GBrain service. +- Local Hermes is an import source, never a runtime dependency. +- No automatic executable skill creation in the first release. + +## Effect conventions + +- Use `Context.Service` for application capabilities. +- Put production Layers in `live.ts`; deterministic implementations in `test.ts`. +- Keep provider SDKs behind named adapter packages. +- Decode configuration once at the runtime boundary. +- Use the Effect Clock instead of `Date.now()` in domain code. +- Use `Effect.retry` only with an explicit retry policy and retryable tagged failures. +- Use `Effect.log*` with structured, redacted annotations. +- Do not rely on finalizers for correctness across Durable Object eviction. + +## Definition of done for a component + +1. Public contracts and errors exist. +2. A deterministic Test Layer exists. +3. The production adapter decodes all unknown data. +4. Authorization and idempotency behavior are tested where relevant. +5. Failure output is safe for Slack and model context. +6. `pnpm check` passes. +7. The component's implementation-plan checklist is updated. diff --git a/scaffolds/maddox-think/README.md b/scaffolds/maddox-think/README.md new file mode 100644 index 0000000..5b90ecb --- /dev/null +++ b/scaffolds/maddox-think/README.md @@ -0,0 +1,71 @@ +# Maddox Think + +A standalone Cloudflare-native company agent for Card Madness. + +This scaffold intentionally does **not** migrate or depend on the existing cloud-hosted Hermes deployment. It starts clean and imports only selected identity, skills, and durable knowledge from the local Hermes instance through an explicit, reviewable import bundle. + +## Architectural thesis + +- **Think owns the conversational runtime.** One `MaddoxConversationAgent` Durable Object is addressed per Slack thread. +- **Effect owns application behavior.** Contracts, policies, integrations, authorization, memory, tools, actions, import logic, retries, and errors are Effect services. +- **Alchemy owns all infrastructure.** There is no hand-maintained Wrangler configuration. +- **Reads are tools; writes are actions.** Side effects use Think Actions with authorization, idempotency, and approval. +- **A real computer is optional.** Repository builds, browser work, and native processes use an on-demand sandbox in a later slice. +- **Company memory is separate from chat history.** Think SQLite stores thread-local state; GBrain remains the shared, provenance-aware company knowledge store. + +## Status + +This is a high-level scaffold, not a deploy-ready release. The first implementation slice is deliberately small: + +1. Alchemy deploys one Worker and the Think Durable Object namespace. +2. Slack ingress verifies signatures and derives a stable thread identity. +3. The Worker durably submits a message to the thread's Think agent. +4. The agent uses a deterministic fake model in tests and one configured model in development. +5. The response delivery boundary is represented by a typed Effect service. + +See: + +- [Architecture](docs/architecture.md) +- [Component plan](docs/implementation-plan.md) +- [Local Hermes import](docs/hermes-import.md) +- [Think and Effect boundary ADR](docs/decisions/0001-think-effect-boundary.md) +- [Conversation identity ADR](docs/decisions/0002-thread-per-agent.md) + +## Intended repository + +Create a new private repository, preferably: + +```text +card-madness/maddox-think +``` + +Copy the contents of this directory to the root of that repository. Do not merge the scaffold into Card Madness, the current Maddox deployment, or the Effect template itself. + +## Local commands + +The dependency versions are intentionally pinned to the currently reviewed Think and Alchemy releases. Re-review both before the first production deploy because they are still pre-stable. + +```bash +pnpm install +pnpm typecheck +pnpm test +pnpm alchemy:dev +``` + +## Repository map + +```text +apps/ + worker/ Cloudflare entry module and Think Durable Object +packages/ + contracts/ Effect Schema authorities and tagged domain values + core/ Application services, policies, and deterministic doubles + runtime/ Thin Think-to-Effect adapter and layer construction +skills/ + company-operating-system/ +docs/ + decisions/ +alchemy.run.ts Alchemy stack and typed Cloudflare bindings +``` + +The implementation plan adds packages only when a component has a real boundary. Avoid turning this into a large generic platform before the Slack-to-Think vertical slice works. diff --git a/scaffolds/maddox-think/alchemy.run.ts b/scaffolds/maddox-think/alchemy.run.ts new file mode 100644 index 0000000..94016af --- /dev/null +++ b/scaffolds/maddox-think/alchemy.run.ts @@ -0,0 +1,75 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; + +import type { MaddoxConversationAgent } from "./apps/worker/src/maddox-agent.ts"; + +const MaddoxWorker = () => + Effect.gen(function* () { + const skills = yield* Cloudflare.R2.Bucket("MaddoxSkills"); + const artifacts = yield* Cloudflare.R2.Bucket("MaddoxArtifacts"); + const registry = yield* Cloudflare.D1.Database("MaddoxRegistry", { + migrations: "./migrations", + }); + + return yield* Cloudflare.Worker("MaddoxThink", { + main: "./apps/worker/src/index.ts", + compatibility: { + date: "2026-08-25", + flags: ["nodejs_compat"], + }, + env: { + AI: Cloudflare.Workers.AI(), + ARTIFACTS: artifacts, + FALLBACK_MODEL: Config.string("FALLBACK_MODEL").pipe( + Config.withDefault("@cf/openai/gpt-oss-120b"), + ), + GBRAIN_DATABASE_URL: Config.redacted("GBRAIN_DATABASE_URL"), + MODEL_PROVIDER: Config.string("MODEL_PROVIDER").pipe( + Config.withDefault("workers-ai"), + ), + MaddoxConversationAgent: + Cloudflare.DurableObject( + "MaddoxConversationAgent", + { className: "MaddoxConversationAgent" }, + ), + PRIMARY_MODEL: Config.string("PRIMARY_MODEL").pipe( + Config.withDefault("@cf/moonshotai/kimi-k2.7-code"), + ), + REGISTRY: registry, + SKILLS: skills, + SLACK_BOT_TOKEN: Config.redacted("SLACK_BOT_TOKEN"), + SLACK_SIGNING_SECRET: Config.redacted("SLACK_SIGNING_SECRET"), + }, + observability: { + enabled: true, + logs: { + enabled: true, + invocationLogs: true, + }, + traces: { + enabled: true, + headSamplingRate: 1, + }, + }, + }); + }); + +export type MaddoxEnv = Cloudflare.InferEnv>; + +export default Alchemy.Stack( + "MaddoxThink", + { + providers: Cloudflare.providers(), + state: Cloudflare.state(), + }, + Effect.gen(function* () { + const worker = yield* MaddoxWorker(); + + return { + workerName: worker.workerName, + url: worker.url, + }; + }), +); diff --git a/scaffolds/maddox-think/apps/worker/package.json b/scaffolds/maddox-think/apps/worker/package.json new file mode 100644 index 0000000..2737f57 --- /dev/null +++ b/scaffolds/maddox-think/apps/worker/package.json @@ -0,0 +1,12 @@ +{ + "name": "@maddox/worker", + "private": true, + "type": "module", + "dependencies": { + "@cloudflare/think": "0.16.0", + "@maddox/contracts": "workspace:*", + "@maddox/runtime": "workspace:*", + "agents": "0.21.0", + "workers-ai-provider": "^4.0.0" + } +} diff --git a/scaffolds/maddox-think/apps/worker/src/index.ts b/scaffolds/maddox-think/apps/worker/src/index.ts new file mode 100644 index 0000000..8859cbc --- /dev/null +++ b/scaffolds/maddox-think/apps/worker/src/index.ts @@ -0,0 +1,86 @@ +import { getAgentByName, routeAgentRequest } from "agents"; + +import type { MaddoxEnv } from "../../../alchemy.run.ts"; +import { decodeSlackEventRequest, SlackRequestRejected } from "@maddox/contracts"; +import { makeMaddoxRuntime, type MaddoxRuntime } from "@maddox/runtime"; + +export { MaddoxConversationAgent } from "./maddox-agent.ts"; + +const notFound = () => new Response("Not found", { status: 404 }); + +let ingressRuntime: MaddoxRuntime | undefined; + +const getIngressRuntime = (env: MaddoxEnv): MaddoxRuntime => + (ingressRuntime ??= makeMaddoxRuntime(env)); + +export default { + async fetch( + request: Request, + env: MaddoxEnv, + executionContext: ExecutionContext, + ): Promise { + const routed = await routeAgentRequest(request, env); + if (routed) { + return routed; + } + + const url = new URL(request.url); + + if (url.pathname === "/healthz") { + return Response.json({ status: "ok" }); + } + + if (url.pathname !== "/webhooks/slack" || request.method !== "POST") { + return notFound(); + } + + const runtime = getIngressRuntime(env); + + try { + const event = await runtime.runPromise( + decodeSlackEventRequest(request, env.SLACK_SIGNING_SECRET), + ); + + if (event.kind === "url-verification") { + return Response.json({ challenge: event.challenge }); + } + + const agent = await getAgentByName( + env.MaddoxConversationAgent, + event.conversationId, + ); + + await agent.submitMessages( + [ + { + id: event.eventId, + role: "user", + parts: [{ type: "text", text: event.text }], + }, + ], + { + idempotencyKey: event.idempotencyKey, + metadata: { + channelId: event.channelId, + slackEventId: event.eventId, + teamId: event.teamId, + threadTs: event.threadTs, + userId: event.userId, + }, + }, + ); + + return new Response(null, { status: 200 }); + } catch (error) { + if (error instanceof SlackRequestRejected) { + return Response.json( + { error: error.reason }, + { status: error.statusCode }, + ); + } + + executionContext.waitUntil(runtime.reportDefect(error)); + return Response.json({ error: "internal_error" }, { status: 500 }); + } + }, +} satisfies ExportedHandler; diff --git a/scaffolds/maddox-think/apps/worker/src/maddox-agent.ts b/scaffolds/maddox-think/apps/worker/src/maddox-agent.ts new file mode 100644 index 0000000..236767e --- /dev/null +++ b/scaffolds/maddox-think/apps/worker/src/maddox-agent.ts @@ -0,0 +1,48 @@ +import { Think, type ThinkScheduledTasks } from "@cloudflare/think"; +import { createWorkersAI } from "workers-ai-provider"; + +import type { MaddoxEnv } from "../../../alchemy.run.ts"; +import { makeMaddoxRuntime } from "@maddox/runtime"; + +export class MaddoxConversationAgent extends Think { + override workspaceBash = false; + + private readonly app = makeMaddoxRuntime(this.env); + + override getModel() { + return createWorkersAI({ binding: this.env.AI })(this.env.PRIMARY_MODEL); + } + + override getSystemPrompt(): string { + return [ + "You are Maddox, the Card Madness company agent.", + "Be direct, evidence-seeking, and explicit about uncertainty.", + "Use read-only tools freely when relevant.", + "Use actions for every external mutation.", + "Never expose credentials or internal implementation secrets.", + ].join("\n"); + } + + override getTools() { + return this.app.thinkTools(); + } + + override getActions() { + return this.app.thinkActions(); + } + + override getDefaultTimezone(): string { + return "America/New_York"; + } + + override getScheduledTasks(): ThinkScheduledTasks { + return { + "weekly-reflection": { + schedule: "every week on monday at 09:00", + handler: async () => { + await this.app.runPromise(this.app.runReflection()); + }, + }, + }; + } +} diff --git a/scaffolds/maddox-think/docs/architecture.md b/scaffolds/maddox-think/docs/architecture.md new file mode 100644 index 0000000..cab8784 --- /dev/null +++ b/scaffolds/maddox-think/docs/architecture.md @@ -0,0 +1,178 @@ +# Architecture + +## System shape + +```text +Slack + │ signed Events API webhook + ▼ +Maddox Worker +├─ Effect-based signature validation and event decoding +├─ verified Slack principal and channel policy +├─ stable thread identity +├─ immediate acknowledgement +└─ durable Think submission + │ + ▼ +MaddoxConversationAgent (one Think DO per Slack thread) +├─ Think Session and SQLite event history +├─ model/tool loop and recovery +├─ context blocks and compaction +├─ read-only tools +├─ authorized/idempotent Actions +├─ scheduled reflection +└─ Slack reply delivery + │ + ├─ GBrain / Neon + ├─ GitHub App + ├─ Linear + ├─ Sentry + ├─ Card Madness domain API + ├─ R2 skills and artifacts + ├─ Cloudflare Workflows + └─ on-demand Sandbox (later) +``` + +## Ownership boundaries + +| Concern | Authority | +|---|---| +| Conversation messages, active turn, compaction | Think Durable Object SQLite | +| Slack identity, roles, channel policy | Registry database and `Authorization` service | +| Company facts, decisions, corrections | GBrain | +| Bundled baseline skills | Git | +| Dynamic instruction skills and resources | R2 plus version metadata | +| GitHub/Linear/Sentry/Card Madness current state | Live external system | +| Side-effect ledger and approval | Think Actions | +| Deterministic multi-step jobs | Cloudflare Workflows | +| Large generated files | R2 artifacts | +| Repository checkout, tests, browser/native work | On-demand Sandbox | +| Infrastructure | Alchemy | +| Domain behavior and adapters | Effect services and Layers | + +## Why one Think agent per Slack thread + +A single company-wide Durable Object would serialize unrelated conversations and make local context separation fragile. The stable identity is: + +```text +slack::: +``` + +Each thread can hibernate independently. Company-wide knowledge is shared through GBrain rather than by sharing the conversation state. + +## Think / Effect seam + +Think must be the base class because it owns the Durable Object lifecycle and SQLite runtime. The class is an adapter, not the application. + +```text +Think callback + → validate/translate callback data + → call one Effect program through MaddoxRuntime + → map tagged result/failure back to Think +``` + +Effect owns: + +- external decoding; +- authorization; +- model policy; +- tools and actions; +- integration clients; +- memory policy; +- Slack delivery; +- import logic; +- retries, timeout policy, and structured logging. + +No Effect Scope or finalizer is allowed to carry correctness across Durable Object eviction. Correctness comes from Think/DO persistence, action idempotency, Workflow state, and external idempotency keys. + +## Tool and action policy + +### Tools + +Read-only and retry-safe: + +- recall company memory; +- read GitHub; +- search Linear; +- inspect Sentry; +- query bounded Card Madness domain data. + +### Actions + +Anything that changes an external system: + +- write/correct GBrain memory; +- create or update a Linear issue; +- create branch, commit, comment, or PR in GitHub; +- create/update/archive a skill; +- start a Workflow or Sandbox job. + +Every Action declares: + +1. permission; +2. actor/principal; +3. stable idempotency key; +4. approval policy; +5. input and output schemas; +6. safe user-facing failure; +7. audit metadata. + +## Storage + +### Think SQLite + +Thread-local runtime only. Do not mirror GBrain or large files into every thread. + +### D1 registry + +Small shared application data: + +- Slack workspace installation metadata; +- user-to-role mapping; +- channel policy; +- enabled integration flags; +- conversation index; +- delivery reconciliation records when cross-thread lookup is required. + +### R2 + +- versioned dynamic skills and references; +- Hermes import bundles; +- reports and large artifacts; +- optional exports/backups. + +### GBrain / Neon + +Shared organizational knowledge with provenance, confidence, scope, validity, and supersession. + +## Failure model + +- Slack retries are deduplicated by `event_id`. +- Think submissions use the same event-derived idempotency key. +- Actions use operation-specific idempotency keys. +- External writes that time out after an uncertain outcome enter `unknown`, not `failed`. +- Delivery is reconciled separately from model completion. +- Workflows own jobs that need durable steps, waits, retries, or approvals. +- No correctness depends on graceful shutdown. + +## Phase boundaries + +### Phase 1 + +Slack → Think → one safe response, deterministic tests, no external writes. + +### Phase 2 + +Read-only GBrain, GitHub, Linear, Sentry, and Card Madness tools. + +### Phase 3 + +Think Actions, approval, authorization, and delivery reconciliation. + +### Phase 4 + +Dynamic instruction skills and controlled local-Hermes import. + +### Phase 5 + +Workflows and on-demand Sandbox for implementation work. diff --git a/scaffolds/maddox-think/docs/decisions/0001-think-effect-boundary.md b/scaffolds/maddox-think/docs/decisions/0001-think-effect-boundary.md new file mode 100644 index 0000000..f93f1e8 --- /dev/null +++ b/scaffolds/maddox-think/docs/decisions/0001-think-effect-boundary.md @@ -0,0 +1,39 @@ +# ADR 0001: Think is the host; Effect is the application + +## Status + +Accepted for the scaffold. + +## Context + +Think provides the Durable Object base class, SQLite-backed Session, model loop, recovery, tools, Actions, scheduling, and stream behavior. Alchemy also offers Effect-native Worker and Durable Object classes, but TypeScript does not support extending both base classes. + +Putting business logic directly in Think callbacks would create an imperative second architecture and weaken the Effect boundaries we want. + +## Decision + +`MaddoxConversationAgent` extends `Think` and remains a thin adapter. + +Think callbacks call Effect programs through a repository-owned `MaddoxRuntime`. Domain services, provider adapters, authorization, memory, delivery, import logic, and failure classification are Effect code. + +Alchemy deploys the plain async Worker module and declares the Think Durable Object binding and migration. + +## Consequences + +### Positive + +- Think remains upgradeable without forking it. +- Business behavior is deterministic and testable without Workerd. +- Provider SDKs and unknown data stay behind adapters. +- Alchemy remains the sole infrastructure authority. +- The inheritance conflict disappears. + +### Negative + +- A deliberate adapter is needed between Effect Schema and the Zod/AI SDK tool interface. +- Think lifecycle methods return Promises, so tagged failures must be mapped carefully. +- Effect resource finalizers cannot be relied on across Durable Object eviction. + +## Guardrail + +No integration SDK import is allowed in `apps/worker/src/maddox-agent.ts`. diff --git a/scaffolds/maddox-think/docs/decisions/0002-thread-per-agent.md b/scaffolds/maddox-think/docs/decisions/0002-thread-per-agent.md new file mode 100644 index 0000000..eed536d --- /dev/null +++ b/scaffolds/maddox-think/docs/decisions/0002-thread-per-agent.md @@ -0,0 +1,28 @@ +# ADR 0002: One Think Durable Object per Slack thread + +## Status + +Accepted for the scaffold. + +## Context + +Maddox is a company agent, but unrelated Slack conversations must execute concurrently and must not accidentally share conversational context. A single global Durable Object serializes events and makes isolation dependent on application discipline. + +## Decision + +Derive a stable agent name from the verified Slack body: + +```text +slack::: +``` + +Route each thread to its own `MaddoxConversationAgent`. + +Shared company knowledge is retrieved through GBrain. Shared identity, roles, and channel policy live in the registry. They are not copied into one global conversation. + +## Consequences + +- Independent threads scale and hibernate independently. +- Thread history cannot leak through one in-memory session. +- Cross-thread search requires a shared registry/GBrain query. +- Scheduled company-wide work should use a dedicated administrative agent or Workflow rather than an arbitrary conversation agent. diff --git a/scaffolds/maddox-think/docs/hermes-import.md b/scaffolds/maddox-think/docs/hermes-import.md new file mode 100644 index 0000000..9a53f32 --- /dev/null +++ b/scaffolds/maddox-think/docs/hermes-import.md @@ -0,0 +1,91 @@ +# Importing selected state from local Hermes + +This application starts clean. The local Hermes instance is treated as an export source, not as a service dependency and not as the authority after import. + +## Import bundle + +The administrative exporter creates a deterministic directory or archive: + +```text +manifest.json +identity/ + SOUL.md + PROFILE.md +skills/ + / + SKILL.md + references/ +memory/ + candidates.jsonl +conversation-summaries/ + .md +``` + +`manifest.json` records: + +- format version; +- export timestamp; +- source Hermes version; +- source machine identifier hash; +- file hashes; +- redaction report; +- selected categories; +- exporter version. + +## Two-stage process + +### Export and review locally + +1. Read only explicitly allowed Hermes paths. +2. Reject symlinks and paths outside the configured root. +3. Scan for common credential formats and high-entropy secrets. +4. Convert skills into the target skill schema. +5. Convert memories into candidates with provenance and confidence. +6. Produce a dry-run report. +7. Require an administrator to approve the bundle. + +### Import into Maddox + +1. Verify manifest and content hashes. +2. Re-run schema and secret checks. +3. Store identity text as a proposed Git change or controlled app config. +4. Store skill versions in R2 as drafts. +5. Send memory candidates through the GBrain write/approval path. +6. Record an import ledger with per-item outcomes. +7. Never overwrite an active item silently; create a superseding version. + +## Explicit exclusions + +Do not export or import: + +- Codex, Linear, GitHub, Slack, or model OAuth state; +- API keys, cookies, `.env` files, credential stores; +- Hermes SQLite databases wholesale; +- model/provider caches; +- installed binaries or virtual environments; +- R2 recovery manifests from the previous cloud design; +- arbitrary filesystem directories; +- current GitHub, Linear, Sentry, or Card Madness snapshots. + +## Idempotency + +The bundle ID is the SHA-256 of the canonical manifest. Every imported item uses: + +```text +hermes-import::: +``` + +Re-importing the same bundle is a no-op. A changed bundle produces new versions and an explicit diff. + +## First implementation + +Create a local-only CLI with commands: + +```bash +maddox-import export --hermes-home --output +maddox-import inspect +maddox-import upload --stage staging +maddox-import apply +``` + +`upload` stores an untrusted bundle in R2. `apply` runs through the administrative authorization and Action system. diff --git a/scaffolds/maddox-think/docs/implementation-plan.md b/scaffolds/maddox-think/docs/implementation-plan.md new file mode 100644 index 0000000..ff3a4ea --- /dev/null +++ b/scaffolds/maddox-think/docs/implementation-plan.md @@ -0,0 +1,565 @@ +# Component implementation plan + +The order is deliberate. Complete a vertical slice before adding broad integrations. + +## 0. Repository and delivery + +**Goal:** a new private `card-madness/maddox-think` repository with isolated dev, staging, and production stages. + +### Work + +- Copy this scaffold to the new repository root. +- Pin the exact resolved `effect`, Think, Agents SDK, Alchemy, AI SDK, and TypeScript versions in the lockfile. +- Add GitHub Actions using the Alchemy action: + - PR: isolated staging preview where platform limits permit; + - `main`: production plan and deploy; + - closed PR: destroy preview. +- Configure Cloudflare account variables and secrets. +- Add `CODEOWNERS`, Dependabot/Renovate, and branch protection. +- Prohibit Wrangler files in an architecture check. + +### Acceptance + +- `pnpm check` passes from a clean clone. +- `alchemy plan` contains only the standalone Maddox resources. +- Destroying a development stage cannot touch Card Madness production resources. + +--- + +## 1. Alchemy foundation + +**Goal:** deploy the thin async Worker and Think Durable Object with typed bindings. + +### Resources + +- Worker hosting `MaddoxConversationAgent`; +- SQLite Durable Object migration; +- Workers AI binding; +- D1 registry; +- R2 skills bucket; +- R2 artifacts bucket; +- observability; +- secret bindings; +- optional custom domain. + +### Design + +The Worker is intentionally an async module because Think must export its Durable Object subclass. Alchemy still owns the worker, bindings, migrations, stages, and inferred `MaddoxEnv`. + +### Tests + +- synth/plan test for expected resource names and bindings; +- local `alchemy dev` health check; +- DO boot and SQLite migration test; +- a deployment smoke request to `/healthz`. + +### Exit criterion + +A fresh stage deploys and a named Think instance can be addressed. + +--- + +## 2. Effect runtime seam + +**Goal:** Think delegates application behavior to Effect without creating a second runtime architecture. + +### Work + +- Replace the placeholder Test Layer with `makeAppLayer(env)`. +- Build one reusable `ManagedRuntime` per active DO instance or isolate. +- Keep the runtime free of correctness-critical finalizers. +- Add adapters that map tagged Effect failures to: + - safe Think tool results; + - Action failures; + - Slack responses; + - redacted observability events. +- Create an adapter helper that converts a canonical Effect Schema to the Zod/AI SDK boundary, or explicitly maintains a tiny Zod boundary schema plus an immediate Effect decode. + +### Tests + +- layer construction from fake bindings; +- interruption and timeout behavior; +- defect redaction; +- no raw provider error crosses the seam. + +### Exit criterion + +A fake Think callback invokes an Effect service and returns a typed result. + +--- + +## 3. Slack ingress + +**Goal:** secure, fast, idempotent Slack Events API ingestion. + +### Work + +- Verify `X-Slack-Request-Timestamp` is within five minutes. +- Verify `v0` HMAC over the exact raw body. +- Decode the envelope with Effect Schema. +- Handle Slack URL verification. +- Ignore bot messages and unsupported events safely. +- Derive conversation identity from the verified body. +- Acknowledge before model execution. +- Use Slack `event_id` for: + - ingress deduplication; + - Think submission idempotency. +- Support direct messages and mentions based on explicit policy. +- Preserve thread metadata in submission metadata. + +### Tests + +- valid signature; +- bad signature; +- stale timestamp; +- malformed body; +- URL verification; +- bot/self-message; +- duplicate event; +- same thread maps to same agent; +- different threads map to different agents. + +### Exit criterion + +One Slack mention creates exactly one durable Think submission. + +--- + +## 4. Think conversation agent + +**Goal:** a durable, isolated company conversation per Slack thread. + +### Work + +- Configure Session context blocks: + - Maddox identity/SOUL; + - verified principal and channel; + - company conventions; + - current-task working memory; + - retrieved GBrain context. +- Configure context compaction and token budgets. +- Disable workspace Bash initially. +- Limit model steps and output tokens by channel policy. +- Persist only safe metadata. +- Define model fallback behavior. +- Implement partial-response and interruption handling. +- Expose submission status for operations. + +### Tests + +- session survives instance reconstruction; +- context does not cross threads; +- partial assistant message persists on failure; +- context overflow compacts and retries within bounds; +- unsupported tool or action fails safely. + +### Exit criterion + +The agent can complete and recover a conversational turn without any external write tools. + +--- + +## 5. Identity, authorization, and channel policy + +**Goal:** verified Slack identity becomes the authority for tool/action permissions. + +### Data + +- Slack team and user IDs; +- company user ID; +- role membership; +- channel policy; +- permission grants; +- effective dates and audit metadata. + +### Work + +- Registry schema and migrations. +- `Authorization.permissionsFor`. +- `Authorization.require`. +- Think `authorizeTurn` integration. +- Channel-specific prompt, model budget, and tool visibility. +- Administrative bootstrap path that does not depend on the model. + +### Tests + +- default deny; +- Brandon/admin role; +- public-channel restrictions; +- removed role takes effect; +- forged URL or header cannot change principal; +- action permission checked again at execution time. + +### Exit criterion + +A user can see/read only the capabilities granted for that channel and role. + +--- + +## 6. Slack delivery and reconciliation + +**Goal:** a completed turn reliably becomes one Slack thread response. + +### Work + +- `SlackDelivery.postReply` with typed Slack response decoding. +- Split long messages safely. +- Stable delivery idempotency key. +- Persist delivery states: pending, posting, posted, unknown, failed. +- Reconcile uncertain Slack API outcomes. +- Post a safe interruption/failure message. +- Later: message updates for streaming, reactions, and interactive approvals. + +### Tests + +- duplicate completion posts once; +- transient 429 obeys retry-after; +- timeout after uncertain outcome becomes `unknown`; +- reconciliation finds an already-posted response; +- internal error details never reach Slack. + +### Exit criterion + +Model completion and Slack delivery can recover independently. + +--- + +## 7. Model routing + +**Goal:** provider-neutral quality/cost policy. + +### Work + +- `ModelRouter` Effect service. +- Cheap/default and capable/escalation tiers. +- Workers AI through the native binding and AI Gateway. +- Provider-specific adapter packages. +- Explicit model budgets per channel/task. +- Fallback only for classified retryable failures. +- Usage and cost attribution by thread, user, action, and model. +- Separate spike for Codex OAuth; do not make it a launch dependency. + +### Tests + +- deterministic tier selection; +- retryable primary failure uses fallback; +- authentication or policy failure does not silently fallback; +- budget cap blocks or downgrades safely; +- provider diagnostics are redacted. + +### Exit criterion + +Routine Slack work and deliberate escalation are observable and bounded. + +--- + +## 8. Read-only integrations + +Implement one adapter at a time behind a port and Test Layer. + +### GBrain recall + +- query and relevance schema; +- provenance returned with every memory; +- bounded result count and token size; +- current/live-system facts excluded by policy. + +### GitHub read + +- GitHub App installation token broker; +- repository allowlist; +- file/issue/PR/search operations; +- response and pagination bounds. + +### Linear read + +- issue/project/search; +- workspace allowlist; +- typed hosted-MCP or API adapter. + +### Sentry read + +- issue and event access; +- organization/project bounds; +- redact request data and user PII. + +### Card Madness data + +- domain-specific service binding; +- no arbitrary SQL; +- query limits, timeouts, and audit fields. + +### Acceptance for each adapter + +- unknown provider data decoded; +- deterministic fake; +- rate-limit and not-found distinctions; +- payload bounds; +- credentials never enter model-visible output. + +--- + +## 9. Think Actions + +**Goal:** all external writes are durable, authorized, idempotent, and reviewable. + +### First actions + +1. `gbrain.remember` +2. `gbrain.correct` +3. `linear.create_issue` +4. `linear.comment` +5. `github.create_branch` +6. `github.open_pull_request` +7. `skills.propose_change` + +### Every Action defines + +- Effect Schema input and output; +- AI SDK boundary schema; +- permission; +- idempotency-key derivation; +- approval mode; +- executor Effect; +- safe attachment/result; +- uncertain-outcome reconciliation; +- audit event. + +### Approval policy + +- low-risk, reversible writes may execute inline for trusted principals; +- GitHub/Linear writes can require Slack approval by channel; +- destructive or security-sensitive changes always durable-pause; +- permission is re-evaluated after approval before execution. + +### Tests + +- duplicate action executes once; +- denied principal never reaches provider; +- approval survives eviction; +- expired/revoked approval fails; +- uncertain provider outcome reconciles; +- action output is attached once. + +--- + +## 10. Company memory + +**Goal:** learn without confusing chat history, live state, and durable knowledge. + +### Working memory + +Think Session context: current task, recent conversation, summaries. + +### Organizational memory + +GBrain: + +- fact; +- decision; +- correction; +- preference; +- procedure; +- product concept; +- open question. + +### Required metadata + +- source/provenance; +- principal; +- scope; +- confidence; +- valid-from/valid-until; +- supersedes; +- reviewed state. + +### Write policy + +- explicit “remember this” can create an Action immediately; +- inferred memory is proposed or stored below an approval threshold; +- corrections supersede rather than append contradictions; +- credentials and current external-system snapshots are prohibited. + +### Reflection + +The weekly Think scheduled handler starts a bounded reflection process that: + +- reviews corrections and repeated failures; +- proposes memory corrections; +- proposes skill changes; +- never changes permissions or executable code. + +--- + +## 11. Skills + +**Goal:** preserve Hermes-like procedural learning without self-modifying production code. + +### Sources + +- bundled, Git-reviewed base skills; +- versioned R2 instruction skills; +- later, approved script resources. + +### Lifecycle + +- draft; +- reviewed; +- active; +- superseded; +- archived. + +### Initial constraints + +- dynamic instruction skills only; +- no networked or executable skill scripts; +- every change versioned and reversible; +- a generated skill cannot expand tool permissions; +- activation catalog is bounded and progressively disclosed. + +### Tools/actions + +- read/list skill: tool; +- propose create/update/archive: Action; +- promote to active: approval or Git review depending on risk. + +### Exit criterion + +Maddox can propose and activate a reversible instruction skill without a deploy. + +--- + +## 12. Local Hermes import + +See `hermes-import.md`. + +The importer is an administrative CLI/workflow, not part of Slack turns. + +### Imported candidates + +- SOUL/profile; +- selected skills and resources; +- curated durable memories; +- optional conversation summaries. + +### Never import + +- OAuth tokens; +- API keys; +- cookies; +- local databases wholesale; +- provider caches; +- broad filesystem state; +- old cloud deployment recovery snapshots. + +--- + +## 13. Workflows + +**Goal:** deterministic company processes with durable steps and waits. + +### Initial workflows + +- weekly company digest; +- issue/repository investigation; +- approved PR preparation; +- skill review/promotion. + +### Rules + +- Think decides or converses; +- Workflow orchestrates deterministic steps; +- Action records the external mutation; +- Workflow inputs/outputs are Effect Schema decoded; +- each step has an explicit retry and compensation posture. + +--- + +## 14. Sandbox and browser execution + +**Goal:** start a computer only when a task truly needs one. + +### Use cases + +- clone repository; +- run tests/builds; +- browser regression; +- native tools; +- implementation branch and PR. + +### Security + +- one sandbox per task; +- default-deny egress; +- short-lived scoped credentials; +- credentials injected by proxy/broker, not written to files; +- resource/time limits; +- artifact export to R2; +- teardown or sleep after completion. + +### Not in initial release + +The first release must prove the company agent and Action boundaries without a sandbox. + +--- + +## 15. Observability and operations + +### Correlation + +```text +Slack event +→ conversation ID +→ Think submission +→ model turn +→ tool/action +→ Workflow +→ Sandbox +→ external result +→ Slack delivery +``` + +### Work + +- Agent Tracing; +- structured Effect logs; +- request/submission/action IDs; +- model tokens and cost; +- action and delivery ledgers; +- SLOs and alerts; +- admin status endpoints; +- payload recording disabled or sampled by default. + +### Required runbooks + +- Slack delivery backlog; +- stuck submission; +- provider outage; +- action in unknown state; +- revoked integration; +- failed Alchemy deploy; +- emergency disable for all write actions. + +--- + +## Release sequence + +### Milestone A — conversational canary + +Components 0–6. No production writes. + +### Milestone B — useful read-only company agent + +Components 7–8 plus GBrain recall. + +### Milestone C — safe company operator + +Components 9–10 with approval and audit. + +### Milestone D — learning agent + +Components 11–12. + +### Milestone E — autonomous execution + +Components 13–14 after explicit threat review. diff --git a/scaffolds/maddox-think/migrations/0001_registry.sql b/scaffolds/maddox-think/migrations/0001_registry.sql new file mode 100644 index 0000000..92dd961 --- /dev/null +++ b/scaffolds/maddox-think/migrations/0001_registry.sql @@ -0,0 +1,41 @@ +-- Deployment-level registry only. Think owns its per-conversation SQLite schema. +CREATE TABLE IF NOT EXISTS slack_principal ( + team_id TEXT NOT NULL, + user_id TEXT NOT NULL, + company_user_id TEXT, + display_name TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (team_id, user_id) +); + +CREATE TABLE IF NOT EXISTS role_grant ( + team_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + granted_by TEXT NOT NULL, + granted_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (team_id, user_id, role) +); + +CREATE TABLE IF NOT EXISTS channel_policy ( + team_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + policy_json TEXT NOT NULL, + updated_by TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (team_id, channel_id) +); + +CREATE TABLE IF NOT EXISTS conversation_directory ( + conversation_id TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + thread_ts TEXT NOT NULL, + created_at TEXT NOT NULL, + last_event_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS conversation_directory_by_channel + ON conversation_directory (team_id, channel_id, last_event_at); diff --git a/scaffolds/maddox-think/package.json b/scaffolds/maddox-think/package.json new file mode 100644 index 0000000..984a69f --- /dev/null +++ b/scaffolds/maddox-think/package.json @@ -0,0 +1,40 @@ +{ + "name": "maddox-think", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@10.23.0", + "engines": { + "node": ">=22" + }, + "scripts": { + "alchemy:dev": "alchemy dev", + "alchemy:deploy": "alchemy deploy", + "alchemy:destroy": "alchemy destroy", + "typecheck": "tsc --noEmit --pretty false", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest", + "lint": "oxlint . && prettier --check .", + "format": "prettier --write .", + "check": "pnpm lint && pnpm typecheck && pnpm architecture:check && pnpm test", + "architecture:check": "tsx scripts/check-architecture.ts" + }, + "dependencies": { + "@cloudflare/think": "0.16.0", + "agents": "0.21.0", + "ai": "^7.0.0", + "effect": "rc", + "workers-ai-provider": "^4.0.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/workers-types": "5.20260821.1", + "@types/node": "^26.0.1", + "alchemy": "2.0.0-beta.74", + "oxlint": "^1.74.0", + "prettier": "^3.9.5", + "typescript": "^6.0.3", + "vitest": "^4.1.10", + "tsx": "^4.23.1" + } +} diff --git a/scaffolds/maddox-think/packages/contracts/package.json b/scaffolds/maddox-think/packages/contracts/package.json new file mode 100644 index 0000000..6a43a8f --- /dev/null +++ b/scaffolds/maddox-think/packages/contracts/package.json @@ -0,0 +1,11 @@ +{ + "name": "@maddox/contracts", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "effect": "rc" + } +} diff --git a/scaffolds/maddox-think/packages/contracts/src/index.ts b/scaffolds/maddox-think/packages/contracts/src/index.ts new file mode 100644 index 0000000..886d6d4 --- /dev/null +++ b/scaffolds/maddox-think/packages/contracts/src/index.ts @@ -0,0 +1,226 @@ +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const ConversationId = Schema.String.pipe(Schema.brand("ConversationId")); +export type ConversationId = typeof ConversationId.Type; + +export const SlackPrincipal = Schema.Struct({ + channelId: Schema.String, + teamId: Schema.String, + threadTs: Schema.String, + userId: Schema.String, +}); +export type SlackPrincipal = typeof SlackPrincipal.Type; + +export const Permission = Schema.Literal( + "company.read", + "gbrain.write", + "github.write", + "linear.write", + "skills.write", +); +export type Permission = typeof Permission.Type; + +export class SlackRequestRejected extends Schema.TaggedErrorClass()( + "SlackRequestRejected", + { + reason: Schema.Literal( + "invalid_body", + "invalid_signature", + "missing_identity", + "replayed_request", + "unsupported_event", + ), + statusCode: Schema.Number, + }, +) {} + +const SlackEnvelope = Schema.Struct({ + challenge: Schema.optional(Schema.String), + event: Schema.optional( + Schema.Struct({ + bot_id: Schema.optional(Schema.String), + channel: Schema.optional(Schema.String), + event_ts: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), + thread_ts: Schema.optional(Schema.String), + ts: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), + user: Schema.optional(Schema.String), + }), + ), + event_id: Schema.optional(Schema.String), + team_id: Schema.optional(Schema.String), + type: Schema.String, +}); + +export type DecodedSlackEvent = + | { + readonly kind: "url-verification"; + readonly challenge: string; + } + | { + readonly kind: "message"; + readonly channelId: string; + readonly conversationId: string; + readonly eventId: string; + readonly idempotencyKey: string; + readonly teamId: string; + readonly text: string; + readonly threadTs: string; + readonly userId: string; + }; + +const constantTimeEqual = (left: string, right: string): boolean => { + if (left.length !== right.length) return false; + let difference = 0; + for (let index = 0; index < left.length; index += 1) { + difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + } + return difference === 0; +}; + +const hmacHex = ( + secret: string, + value: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(value), + ); + return [...new Uint8Array(signature)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + }, + catch: () => + new SlackRequestRejected({ + reason: "invalid_signature", + statusCode: 401, + }), + }); + +export const decodeSlackEventRequest = ( + request: Request, + signingSecret: string, +): Effect.Effect => + Effect.gen(function* () { + const timestamp = request.headers.get("x-slack-request-timestamp"); + const signature = request.headers.get("x-slack-signature"); + if (!timestamp || !signature) { + return yield* new SlackRequestRejected({ + reason: "invalid_signature", + statusCode: 401, + }); + } + + const numericTimestamp = Number(timestamp); + const nowMillis = yield* Clock.currentTimeMillis; + const nowSeconds = Math.floor(nowMillis / 1_000); + if ( + !Number.isFinite(numericTimestamp) || + Math.abs(nowSeconds - numericTimestamp) > 300 + ) { + return yield* new SlackRequestRejected({ + reason: "replayed_request", + statusCode: 401, + }); + } + + const body = yield* Effect.tryPromise({ + try: () => request.text(), + catch: () => + new SlackRequestRejected({ + reason: "invalid_body", + statusCode: 400, + }), + }); + + const expected = `v0=${yield* hmacHex( + signingSecret, + `v0:${timestamp}:${body}`, + )}`; + if (!constantTimeEqual(expected, signature)) { + return yield* new SlackRequestRejected({ + reason: "invalid_signature", + statusCode: 401, + }); + } + + const unknownEnvelope = yield* Effect.try({ + try: () => JSON.parse(body) as unknown, + catch: () => + new SlackRequestRejected({ + reason: "invalid_body", + statusCode: 400, + }), + }); + const envelope = yield* Schema.decodeUnknown(SlackEnvelope)(unknownEnvelope).pipe( + Effect.mapError( + () => + new SlackRequestRejected({ + reason: "invalid_body", + statusCode: 400, + }), + ), + ); + + if (envelope.type === "url_verification" && envelope.challenge) { + return { kind: "url-verification", challenge: envelope.challenge }; + } + + const event = envelope.event; + if ( + envelope.type !== "event_callback" || + !event || + event.bot_id || + event.type !== "app_mention" || + !envelope.event_id || + !envelope.team_id || + !event.channel || + !event.user || + !event.text + ) { + return yield* new SlackRequestRejected({ + reason: "unsupported_event", + statusCode: 202, + }); + } + + const threadTs = event.thread_ts ?? event.ts ?? event.event_ts; + if (!threadTs) { + return yield* new SlackRequestRejected({ + reason: "missing_identity", + statusCode: 400, + }); + } + + const conversationId = [ + "slack", + envelope.team_id, + event.channel, + threadTs, + ].join(":"); + + return { + kind: "message", + channelId: event.channel, + conversationId, + eventId: envelope.event_id, + idempotencyKey: `slack:${envelope.event_id}`, + teamId: envelope.team_id, + text: event.text, + threadTs, + userId: event.user, + }; + }); diff --git a/scaffolds/maddox-think/packages/core/package.json b/scaffolds/maddox-think/packages/core/package.json new file mode 100644 index 0000000..e45c35c --- /dev/null +++ b/scaffolds/maddox-think/packages/core/package.json @@ -0,0 +1,12 @@ +{ + "name": "@maddox/core", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@maddox/contracts": "workspace:*", + "effect": "rc" + } +} diff --git a/scaffolds/maddox-think/packages/core/src/index.ts b/scaffolds/maddox-think/packages/core/src/index.ts new file mode 100644 index 0000000..2bcf8e2 --- /dev/null +++ b/scaffolds/maddox-think/packages/core/src/index.ts @@ -0,0 +1,90 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type { Permission, SlackPrincipal } from "@maddox/contracts"; + +export class AuthorizationDenied extends Schema.TaggedErrorClass()( + "AuthorizationDenied", + { + permission: Schema.String, + principalId: Schema.String, + }, +) {} + +export class IntegrationFailure extends Schema.TaggedErrorClass()( + "IntegrationFailure", + { + integration: Schema.String, + operation: Schema.String, + reason: Schema.String, + retryable: Schema.Boolean, + }, +) {} + +export class Authorization extends Context.Service< + Authorization, + { + readonly permissionsFor: ( + principal: SlackPrincipal, + ) => Effect.Effect, IntegrationFailure>; + readonly require: ( + principal: SlackPrincipal, + permission: Permission, + ) => Effect.Effect; + } +>()("@maddox/core/Authorization") {} + +export class CompanyMemory extends Context.Service< + CompanyMemory, + { + readonly recall: ( + query: string, + ) => Effect.Effect< + ReadonlyArray<{ readonly id: string; readonly text: string }>, + IntegrationFailure + >; + readonly remember: (input: { + readonly idempotencyKey: string; + readonly principal: SlackPrincipal; + readonly text: string; + }) => Effect.Effect<{ readonly memoryId: string }, IntegrationFailure>; + } +>()("@maddox/core/CompanyMemory") {} + +export class SlackDelivery extends Context.Service< + SlackDelivery, + { + readonly postReply: (input: { + readonly channelId: string; + readonly idempotencyKey: string; + readonly text: string; + readonly threadTs: string; + }) => Effect.Effect; + } +>()("@maddox/core/SlackDelivery") {} + +export const AuthorizationTest = { + permissionsFor: () => + Effect.succeed( + new Set([ + "company.read", + "gbrain.write", + "github.write", + "linear.write", + "skills.write", + ]), + ), + require: () => Effect.void, +}; + +export const CompanyMemoryTest = { + recall: (query: string) => + Effect.succeed([{ id: "memory-test", text: `Relevant to: ${query}` }]), + remember: ({ idempotencyKey }: { readonly idempotencyKey: string }) => + Effect.succeed({ memoryId: `memory:${idempotencyKey}` }), +}; + +export const SlackDeliveryTest = { + postReply: () => Effect.void, +}; diff --git a/scaffolds/maddox-think/packages/runtime/package.json b/scaffolds/maddox-think/packages/runtime/package.json new file mode 100644 index 0000000..4dd71e8 --- /dev/null +++ b/scaffolds/maddox-think/packages/runtime/package.json @@ -0,0 +1,15 @@ +{ + "name": "@maddox/runtime", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@cloudflare/think": "0.16.0", + "@maddox/core": "workspace:*", + "ai": "^7.0.0", + "effect": "rc", + "zod": "^4.4.3" + } +} diff --git a/scaffolds/maddox-think/packages/runtime/src/index.ts b/scaffolds/maddox-think/packages/runtime/src/index.ts new file mode 100644 index 0000000..b0dd917 --- /dev/null +++ b/scaffolds/maddox-think/packages/runtime/src/index.ts @@ -0,0 +1,74 @@ +import { tool } from "ai"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import { z } from "zod"; + +import type { MaddoxEnv } from "../../../alchemy.run.ts"; +import { + Authorization, + AuthorizationTest, + CompanyMemory, + CompanyMemoryTest, + SlackDelivery, + SlackDeliveryTest, +} from "@maddox/core"; + +const AppLayer = Layer.mergeAll( + Layer.succeed(Authorization, AuthorizationTest), + Layer.succeed(CompanyMemory, CompanyMemoryTest), + Layer.succeed(SlackDelivery, SlackDeliveryTest), +); + +export type MaddoxRuntime = ReturnType; + +export const makeMaddoxRuntime = (_env: MaddoxEnv) => { + // Replace Test services one adapter at a time. Keeping this construction + // explicit prevents Think callbacks from becoming a second DI framework. + const runtime = ManagedRuntime.make(AppLayer); + + const runPromise = ( + effect: Effect.Effect, + ): Promise => runtime.runPromise(effect); + + return { + runPromise, + + reportDefect: (error: unknown): Promise => + runtime.runPromise( + Effect.logError("Unhandled Maddox defect").pipe( + Effect.annotateLogs({ errorType: typeof error }), + ), + ), + + runReflection: () => + Effect.logInfo( + "Running scheduled reflection placeholder; production uses a Workflow", + ), + + thinkTools: () => ({ + recall_company_memory: tool({ + description: + "Recall provenance-aware company knowledge relevant to a question.", + inputSchema: z.object({ query: z.string().min(3).max(2_000) }), + execute: ({ query }) => + runtime.runPromise( + Effect.gen(function* () { + const memory = yield* CompanyMemory; + return yield* memory.recall(query); + }), + ), + }), + }), + + thinkActions: () => ({ + // First production actions: + // - remember_company_fact + // - linear_create_issue + // - github_open_pull_request + // + // Each action must declare permission, idempotency, approval policy, + // and a safe attachment/result shape before being enabled. + }), + }; +}; diff --git a/scaffolds/maddox-think/pnpm-workspace.yaml b/scaffolds/maddox-think/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/scaffolds/maddox-think/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/scaffolds/maddox-think/scripts/check-architecture.ts b/scaffolds/maddox-think/scripts/check-architecture.ts new file mode 100644 index 0000000..48ff7a4 --- /dev/null +++ b/scaffolds/maddox-think/scripts/check-architecture.ts @@ -0,0 +1,66 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +const repositoryRoot = path.resolve(import.meta.dirname, ".."); + +const walk = async (directory: string): Promise> => { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries + .filter((entry) => entry.name !== "node_modules" && entry.name !== ".git") + .map((entry) => { + const absolute = path.join(directory, entry.name); + return entry.isDirectory() ? walk(absolute) : Promise.resolve([absolute]); + }), + ); + return nested.flat(); +}; + +const relative = (absolute: string) => + path.relative(repositoryRoot, absolute).split(path.sep).join("/"); + +const files = await walk(repositoryRoot); +const failures: string[] = []; + +for (const absolute of files) { + const file = relative(absolute); + + if (/^(wrangler\.(toml|json|jsonc)|.+\/wrangler\.(toml|json|jsonc))$/.test(file)) { + failures.push(`${file}: Alchemy is the only infrastructure authority`); + } + + if (!file.endsWith(".ts")) continue; + const source = await readFile(absolute, "utf8"); + + if ( + file === "apps/worker/src/maddox-agent.ts" && + /from\s+["'](?:@octokit|@linear|@sentry|pg|postgres|slack)["']/.test(source) + ) { + failures.push( + `${file}: integration SDKs belong behind Effect adapter packages`, + ); + } + + if ( + file !== "alchemy.run.ts" && + !file.startsWith("scripts/") && + /\bprocess\.env\b/.test(source) + ) { + failures.push(`${file}: environment access belongs at the config boundary`); + } + + if ( + !file.startsWith("test/") && + !file.startsWith("scripts/") && + /\bDate\.now\(\)/.test(source) + ) { + failures.push(`${file}: use the Effect Clock in application code`); + } +} + +if (failures.length > 0) { + console.error(failures.join("\n")); + process.exitCode = 1; +} else { + console.log("Architecture checks passed"); +} diff --git a/scaffolds/maddox-think/skills/company-operating-system/SKILL.md b/scaffolds/maddox-think/skills/company-operating-system/SKILL.md new file mode 100644 index 0000000..3f5814a --- /dev/null +++ b/scaffolds/maddox-think/skills/company-operating-system/SKILL.md @@ -0,0 +1,24 @@ +--- +name: company-operating-system +description: Apply Card Madness decision, evidence, and change-management conventions. +--- + +# Company operating system + +Use this skill when the user asks for a company decision, implementation plan, architecture recommendation, operational review, or change to a live external system. + +## Procedure + +1. Identify the decision, actor, affected system, and reversibility. +2. Retrieve live source-of-truth information before relying on memory. +3. Separate facts, assumptions, recommendations, and unresolved questions. +4. Prefer the smallest durable abstraction that solves the immediate problem. +5. For writes, explain the intended mutation and use an Action with the correct permission and idempotency key. +6. Record durable decisions in GBrain with provenance and supersession links. +7. Propose code or policy changes through Git rather than silently changing Maddox's security posture. + +## Safety + +- Never place credentials in prompts, workspace files, logs, memories, or tool output. +- Never treat remembered GitHub, Linear, Sentry, or production database state as current. +- Do not create executable skills automatically. diff --git a/scaffolds/maddox-think/tsconfig.json b/scaffolds/maddox-think/tsconfig.json new file mode 100644 index 0000000..ef64c5e --- /dev/null +++ b/scaffolds/maddox-think/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "exactOptionalPropertyTypes": true, + "lib": [ + "ES2023" + ], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": false, + "strict": true, + "target": "ES2023", + "types": [ + "@cloudflare/workers-types" + ] + }, + "include": [ + "alchemy.run.ts", + "apps/**/*.ts", + "packages/**/*.ts", + "test/**/*.ts" + ] +}