diff --git a/.github/ISSUE_TEMPLATE/kitchen.yml b/.github/ISSUE_TEMPLATE/kitchen.yml new file mode 100644 index 0000000..411669b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/kitchen.yml @@ -0,0 +1,36 @@ +name: Kitchen problem +description: Report a scene, provider connection or first-run issue in Agenttrail Kitchen. +labels: [kitchen, bug] +body: + - type: input + id: version + attributes: + label: Kitchen version + description: Include the release tag or source commit. + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: OS, Node version, browser and coding agent. + validations: + required: true + - type: dropdown + id: mode + attributes: + label: Which view is affected? + options: + - Live + - Example + - Both + - Installation or startup + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: What happened? + description: Steps to reproduce, expected behavior and actual behavior. Redact private paths and task titles. Do not attach native transcripts or credentials. + validations: + required: true diff --git a/.github/workflows/kitchen.yml b/.github/workflows/kitchen.yml new file mode 100644 index 0000000..26341f1 --- /dev/null +++ b/.github/workflows/kitchen.yml @@ -0,0 +1,43 @@ +name: Kitchen preview + +on: + push: + paths: + - 'packages/kitchen/**' + - 'docs/kitchen/**' + - 'examples/kitchen-workflow/**' + - '.github/workflows/kitchen.yml' + pull_request: + paths: + - 'packages/kitchen/**' + - 'docs/kitchen/**' + - 'examples/kitchen-workflow/**' + - '.github/workflows/kitchen.yml' + +permissions: + contents: read + +jobs: + verify: + strategy: + fail-fast: false + matrix: + node: [20, 22, 24] + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/kitchen + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: packages/kitchen/package-lock.json + - run: npm ci + - run: npm run build + - run: npm run check + - run: npm test + - run: npm pack + - run: node scripts/check-package.mjs ./agenttrail-kitchen-0.1.0-alpha.1.tgz + - run: node --check ../../bin/agenttrail.mjs diff --git a/.gitignore b/.gitignore index db662aa..6563c7f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ node_modules/ .DS_Store .claude/settings.local.json +promo-gifs/ +packages/kitchen/public/build/ +*.tgz +recordings/ +.runs/ +.office/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..96f4197 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing to Agenttrail + +Agenttrail has a lightweight project map and an experimental 3D kitchen. Both use the same repository and issue tracker. The map's existing command remains independent of the kitchen's graphics dependencies. + +## Find the relevant code + +| Area | Location | +| --- | --- | +| Map daemon, plans, hooks and fleet | `bin/agenttrail.mjs` | +| Map interface | `public/index.html` | +| Kitchen launcher and local relays | `packages/kitchen/bin/` | +| Provider adapters and workflow state | `packages/kitchen/src/` | +| Kitchen scene, characters and interface | `packages/kitchen/public/src/` | +| Kitchen automated checks | `packages/kitchen/test/` | + +The views currently have separate local services. The kitchen can read the map's local board context; it also has standalone observation. Consolidating the duplicated parsers and adapters is future work. Keep provider interpretation out of the scene renderer so future themes can reuse the evidence model. + +## Run and check the kitchen + +```sh +cd packages/kitchen +npm ci +npm run build +npm start -- --example +``` + +The example is scripted and labeled. Live provider validation should use your own disposable project. Do not install hooks into unrelated repos or submit real transcripts as test fixtures. + +```sh +npm run check +npm test +npm pack +node scripts/check-package.mjs ./agenttrail-kitchen-0.1.0-alpha.1.tgz +``` + +Keep fixtures synthetic, small and focused on a real failure mode. The package smoke check verifies a clean install with no development dependencies or lifecycle scripts. Run `node --check bin/agenttrail.mjs` at the repository root for changes touching the existing map daemon, and manually check the relevant view. + +## Preserve the meaning of activity + +- Sessions are actual executors; chefs are responsibilities. Never infer parallel execution from the number of visible chefs. +- Native todos remain the source of dish identity and completion. Never replace missing task progress with an invented checklist. +- A turn ending, tool finishing or file changing does not prove an outcome shipped. +- Explicit receipt metadata is required for confirmed artifact transfers. Clearly distinguish inferred associations. +- Keep pending human actions in the provider's own tool. This project observes; it does not approve or run those actions. + +## Report a problem + +Use the [issue tracker](https://github.com/sodiumsun/agenttrail/issues). Include the package version, OS, Node version, provider and a short reproduction. Describe whether the problem affects live data, example mode or both. Redact project paths, task titles and private data from screenshots. Useful labels are `kitchen`, `provider-adapter` and `first-run`. + +In a pull request, explain the trigger, resulting behavior and relevant checks. Include a screenshot or short silent clip for visible scene changes. Preserve the project's visual direction. New dependencies should have a concrete purpose and their license notices must accompany bundled assets. + +## License + +Project code and original assets are MIT. Preserve the root license and third-party notices. Do not add game assets, soundtrack recordings, personal logs, secrets or local connection state. Contributions must be yours to contribute under the relevant license. diff --git a/PLAN.md b/PLAN.md index 5e28aa1..375dd2e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -164,9 +164,37 @@ files: [bin/**, public/**] by: claude tech: overview+detail per the research — status-colored nodes, click to jump +## Show agents cooking together {#kitchen} +tech: optional kitchen package, local observers, workflow model and Three.js renderer +files: [packages/kitchen/**] +links: [plan-reader, runs, map] +- [x] Bring the runnable kitchen into this repository {#kitchen-import} + by: codex + from: agent +- [x] Make the preview install without a graphics build {#kitchen-package} + by: codex + from: agent +- [x] Verify the packaged kitchen in a clean folder {#kitchen-package-check} + by: codex + from: agent + ## Ship to GitHub and npm {#ship} needs: [map, explorer] -files: [README.md, docs/**, package.json] +files: [README.md, docs/**, package.json, CONTRIBUTING.md, examples/**, .github/**] +- [x] Explain how to try and contribute to the kitchen {#ship-kitchen-guide} + by: codex + from: agent +- [~] Publish a runnable experimental kitchen preview {#ship-kitchen-preview} + by: codex + from: agent +- [x] Propose how to release the kitchen inside Agenttrail {#ship-kitchen-structure} + by: codex + from: agent + tech: package boundaries, shared event model, public preview and announcement in docs/KITCHEN-RELEASE-PLAN.md +- [x] Plan the companion virtual agent office {#ship-office-plan} + by: codex + from: agent + tech: Research and visual alternatives in ../agent-office; planning only, no runtime changes - [x] Public repo and readme {#ship-repo} tech: github.com/sodiumsun/agenttrail + README.md - [x] Fresh demo gif of the current look {#ship-gif} @@ -204,6 +232,9 @@ files: [README.md, docs/**, package.json] tech: README definition, sentence-case headings, npm metadata, GitHub description and topics ## decisions +- 2026-09-08: The owner approved bringing Kitchen into Agenttrail and releasing an experimental preview. Add the kitchen component because the working scene, observers and packaging own packages/kitchen/** and the release now depends on them. Keep the existing map package unchanged; share more runtime code in subsequent work. Import runtime assets, tests and relevant docs, excluding personal logs, reference screenshots and music. A separate video-editing sub-agent is preparing smooth camera moves from the real footage. +- 2026-09-08: Prepare a release-structure recommendation for bringing the built kitchen into Agenttrail. The proposal keeps one repository and an optional kitchen package; package migration and publication are not part of this planning change. +- 2026-09-08: Explore a visually distinct virtual office in sibling ../agent-office. This session produces research, proposed architecture, must-build scope, and comparable visual concepts only. Keep proposed future components in the companion brief until implementation makes them real; do not add speculative components to the existing map. - 2026-08-30: cycles + kind: knowledge + card-setup graduated into the plan after the board flagged PLAN BEHIND — the observed layer caught an undeclared build burst - 2026-08-21: spine is the codebase (fs watcher + PLAN.md), not agent hooks; hooks become an optional fidelity adapter - 2026-08-21: serve index.html fresh per request (no startup cache) so UI edits land without daemon restart diff --git a/README.md b/README.md index 8f42e82..a27103c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,22 @@ The browser opens on the live board. On a repo with no plan it offers the full s That's it. No account, no global install, no telemetry. agenttrail opens on localhost and starts watching the repo. +## Watch your agents cook + +**[Agenttrail Kitchen](docs/kitchen/README.md)** is an experimental 3D view in this same project. Responsibilities become chefs, native todos become dishes, and completed tasks travel down a conveyor. Real Codex and Claude sessions have been exercised together; Cursor hooks have automated coverage, with native live validation pending. + +![Real coding-agent responsibilities working on a shared dish in Agenttrail Kitchen](docs/kitchen/preview.png) + +Kitchen is an optional package. The existing map command stays lightweight and independent. [Try the prebuilt preview](https://github.com/sodiumsun/agenttrail/releases/tag/kitchen-v0.1.0-alpha.1), or run from this repository: + +```bash +npm ci --prefix packages/kitchen +npm run build --prefix packages/kitchen +npm start --prefix packages/kitchen -- --project /absolute/path/to/your/repo +``` + +No running agent yet? Add `--example` to the last command for a labeled demonstration. Chefs represent responsibilities, so several chefs can belong to one actual session. Artifact transfers require explicit receipts; missing progress stays unknown. [Connection details](docs/kitchen/CONNECTING.md) · [Contribute](CONTRIBUTING.md) + ## How the live agent map works A plan says what the agent intends to do. The filesystem says what it actually touched. agenttrail shows both. @@ -80,7 +96,7 @@ The agent studies the code first, git history next, and planning prose last. It ## Local by construction -The daemon is one dependency-free Node file, about 470 lines. The interface is one static HTML file. There is no database, build step, cloud service, account, or telemetry. +The map's daemon is one dependency-free Node file. Its interface is one static HTML file. The map needs no database or build step. The optional kitchen has a separate graphics build, included in its release archive. Neither view needs a cloud service, account, or telemetry. It binds to **127.0.0.1 only**. Claude Code hooks live in the repo-local `.claude/settings.local.json` and relay events to the local daemon. While it runs, agenttrail only observes. It never sends a prompt or edits your code. diff --git a/docs/KITCHEN-RELEASE-PLAN.md b/docs/KITCHEN-RELEASE-PLAN.md new file mode 100644 index 0000000..3cdf15e --- /dev/null +++ b/docs/KITCHEN-RELEASE-PLAN.md @@ -0,0 +1,73 @@ +# Release Agenttrail Kitchen + +Status: approved and implemented for the first experimental preview. See [release details](kitchen/RELEASE.md) for verification and distribution status. + +## One project, two views + +Use **Agenttrail** as the project name and **Agenttrail Kitchen** as its optional 3D view. The map explains project structure and activity; the kitchen makes the same work visible as a collaborative cooking scene. Use one GitHub repository, issue tracker, documentation entry point, and contribution process. + +The map remains a small dependency-free Node package. The kitchen has been imported under `packages/kitchen`, with MIT licensing, a package file allowlist and its own Node service and Three.js build. It reads Agenttrail board data and also parses plans and provider activity itself. The views belong to one project but do not yet share one runtime. + +## Repository and installation + +For the first public preview, keep the existing map package in place and import the kitchen as an independently runnable package: + +```text +agenttrail/ + bin/ existing Agenttrail command + public/ existing map + packages/ + kitchen/ + bin/ kitchen launcher and local relays + src/ observers, event state and local service + public/ scene, characters and interface + scripts/ bundle the renderer + test/ + package.json + docs/ + kitchen/ quick start, connection limits, event model + examples/ + kitchen-workflow/ portable roles and a labeled example + LICENSE + CONTRIBUTING.md +``` + +The package is `agenttrail-kitchen@0.1.0-alpha.1`. The preview is distributed as a prebuilt GitHub release archive because npm registry authentication is currently unavailable. The short `npx agenttrail-kitchen` command must not be advertised as available until registry publication succeeds. The archive includes built graphics and local fonts, has an explicit file allowlist and has no runtime npm dependencies. The existing `npx agenttrail` package and command remain unchanged. + +After the optional package works, offer a convenient `agenttrail kitchen` launcher and Map/Kitchen navigation that preserve the selected repo. That launcher is a later interface change, not an existing command. + +## Shared meaning before more themes + +Consolidate the duplicated plan and provider handling incrementally after the import. Both views should eventually subscribe to one local event stream and model. Keep kitchen geometry, characters and animation separate from provider adapters and task state. A future space-station or fire-station view can reuse those events and change its scene and presentation. + +The shared model must distinguish: + +| Product fact | Kitchen presentation | +| --- | --- | +| Actual provider session/executor | Source of observed work; provider badge and session count | +| Project responsibility/role | Persistent chef; several roles may belong to one session | +| Ephemeral native todo | Dish and evolving order ticket | +| Durable component or outcome | Project map context or destination table; not fabricated todo progress | +| Observed contribution to a todo | Chef works on that dish | +| Explicit artifact revision and receipt | Plate transfer with provenance | + +Inferred roles remain labeled inferred. One session moving between roles does not establish parallel execution. Handoffs inferred from timing remain distinct from acknowledged artifact receipts. Missing native tasks or progress remain unknown. Switching views must not create new activity or start agents. + +## Make the preview useful to contributors + +1. Import only the runtime, tests, useful documentation and original project assets. Preserve the existing map behavior and package contents. +2. Apply the existing MIT license to code the owner is entitled to license; retain Three.js MIT and Nunito OFL notices. Keep the soundtrack compilation and game-reference screenshots outside the open-source distribution. +3. Provide one copyable quick start and an explicit example mode for people without agents running. Clearly label examples and replays. Test a packed package in a clean folder, not just the development checkout. +4. Publish a short compatibility table: Codex and Claude local integration were exercised in the demo; native Cursor validation is pending. Explicit handoff metadata is required for reliable artifact receipts. Remote sessions without local logs are not automatically visible. +5. Move only a sanitized version of the Maze Shift role/example configuration. Do not include `.runs`, personal paths, native transcripts, saved local connection state or the full raw recordings. +6. Run the existing kitchen checks plus install/launch verification, then publish an experimental release with a runnable source link. Make issue labels for kitchen visuals, provider adapters and first-run problems. + +## Announce it with the kitchen clip + +Use the 22-second kitchen-only video as the main post. Introduce the experience, identify the demo as real Codex and Claude work, and link the runnable preview and setup instructions. Invite people to try their own repos and report integration gaps. Use “Overcooked-inspired” as the inspiration, while retaining Agenttrail Kitchen as the product name. + +The music edit uses the owner's supplied MP3 from 10:25.000 to 10:46.833. Public promotional use needs to be covered by the owner's applicable music licence; an MIT software release does not grant rights to the soundtrack. Audio Network offers [social and promotional licensing](https://us.audionetwork.com/licensing) and sets out its [usage terms](https://www.audionetwork.com/terms-and-conditions). Use the silent cut or another suitably licensed track if that coverage is unavailable. + +Promotion videos are separate from the software distribution. Only the original kitchen screenshot is included in these docs; native logs, raw recordings and soundtrack files are excluded. + +Do not describe the kitchen as published until its code is in the public repository. A post made before then should call it a work-in-progress preview. diff --git a/docs/kitchen/CONNECTING.md b/docs/kitchen/CONNECTING.md new file mode 100644 index 0000000..44d498c --- /dev/null +++ b/docs/kitchen/CONNECTING.md @@ -0,0 +1,38 @@ +# Open a working repo + +Keep your existing agents running. In the kitchen, choose **Open repo**, select a recently active folder or paste its absolute path, and open its Live kitchen. It works with an ordinary local folder, including a Git repo, without a PLAN.md or Agenttrail installation. The companion reads the selected project; opening it does not write project files. + +After installing the preview, launch it from the working repo with `agenttrail-kitchen .`. From a source checkout, use `node /path/to/agenttrail/packages/kitchen/bin/office.mjs .` after building. An existing companion is reused through its authenticated local registration; the requested folder is added, selected, and opened in Live even if the browser previously showed Example. Repeat `--project /absolute/path` for multiple folders. Up to 12 roots can be watched. `--saved` reopens the saved set. See the [quick start](README.md) for the downloadable package command. + +## What connects automatically + +| Source | Without setup | Additional detail | +| --- | --- | --- | +| Local Codex sessions | Discovers recent session logs, including sessions resumed from an older creation-date folder. Reads available lifecycle, tool, structured `update_plan`, and desktop completed-item metadata. | No repo hooks required. Parsed reads, simple checks, browser work, and coordination provide role context. Unsupported operations remain unknown; they do not generate native todos. | +| Local Claude Code sessions | Reads available local project logs, lifecycle, supported tool events, and structured task metadata. | Connect Claude previews an additive hook configuration for more direct events. | +| Cursor | No automatic transcript discovery. | Connect Cursor once for the selected repo. Start a new conversation if the running one does not load the hooks. | + +The connection panel reports observations **for the selected repo**, including whether native todos are available. A provider being installed elsewhere does not establish an active connection to this repo. Native Codex and Claude Code have been checked together in the Maze Shift build. Claude’s confirmed text TaskCreate/TaskUpdate receipts are supported alongside structured results. Cursor adapters and reversible hooks have automated coverage; native Cursor live validation remains pending. + +Claude `TaskCreate`, `TaskUpdate`, `TaskGet`, and `TaskList` update dishes when a supported successful result supplies a stable task ID and status; task descriptions are discarded. Cursor partial todo updates preserve other items. The adapters also support legacy TodoWrite. Unknown output formats remain ordinary activity rather than guessed tasks. Event handling follows the documented [Claude Code hooks](https://code.claude.com/docs/en/hooks) and [Cursor hooks](https://cursor.com/docs/hooks); provider format changes may require adapter updates. + +## What you see + +- With no project map, the repo gets a persistent crew of inferred responsibilities. A single session moves between those chefs using observed file and operation context. No activity means the crew waits; creating roles does not start agents. +- With a project map, named role chefs remain available and actual sessions can move between those roles. A session without a reliable role association stays visible as its own session chef. Opening a repo does not fabricate additional workers or collaboration. +- Native todos become numbered dishes. Their source status controls cooking, completion, withdrawal, and reopening. Without a native plan, the available activity stays visible and progress remains unknown. +- Actual inter-agent artifact handoffs still require explicit artifact and receipt metadata. Reading a file alone does not prove a transfer. + +Use [workflow configuration](WORKFLOW-INTEGRATION.md) only when you want more precise role names or explicit role/order bindings. It is optional for basic observation. + +The default crew is adapted from a bounded scan of directory and file names, not from invented tasks: simulation folders add a Simulation engineer, world/character assets add a World builder, and writing folders add a Writer and optional Publisher. The UI marks these roles as inferred. The Head chef holds activity when a specialist role cannot be identified. A recorded operation can suggest responsibility but does not prove that a separately running specialist exists. Each chef retains its latest observed contribution while the actual session count stays separate. + +Codex desktop's structured `item_completed` records supply completed-operation context. Completed metadata is displayed as past activity and does not revive a finished session. The adapter excludes messages, reasoning, raw command bodies, and tool outputs. It recognizes structured parsed reads/searches, complete simple check commands, known preview tools, and coordination events. Other completed commands receive only a generic “Ran a command” label, replacing stale specialist context without interpreting arbitrary orchestration code. An observation alone does not create or complete a dish. + +## Local scope and discovery limits + +The companion reads a bounded metadata header from recent local Codex/Claude logs to suggest folder names, paths, providers, and last-seen times. Transcript event bodies are processed only after their folder falls within a watched root, and only allowlisted activity/task metadata reaches the browser. No prompts, code bodies, or arbitrary command text are displayed or uploaded. The browser uses bundled local assets. + +Default stores are `~/.codex/sessions` and `~/.claude/projects`; the production observer also honors `CODEX_HOME` and `CLAUDE_CONFIG_DIR`. Remote/cloud sessions whose logs are not on this machine are not automatically visible. + +Discovery refreshes about every five seconds. It considers files modified within 24 hours, examines up to 240 recent metadata candidates, retains up to 120 observation streams, and suggests up to 24 folders (eight in the picker). Each initial replay is bounded to the last 2 MiB. Larger archives can hit these bounds; `discoveryLimited` appears in the local state response. Missing older history stays unknown. Quiet activity is not presented as continuously working, and native orders are reconstructed from available observations after restart. diff --git a/docs/kitchen/HANDOFFS.md b/docs/kitchen/HANDOFFS.md new file mode 100644 index 0000000..e935f87 --- /dev/null +++ b/docs/kitchen/HANDOFFS.md @@ -0,0 +1,47 @@ +# Explicit plates and receipts + +Ordinary provider logs and hooks report agent activity. They do not currently establish artifact identity, revisions, or receipts. The kitchen provides an optional local metadata endpoint for an integration that has this evidence. It does not infer a handoff from two agents touching the same path. + +The endpoint accepts four kinds: `produced`, `offered`, `received`, and `failed`. Every event needs a unique event ID, watched project directory, producer provider/session, artifact ID, and exact revision ID. Transfer events additionally need a stable handoff ID and recipient provider/session. A receipt means the emitting integration attests that the recipient received that revision. + +```json +{ + "id": "event-unique-1", + "kind": "produced", + "cwd": "/absolute/project", + "provider": "codex", + "sessionId": "actual-producer-session", + "artifactId": "gallery-layout", + "revisionId": "sha256-exact-revision", + "type": "json", + "file": "layout.json", + "label": "Gallery layout" +} +``` + +Send this JSON on stdin to `node bin/plate.mjs`. Use `--state-dir /path/to/state` when running a separately configured service. The relay reads the local service registration; its connector key is never exposed in the browser. It does not read or hash the artifact body. The producing integration must provide a trustworthy revision. + +For a transfer, keep the artifact/producer fields identical, use a new event ID, and add: + +```json +{ + "kind": "received", + "handoffId": "handoff-unique-1", + "recipientProvider": "claude", + "recipientSessionId": "actual-recipient-session" +} +``` + +An offer can precede the receipt. A received event can also arrive first; a delayed offer will not rewind it. Terminal states cannot change identity or return to offered. Use a new handoff ID for a retry after failure. Multiple receivers use separate handoff IDs referencing the same artifact revision. + +Only whitelisted metadata enters the ledger. Raw bodies, prompts, and arbitrary extra fields are discarded. Paths must remain in the watched project. Known producer/recipient sessions cannot cross project boundaries. IDs, provider values, event kinds, duplicates, and changing transfer identities are validated. + +The service keeps up to 200 artifact revisions, 400 transfers, and 4,000 deduplication keys in memory. Restarting the service clears this ledger; the source integration must restore current metadata if needed. Browser reconnection restores the current service snapshot without replaying old animations. A new receipt briefly presents the plate arriving at its recipient's station, using the sender's position when visible or the central pass for an incoming cross-room receipt. This presentation lasts about 0.7 seconds and does not take over either chef's current action. Reduced motion places the plate directly; historical receipts are not replayed on room entry. The ledger updates immediately, including while motion is paused. + +Supported ingredient types are `json`, `image`, `text`, `code`, and `table`. Unknown types use a cloche. Native log-based revision matching, persistent artifact history, and provider-specific automatic receipt integrations are not implemented in this first release. + +## Attach inputs and outputs to a shared dish + +Add `orderId` to the first artifact event, using the exact ID from a native order's details or the local state API. The order must exist in the same watched project. The artifact revision retains that order link; later events cannot reassign it. Its inputs/outputs and receipts then appear in the dish's details. + +An explicitly bound producer role is captured when `produced` is observed. A recipient's explicit role is captured when the transfer event is observed. These historical labels survive later role changes, including Researcher → Writer within one Codex session. When the producer role was not observed, keep it unknown. Ordinary file activity still does not establish a receipt. diff --git a/docs/kitchen/KITCHENS.md b/docs/kitchen/KITCHENS.md new file mode 100644 index 0000000..7e133e9 --- /dev/null +++ b/docs/kitchen/KITCHENS.md @@ -0,0 +1,36 @@ +# One project, several kitchens + +The order rail follows ephemeral native todos. Durable Agenttrail components remain in **Project map**, where they describe ownership, dependencies and configured outcomes. A chef represents a persistent responsibility; actual sessions execute that work. [Shared dishes](SHARED-OUTCOMES.md) explains the distinction. + +## Group a larger project + +A workflow room holds up to eight components. Related roles share preparation, cooking, checking and assembly counters. Explicit room groups keep a large repo navigable without creating separate projects or duplicating its task history. Unlisted components remain visible in additional rooms; changing task status does not regroup rooms. + +Put this optional configuration in the watched repo's `.office/kitchen.json`: + +```json +{ + "version": 1, + "kitchens": [ + {"id":"main","title":"The product kitchen","components":["ui","api"]}, + {"id":"service","title":"The service kitchen","components":["jobs"]} + ], + "deliverables": [ + {"id":"launch","title":"Launch the gallery","icon":"image","tasks":["upload","gallery","thumbnail-job"]} + ] +} +``` + +`components` references stable PLAN.md component IDs; `tasks` references globally unique task IDs in that plan. These configured deliverables are project-map context, not a replacement for the native todo collection. Missing references leave completion unknown. Available icons are `map`, `image`, `network`, `gear` and `box`. + +The [portable role example](../../examples/kitchen-workflow) shows how one 3D project can retain a Researcher, World builder, Simulation engineer, Gameplay engineer, Reviewer and Producer in a shared kitchen. It contains configuration only and does not launch agents or supply activity. + +## Move between rooms + +A role has a stable home based on its first component. Keep that role's components together when authoring room groups. Actual session assignments determine which role is working; changing rooms does not move agents between tools or start execution. + +Room tabs show working counts and attention. Selecting related work can open another kitchen. Agent names, colors and badges persist; details retain session identities and evidence. The scene prioritizes working or urgent chefs if more than twelve are available, while the roster retains the others. + +Plate histories preserve sender, recipient and revision identities across kitchens. A newly received artifact can animate at the receiving station. Room entry and reconnection do not replay historical receipts. Ordinary file activity does not prove a transfer. + +The first preview displays at most twelve chefs and eight individual artifact plates per room. Room layout editing, dragging components between rooms and cross-machine observation are future work. diff --git a/docs/kitchen/README.md b/docs/kitchen/README.md new file mode 100644 index 0000000..1861afb --- /dev/null +++ b/docs/kitchen/README.md @@ -0,0 +1,68 @@ +# Agenttrail Kitchen + +Watch coding agents work through a shared 3D kitchen. Agenttrail's map shows project structure; Kitchen shows the current work as chefs, order tickets, ingredients and deliveries. Both belong to the same open-source project. Kitchen is an experimental, independently installed package. + +![Chefs contributing to a shared task in a recorded Codex and Claude collaboration](preview.png) + +## Start with your repo + +Requires Node.js 20 or newer and a WebGL browser. + +From a checkout of Agenttrail: + +```sh +npm ci --prefix packages/kitchen +npm run build --prefix packages/kitchen +npm start --prefix packages/kitchen -- --project /absolute/path/to/your/repo +``` + +Or, from your working repo, run the prebuilt archive from the [experimental release](https://github.com/sodiumsun/agenttrail/releases/tag/kitchen-v0.1.0-alpha.1): + +```sh +npm exec --yes --package=https://github.com/sodiumsun/agenttrail/releases/download/kitchen-v0.1.0-alpha.1/agenttrail-kitchen-0.1.0-alpha.1.tgz -- agenttrail-kitchen . +``` + +The browser opens at localhost:4780 or the next available port. Choose **Open repo** to switch folders. You can watch up to 12 roots without restarting agents. No PLAN.md is required; available native activity supplies the view. The source map command, `npx agenttrail`, keeps its existing behavior and installation footprint. + +## Explore without running an agent + +Add `--example` to the kitchen command, or click **Example** in the app. **Next example step** advances a labeled, scripted illustration of one session changing responsibilities while contributing to shared orders. It is not a recording of real providers. Click **Live** to return to the selected folder's observations. + +## Read the kitchen + +| What you see | What it means | +| --- | --- | +| Chef | A project responsibility, with actual assigned sessions visible in details | +| Order ticket and dish | An ephemeral native todo, following its reported wording and status | +| Chef cooking | Current observed work associated with that responsibility and dish | +| Ingredient plate | A recorded artifact revision or a clearly labeled workflow item | +| Plate transfer | Explicit receipt metadata; not a guess from matching filenames | +| Delivery conveyor | A newly completed native todo, not proof of a deployment or publication | +| Project map | Durable components, dependencies and configured outcomes | + +One session can work through several chefs sequentially. Several sessions can contribute to one explicitly shared order. Similar todo titles alone do not establish collaboration. Inferred role matches stay labeled inferred. No native todo list means progress unknown. + +Click a chef or ticket to inspect the evidence. Drag to pan, scroll to zoom, and use **Fit kitchen** or **Room view**. Motion pause and reduced motion stop decorative movement while live text still updates. + +## Provider support + +| Provider | Connection | Verification | +| --- | --- | --- | +| Codex | Experimental local logs | Real local collaboration exercised | +| Claude Code | Local logs; optional hooks | Real collaboration and native task receipts exercised | +| Cursor | Optional hooks | Automated coverage; native live validation pending | + +Formats can change, and missing observations remain unknown. Only sessions with accessible local metadata can be discovered. [Connection details](CONNECTING.md) explain discovery limits and optional hook setup. + +## Customize and contribute + +An optional `.office/kitchen.json` names responsibilities and groups rooms. The [portable workflow example](../../examples/kitchen-workflow) contains role/file mappings with no personal sessions or paths. Opening a project does not create this configuration automatically. + +- [Shared dishes and native planning](SHARED-OUTCOMES.md) +- [Workflow configuration and role bindings](WORKFLOW-INTEGRATION.md) +- [Multiple kitchens](KITCHENS.md) +- [Explicit artifact receipts](HANDOFFS.md) +- [Contributing and checks](../../CONTRIBUTING.md) +- [Release notes](RELEASE.md) + +The package is built from original project geometry and animation, using Three.js and locally bundled Nunito fonts. See [licenses and notices](../../packages/kitchen/docs/THIRD-PARTY.md). Soundtrack recordings and game-reference screenshots are excluded from the code distribution. diff --git a/docs/kitchen/RELEASE.md b/docs/kitchen/RELEASE.md new file mode 100644 index 0000000..dbe3e38 --- /dev/null +++ b/docs/kitchen/RELEASE.md @@ -0,0 +1,36 @@ +# Kitchen preview 0.1.0-alpha.1 + +Agenttrail Kitchen is now part of the Agenttrail source tree under `packages/kitchen`. This is an experimental preview of the existing festival kitchen, not a rewrite of the map. + +## Included + +- Local 3D kitchen with project-adapted chefs, native todo dishes, shared contributions, a delivery conveyor and explicit artifact receipts. +- Standalone repo selection and optional integration with an existing Agenttrail board. +- Experimental local Codex and Claude adapters, plus optional Claude/Cursor hooks with reviewable additive setup. +- Labeled Example mode, also accessible with `--example`. +- Prebuilt graphics, local fonts, original project assets and license notices in the installable archive. +- Portable role configuration, contributor guide, issue template and a clean-package smoke check. + +The existing map daemon, interface and package metadata are unchanged. The root README now links the optional kitchen. + +## Verification + +- Graphics build and syntax checks pass. +- All 71 kitchen tests pass, including native task receipts, role/session identity, shared orders, explicit handoffs, path isolation and connector setup. +- The archive installs offline into a fresh folder with no lifecycle scripts or development dependencies. Its installed launcher attaches a folder containing spaces and shell metacharacters correctly, serves its bundled assets and opens the labeled example URL. +- Browser verification from the isolated installed archive shows the 3D scene, a Researcher → Writer contribution on the same dish, and completion moving that dish to the deliverable collection. The scene correctly reports five roles and one example session. +- The 71 tests and clean-package check also pass in CI on Linux with Node 20, 22 and 24. See the release's associated commit checks for their results. + +Earlier native Codex/Claude collaboration was recorded before this import. Native Cursor validation and Windows-specific browser/CLI validation remain pending. No broader support claim is implied by the example mode. + +## Distribution + +The first preview uses a [GitHub prerelease](https://github.com/sodiumsun/agenttrail/releases/tag/kitchen-v0.1.0-alpha.1) with an installable `agenttrail-kitchen-0.1.0-alpha.1.tgz`. npm authentication is currently unavailable, so the registry package and short `npx agenttrail-kitchen` command are not yet published. + +The archive has no runtime npm dependencies. Its built browser bundle includes Three.js; fonts retain their OFL license. It excludes tests, development scripts, original source scene modules, native transcripts, personal configuration, raw recordings, music and reference screenshots. Complete editable source and tests remain in the repository. + +## Known limits + +Roles are not independent processes. Native todos are shown only when available. Inter-agent artifact transfers require explicit revision/receipt metadata. Logs are local and observation is bounded; cloud sessions without local logs are not discovered automatically. Order and artifact history is currently in memory and restarts reconstruct only available observations. + +Future work includes shared map/kitchen event handling, native Cursor validation and additional visual themes. These are not required to run this preview. diff --git a/docs/kitchen/SHARED-OUTCOMES.md b/docs/kitchen/SHARED-OUTCOMES.md new file mode 100644 index 0000000..7fcfeb9 --- /dev/null +++ b/docs/kitchen/SHARED-OUTCOMES.md @@ -0,0 +1,37 @@ +# Cook todos together + +Each ephemeral native todo is one dish. Project-adapted chefs contribute to it as real sessions work through their responsibilities. + +## Follow the orders + +A new native todo is an order ticket and a dish to prepare. Several chefs can contribute to the same dish as the executing agents change responsibilities. Ingredients and intermediate outputs belong to that work; changing roles does not create another dish. + +The top rail follows the agent's current todo list, including its wording, native order, additions, completions, removals, and reopening. It does not use permanent PLAN.md components as orders or invent a fixed Research → Write → Evaluate pipeline. Components remain available in the project map for ownership and context. + +A completed todo travels along a conveyor to the shared deliverable table. That table collects completed contributions toward the larger outcome. It is not evidence that anything was deployed, published, or externally delivered. Only the source can report that result. A turn ending is not a completed dish. + +## Share the work + +Counters describe shared capabilities: preparation, cooking, checking, assembly, and the pass. Chef colors identify responsibilities, while a separate order number follows a dish, its ticket, its contributing chefs, and its destination table. + +For a native todo named “Prepare the weekly post,” Researcher can gather references, Writer can use them to draft the post, and Evaluator can review it. All three contributions stay on the same order. If the native agent instead creates three separate todos, show three dishes; the kitchen must not rewrite the source plan to fit its metaphor. + +An observed role transition within one session shows sequential contributions. Separate executors can work simultaneously when that is observed. Keep past contributors visible on the ticket and in details without animating them as currently working. A minimum of two active chefs must not be fabricated for a solo or unlinked task. + +Artifact receipts and responsibility changes are different evidence. An order can show the same session switching roles without claiming a file was explicitly transferred. Plates representing native todos are labeled as work; artifact plates preserve their actual sender, recipient, revision, and source. + +## Preserve native planning + +Keep source scope and stable native task identifiers where available. Within a source plan, unchanged unique titles can retain identity when IDs are absent. Ambiguous or renamed tasks without IDs become removed/new orders; never silently merge them. Reordered tasks keep their identity. Removed tasks are withdrawn, not completed. Reopened tasks leave the completed collection and return to the rail. History remains available in details. + +Use explicit plan/outcome metadata where available. Otherwise group the session's dishes under the project and identify the larger outcome as unreported. Similar wording across sessions does not prove a shared task; cross-session contribution requires an explicit order binding. + +When no native plan is exposed, show available request/activity and unknown progress. Do not suggest or generate a breakdown. Current connectors do not extract user prompts; without a source title, show “Current work” and the literal observed activity. Never replace missing todos with the durable project checklist. + +## Build and verify + +Keep a bounded in-memory order history with native revisions and observed role contributions. Native state drives all movement; the conveyor animates newly observed completion, avoids replaying old completions on initial load, and respects pause/reduced motion. Reopening or withdrawal cancels pending presentation of completion. + +The deliverable table is accessible through a screen-space control as well as its 3D model. Its details list current completed orders and preserve withdrawn/reopened history. Project components and workflow draft queues remain available separately. + +Checks cover native plan changes, role changes on one order, explicitly shared orders across sessions, ambiguous task titles, turn completion without todo completion, no-plan fallback and conveyor completion/reopening. Verify keyboard selection and narrow screens when changing the interface. diff --git a/docs/kitchen/WORKFLOW-INTEGRATION.md b/docs/kitchen/WORKFLOW-INTEGRATION.md new file mode 100644 index 0000000..44c223b --- /dev/null +++ b/docs/kitchen/WORKFLOW-INTEGRATION.md @@ -0,0 +1,81 @@ +> Current order presentation: ephemeral native todos now appear as shared dishes, with a completion conveyor and deliverable table. Permanent components are background context. See [SHARED-OUTCOMES.md](SHARED-OUTCOMES.md). + +# Connect a workflow to its chefs + +Any local repo can be opened without a project map; it receives a persistent crew adapted from its structure, with inferred responsibility assignments. Sessions supply execution evidence and can move between several chefs. When a map is present, its persistent roles take precedence. The Reddit component map produces Researcher, Writer, Evaluator, Publisher, and Head chef, plus human review and knowledge counters. Other mapped projects receive roles from their own components. See [Open a working repo](CONNECTING.md) for the basic connection flow. + +## Configure names and responsibilities + +The optional `.office/kitchen.json` retains version 1. Add a `workflow` object alongside the existing `kitchens` and `deliverables` arrays: + +```json +{ + "version": 1, + "kitchens": [], + "deliverables": [], + "workflow": { + "id": "project", + "title": "Build the gallery", + "roles": [ + {"id": "designer", "title": "Designer", "components": ["gallery", "uploads"]}, + {"id": "reviewer", "title": "Reviewer", "components": ["review"]} + ] + } +} +``` + +Component IDs must exist in PLAN.md. Unclaimed ordinary components receive their own roles, so a partial configuration cannot hide work. Human components never become chefs; knowledge components become pantry counters and may also be included among a role's responsibilities. Invalid/duplicate role entries are skipped; their unclaimed components use the fallback mapping. + +For a repo without PLAN.md, the configuration can instead declare roles with `id`, `title`, `description`, `files` (relative glob patterns), and an optional `category` of `coordinate`, `research`, `build`, `simulation`, `review`, or `publish`. Keep `version: 1`, `kitchens: []`, and `deliverables: []`. Explicit roles override the inferred crew; `workflow: false` deliberately opts back into session-only presentation. Opening a repo never writes this file automatically. + +An empty `kitchens` array uses one coherent group, with up to eight components per room. Explicit room groups remain supported. A role has one home based on its first component; keep that role's components together when authoring room groups. The scene adds counters at the same scale, up to eight station positions. A room without an explicit workflow map can still use the legacy session presentation by setting `"workflow": false`. + +## Automatic association + +An explicit role binding takes precedence. Otherwise the kitchen uses a unique exact match between the current session task and a plan task, then the available component association. Those automatic links remain labeled inferred. File and board conflicts stay uncertain. + +Without components, a unique role file pattern wins, followed by a supported operation category. The Head chef holds unclassified activity when that role exists. Recent completed desktop operations add context and a per-chef last contribution; they do not start an executor or claim simultaneous work. One session has at most one current chef assignment. All inferred roles share the same native order collection, so switching roles never duplicates the dish. + +The normal snapshot includes both `crew` (the presentation roles, plus unlinked session entries) and `executors` (the original observed sessions). Each role carries its own stable ID, owned components, current assignments, source evidence, and working count. Its visible name and color remain stable as sessions change. A role shared by two sessions still has one character and exposes both assignments in details. + +Unlinked sessions appear both in the roster and as named session chefs on the floor until a project role is known. The scene prioritizes working/urgent chefs when more than twelve are available; the roster keeps the rest. Empty roles remain visible without pretending to be running. A role covering several goals shows live activity on the associated goal, rather than claiming that every owned goal is active. + +## Report an explicit role + +For orchestration that cannot be identified from ordinary tool/file metadata, send a role event after the session has been observed. `bin/role.mjs` reads JSON from stdin and uses the existing local connector registration: + +```json +{ + "provider": "codex", + "sessionId": "the-observed-session-id", + "cwd": "/absolute/path/to/watched/project", + "workflowId": "project", + "roleId": "writer", + "runId": "optional-cycle-id", + "itemId": "optional-work-item-id" +} +``` + +Pass that JSON to `node bin/role.mjs`, optionally with `--state-dir /path/to/state`. Supported providers are codex, claude, and cursor. Use the unprefixed `sessionId` from `executors`, rather than the composite `id`. + +Send another binding when the session changes role. Send `roleId: null` to clear it. The event changes the display association only: it neither starts work nor refreshes a stale execution signal. A native turn/tool event still determines the actual activity. Cross-project bindings and bindings for unobserved sessions are rejected. Unknown configured role IDs remain visibly unlinked. + +Bindings are in memory; integrations should announce the current role after the companion restarts. Role character identities persist in the browser. Optional run/item identifiers are retained as metadata; this version does not derive batch completion or provide a run-filtered queue from them. + +## Read the Reddit queue + +The local adapter reads bounded draft frontmatter, matching evaluation verdicts, and the recorded dispatch block. The browser receives titles, paths, item states, and revision digests; draft/evaluation bodies stay on the local server. Cached reads update when files change. Files resolving outside the project are excluded. + +The queue distinguishes revision, evaluation, human review, dispatch, history, and unknown state. Recorded posting is labeled as such, rather than being declared verified delivery. Schedule and approval fields are observations, not permission to publish. Human review opens the existing console; there is no approval or posting action in the kitchen. + +Counts cover the recorded queue, independently of PLAN checkboxes. The view is bounded to 500 files and labels limited/unavailable input. The digest identifies title/body changes; if a previously observed draft changes without a new evaluation, it returns to evaluation. This is an in-memory freshness check, not a replacement for revision-aware approval in the source workflow. + +Individual plates expose item details. The room displays at most eight plates; the queue retains the rest. Reading files establishes item state, not a proven handoff between actors. Explicit artifact transfers keep their original session identities and are never rewritten to whichever role that session serves now. Native provider role/receipt coverage remains dependent on emitted metadata. + +## Controls + +Select a chef to inspect its responsibilities and executing sessions. Select a goal to find its contributing role. Room view gives the scene more space; Show goals brings the tickets back. Motion pause preserves live text updates. The festival art, level camera, and uniform object scale remain unchanged; the transparent canvas shares one continuous page backdrop. + +### Share a native order + +The role relay additionally accepts `orderId`, copied from the order's details. This explicitly associates that executing session with the same dish, including when another session owns the native todo. Cross-project links are ignored. The native todo's owner still controls its status; role bindings do not complete work. Bindings and contribution history are currently in memory. diff --git a/docs/kitchen/preview.png b/docs/kitchen/preview.png new file mode 100644 index 0000000..3e3b3ce Binary files /dev/null and b/docs/kitchen/preview.png differ diff --git a/examples/kitchen-workflow/README.md b/examples/kitchen-workflow/README.md new file mode 100644 index 0000000..e560c3b --- /dev/null +++ b/examples/kitchen-workflow/README.md @@ -0,0 +1,9 @@ +# A crew for a small 3D project + +`kitchen.json` adapts the role layout used in the recorded collaboration: Researcher, World builder, Simulation engineer, Gameplay engineer, Reviewer and Producer. It contains no real session IDs, saved connection state or personal paths. + +To use it, copy this file to `.office/kitchen.json` in your own repo and adjust its component IDs and relative file patterns. Existing files should be reviewed before replacement. The component IDs refer to the durable headings in your PLAN.md. If your repo has no plan, use `"kitchens": []`, remove the role `components` arrays, and retain the role `files` mappings. + +This config only describes responsibilities. It does not launch six agents, create todos or emit handoffs. Your actual sessions and supported native task metadata determine which chefs work and which dishes appear. + +For an immediately active demonstration without any agent session, open the kitchen's labeled **Example** mode. For genuine multi-session contributions to a shared todo, see [explicit order bindings](../../docs/kitchen/WORKFLOW-INTEGRATION.md#share-a-native-order) and [artifact receipts](../../docs/kitchen/HANDOFFS.md). diff --git a/examples/kitchen-workflow/kitchen.json b/examples/kitchen-workflow/kitchen.json new file mode 100644 index 0000000..c2b0171 --- /dev/null +++ b/examples/kitchen-workflow/kitchen.json @@ -0,0 +1,88 @@ +{ + "version": 1, + "kitchens": [ + { + "id": "shared", + "title": "Maze Shift", + "components": [ + "research", + "world", + "simulation", + "gameplay", + "review", + "launch" + ] + } + ], + "deliverables": [], + "workflow": { + "id": "maze-shift", + "title": "Ship Maze Shift", + "roles": [ + { + "id": "researcher", + "title": "Researcher", + "components": [ + "research" + ], + "files": [ + "research/**", + "docs/CONTRACT.md" + ] + }, + { + "id": "world-builder", + "title": "World builder", + "components": [ + "world" + ], + "files": [ + "src/world.js" + ] + }, + { + "id": "simulation-engineer", + "title": "Simulation engineer", + "components": [ + "simulation" + ], + "files": [ + "src/sim.js", + "src/maze.js" + ] + }, + { + "id": "gameplay-engineer", + "title": "Gameplay engineer", + "components": [ + "gameplay" + ], + "files": [ + "src/main.js", + "public/**" + ] + }, + { + "id": "reviewer", + "title": "Reviewer", + "components": [ + "review" + ], + "files": [ + "tests/**", + "docs/REVIEW.md" + ] + }, + { + "id": "producer", + "title": "Producer", + "components": [ + "launch" + ], + "files": [ + "scripts/**" + ] + } + ] + } +} \ No newline at end of file diff --git a/packages/kitchen/LICENSE b/packages/kitchen/LICENSE new file mode 100644 index 0000000..7babb01 --- /dev/null +++ b/packages/kitchen/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kelly Sun + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/kitchen/README.md b/packages/kitchen/README.md new file mode 100644 index 0000000..959de7a --- /dev/null +++ b/packages/kitchen/README.md @@ -0,0 +1,67 @@ +# Agenttrail Kitchen + +Your coding agents, cooking together. An experimental 3D view in [Agenttrail](https://github.com/sodiumsun/agenttrail), a local, open-source monitor for coding agents. + +Native todos become order tickets, project responsibilities become chefs, and completed todos travel to the deliverable table. Several chefs can contribute to one dish. A chef is a role, so one actual session can work through several chefs; the interface keeps session counts separate. Confirmed artifact receipts can animate plates moving between contributors. + +## Try the preview + +Requires Node.js 20 or newer and a browser with WebGL. From the repo you want to watch, run the prebuilt [Kitchen preview release](https://github.com/sodiumsun/agenttrail/releases/tag/kitchen-v0.1.0-alpha.1): + +```sh +npm exec --yes --package=https://github.com/sodiumsun/agenttrail/releases/download/kitchen-v0.1.0-alpha.1/agenttrail-kitchen-0.1.0-alpha.1.tgz -- agenttrail-kitchen . +``` + +The published archive includes the graphics bundle and local fonts. It has no runtime npm dependencies and needs no graphics build. The short npm registry command is not available until this package is published there. + +The browser opens on localhost, using port 4780 or the next available port. Keep your agents working in their current tools. No PLAN.md or Agenttrail setup is required in the watched repo. Opening a repo reads its available activity; it does not modify the repo or launch agents. + +To explore without running an agent, add `--example` or click **Example**, then **Next example step**. The example is clearly labeled and uses scripted activity. Live observation of the selected folder stays available behind the Live button. + +## What connects + +| Provider | Current preview support | +| --- | --- | +| Codex | Experimental local log adapter; exercised in the recorded collaboration. Available lifecycle, tool and native plan metadata drive the view. | +| Claude Code | Experimental local logs and optional additive hooks; exercised with Codex, including native task acknowledgements. | +| Cursor | Optional hooks with automated adapter tests; native live validation is still pending. | + +Local file watching remains available without native todos. Missing tasks or progress stay unknown. Remote sessions whose logs are not on this machine are not automatically visible. Explicit artifact/revision/receipt metadata is required for reliable plate transfers; ordinary file reads do not establish a handoff. + +Choose **Connect agents** to review optional Claude or Cursor hook changes. Installation is additive, reversible and explicit. The kitchen never sends prompts, approves actions or changes task status in your agents. + +## Develop from source + +From the repository root: + +```sh +npm ci --prefix packages/kitchen +npm run build --prefix packages/kitchen +npm start --prefix packages/kitchen -- --project /absolute/path/to/your/repo +``` + +After frontend changes, rebuild and reload. To check and package: + +```sh +cd packages/kitchen +npm run check +npm test +npm pack +node scripts/check-package.mjs ./agenttrail-kitchen-0.1.0-alpha.1.tgz +``` + +The package smoke check installs into an isolated temporary folder without lifecycle scripts or development dependencies, launches the installed command, verifies bundled assets and checks repo attachment. It does not use your real agent logs or saved kitchen state. + +## Learn more + +- [Quick start and scene controls](https://github.com/sodiumsun/agenttrail/tree/main/docs/kitchen) +- [Connection details and limits](https://github.com/sodiumsun/agenttrail/blob/main/docs/kitchen/CONNECTING.md) +- [Roles and shared orders](https://github.com/sodiumsun/agenttrail/blob/main/docs/kitchen/WORKFLOW-INTEGRATION.md) +- [Artifact receipt contract](https://github.com/sodiumsun/agenttrail/blob/main/docs/kitchen/HANDOFFS.md) +- [Contributing](https://github.com/sodiumsun/agenttrail/blob/main/CONTRIBUTING.md) + +## License and privacy + +MIT for the project code and original kitchen assets. [Third-party notices](docs/THIRD-PARTY.md) cover Three.js and Nunito. The game-reference screenshots and soundtrack are not included; this is an independent, cooking-game-inspired project. + +The service binds to 127.0.0.1. Rendering, fonts and event delivery stay local. There is no account, telemetry, transcript upload or extra model call. Only allowlisted activity metadata reaches the browser; task titles and project paths may still be visible when you record or share your screen. diff --git a/packages/kitchen/bin/office.mjs b/packages/kitchen/bin/office.mjs new file mode 100755 index 0000000..308139e --- /dev/null +++ b/packages/kitchen/bin/office.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {spawn} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {startOffice} from '../src/server.mjs'; + +const appRoot=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +export function parseArgs(args,cwd=process.cwd()){ + const options={roots:[],port:4780,open:true,saved:false,stateDir:path.join(os.homedir(),'.agent-office')}; + const value=i=>{if(!args[i]||args[i].startsWith('--'))throw new Error('Provide a value for '+args[i-1]+'.');return args[i];}; + for(let i=0;i65515)throw new Error('Choose a port from 1024 through 65515.'); + return options; +} +export function liveUrl(base,project){const url=new URL(base);url.searchParams.set('project',project);url.searchParams.set('mode','live');return url.href;} +function launchUrl(base,project,example){const url=new URL(liveUrl(base,project));if(example){url.searchParams.set('mode','demo');url.searchParams.delete('project');}return url.href;} +function openBrowser(url){const bin=process.platform==='darwin'?'open':process.platform==='win32'?'cmd':'xdg-open',params=process.platform==='win32'?['/c','start','',url]:[url];const child=spawn(bin,params,{stdio:'ignore',detached:true});child.on('error',()=>{});child.unref();} +export async function main(args=process.argv.slice(2)){ + const options=parseArgs(args); + if(options.help){console.log('Agenttrail Kitchen (experimental)\n agenttrail-kitchen . Watch the current repo\n agenttrail-kitchen /path/to/repo Open any working folder\n --example Open the labeled example view\n --project /path (repeatable)\n --saved Reopen saved folders\n --port 4780\n --no-open\n --state-dir /absolute/state\n\nNo PLAN.md, Agenttrail install, or repo changes required. A running kitchen is reused and receives the requested folders.');return;} + const {port,open,stateDir}=options,roots=options.roots; + if(!roots.length&&(options.saved||process.cwd()===appRoot)){try{const saved=JSON.parse(await fs.readFile(path.join(stateDir,'projects.json'),'utf8'));if(Array.isArray(saved))roots.push(...saved.filter(s=>typeof s==='string'));}catch{}} + if(!roots.length)roots.push(process.cwd()); + const unique=[];for(const root of roots){let real;try{real=await fs.realpath(root);if(!(await fs.stat(real)).isDirectory())throw 0;}catch{if(options.saved)continue;throw new Error('Project folder does not exist: '+root);}if(!unique.includes(real))unique.push(real);} + if(!unique.length||unique.length>12)throw new Error('Choose between one and twelve existing project folders.'); + await fs.mkdir(stateDir,{recursive:true,mode:0o700}); + let registration;try{registration=JSON.parse(await fs.readFile(path.join(stateDir,'server.json'),'utf8'));}catch{} + if(Number.isInteger(registration?.port)&®istration.port>=1024&®istration.port<=65535){ + const base=`http://127.0.0.1:${registration.port}`;let response; + try{response=await fetch(base+'/api/state',{signal:AbortSignal.timeout(1500),redirect:'error'});}catch(error){if(!['ECONNREFUSED','ECONNRESET'].includes(error.cause?.code))throw new Error('The existing kitchen is not responding. Retry or restart that service.');} + if(response){ + const existing=await response.json().catch(()=>null); + if(existing?.app!=='agenttrail-kitchen')throw new Error('The registered service needs to be restarted with the updated kitchen before attaching a repo.'); + const result=await fetch(base+'/api/attach',{method:'POST',headers:{authorization:`Bearer ${registration.hookToken}`,'content-type':'application/json'},body:JSON.stringify({projects:unique}),signal:AbortSignal.timeout(15000),redirect:'error'}); + const attached=await result.json();if(!result.ok)throw new Error(attached.error||'Could not attach this repo to the kitchen.'); + const url=launchUrl(base,attached.projects[0],options.example);console.log(`Kitchen updated: ${url}\nWatching ${attached.projects.map(p=>path.basename(p)).join(', ')}. Existing agents keep running.`);if(open)openBrowser(url);return; + } + } + await fs.writeFile(path.join(stateDir,'projects.json'),JSON.stringify(unique),{mode:0o600}); + const office=await startOffice({roots:unique,home:os.homedir(),stateDir,port});const url=launchUrl(office.url,unique[0],options.example); + console.log(`Agenttrail Kitchen is ready: ${url}\nWatching ${unique.map(p=>path.basename(p)).join(', ')}. Local metadata only.\nCodex and Claude observations are automatic when available. Use Connect agents for provider hooks.`);if(open)openBrowser(url); + for(const signal of ['SIGINT','SIGTERM'])process.once(signal,async()=>{await office.close();process.exit(0);}); +} +if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url))main().catch(error=>{console.error(error.message);process.exitCode=1;}); diff --git a/packages/kitchen/bin/plate.mjs b/packages/kitchen/bin/plate.mjs new file mode 100644 index 0000000..0e44fd2 --- /dev/null +++ b/packages/kitchen/bin/plate.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +// Explicit artifact metadata only. This command never reads the artifact body. +const args=process.argv.slice(2); +if(args.includes('--help')){console.log('Read explicit artifact metadata as JSON from stdin.\nUsage: node bin/plate.mjs [--state-dir /path/to/state]\nSee docs/HANDOFFS.md for the produced/offered/received contract.');process.exit(0);} +const stateDir=args[0]==='--state-dir'&&args[1]?path.resolve(args[1]):path.join(os.homedir(),'.agent-office'); +try{ + let raw='';for await(const chunk of process.stdin){raw+=chunk;if(raw.length>32_000)throw new Error('Metadata is too large.');} + const event=JSON.parse(raw),registration=JSON.parse(await fs.readFile(path.join(stateDir,'server.json'),'utf8')); + if(!Number.isInteger(registration.port)||registration.port<1024||registration.port>65535)throw new Error('No valid kitchen service is registered.'); + const response=await fetch(`http://127.0.0.1:${registration.port}/api/artifact`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${registration.hookToken}`},body:JSON.stringify(event),signal:AbortSignal.timeout(3000)}); + const result=await response.json();if(!response.ok||!result.accepted)throw new Error(result.error||'Event was duplicate, stale, or missing valid artifact metadata.'); + console.log('Plate event accepted.'); +}catch(error){console.error(error.message);process.exitCode=1;} diff --git a/packages/kitchen/bin/relay.mjs b/packages/kitchen/bin/relay.mjs new file mode 100755 index 0000000..247c87b --- /dev/null +++ b/packages/kitchen/bin/relay.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { normalizeHook } from '../src/connectors/events.mjs'; + +const deadline=setTimeout(()=>process.exit(0),650); +try{ + const [provider,stateDir]=process.argv.slice(2);let input=''; + for await(const chunk of process.stdin){input+=chunk;if(input.length>2*1024*1024)process.exit(0);} + const raw=JSON.parse(input);raw.office_event_id=crypto.randomUUID(); + const event=normalizeHook(provider,raw);if(!event)process.exit(0); + // Raw tool arguments, prompts, emails and responses never leave this process. + const registration=JSON.parse(await fs.readFile(path.join(stateDir,'server.json'),'utf8')); + if(!Number.isInteger(registration.port)||registration.port<1024||registration.port>65535)process.exit(0); + await fetch(`http://127.0.0.1:${registration.port}/api/hook`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${registration.hookToken}`},body:JSON.stringify(event),signal:AbortSignal.timeout(400)}); +}catch{}finally{clearTimeout(deadline);process.exit(0);} diff --git a/packages/kitchen/bin/role.mjs b/packages/kitchen/bin/role.mjs new file mode 100644 index 0000000..451efdd --- /dev/null +++ b/packages/kitchen/bin/role.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +const args=process.argv.slice(2); +if(args.includes('--help')){console.log('Bind an observed session to a workflow chef. Read JSON from stdin.\nUsage: node bin/role.mjs [--state-dir /path]\nFields: provider, sessionId, cwd, roleId; optional workflowId, runId, itemId, orderId.\nUse roleId: null to clear the binding. This does not start or stop work.');process.exit(0);} +const stateDir=args[0]==='--state-dir'&&args[1]?path.resolve(args[1]):path.join(os.homedir(),'.agent-office'); +try{ + let raw='';for await(const chunk of process.stdin){raw+=chunk;if(raw.length>4000)throw new Error('Role metadata is too large.');} + const input=JSON.parse(raw),registration=JSON.parse(await fs.readFile(path.join(stateDir,'server.json'),'utf8')); + if(!Number.isInteger(registration.port)||registration.port<1024||registration.port>65535)throw new Error('No valid kitchen service is registered.'); + const event={id:crypto.randomUUID(),kind:'role',provider:input.provider,sessionId:input.sessionId,cwd:input.cwd,roleId:input.roleId,workflowId:input.workflowId,runId:input.runId,itemId:input.itemId,orderId:input.orderId}; + const response=await fetch(`http://127.0.0.1:${registration.port}/api/hook`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${registration.hookToken}`},body:JSON.stringify(event),signal:AbortSignal.timeout(3000)}); + const result=await response.json();if(!response.ok||!result.accepted)throw new Error(result.error||'Use a valid role and an already observed session in this project.'); + console.log(input.roleId===null?'Role binding cleared.':'Role binding recorded.'); +}catch(error){console.error(error.message);process.exitCode=1;} diff --git a/packages/kitchen/docs/THIRD-PARTY.md b/packages/kitchen/docs/THIRD-PARTY.md new file mode 100644 index 0000000..744ac80 --- /dev/null +++ b/packages/kitchen/docs/THIRD-PARTY.md @@ -0,0 +1,7 @@ +# Bundled dependencies + +- Three.js 0.186.0, MIT license. Used for real-time 3D rendering and the bundled geometry/postprocessing utilities. [License](licenses/three-MIT.txt). +- Nunito, distributed by Fontsource 5.2.7 under the SIL Open Font License 1.1. Font files are served locally. [License](licenses/Nunito-OFL.txt). +- esbuild 0.25.12, MIT license. Development-time bundling only. + +Kitchen geometry, character rigs, textures, and animation code are authored in this project and released under the repository's MIT license. Game-reference screenshots, concept studies and soundtrack recordings are not included. No Overcooked game assets or music are bundled. diff --git a/packages/kitchen/docs/licenses/Nunito-OFL.txt b/packages/kitchen/docs/licenses/Nunito-OFL.txt new file mode 100644 index 0000000..ae9dd5a --- /dev/null +++ b/packages/kitchen/docs/licenses/Nunito-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito) Nunito-Italic[wght].ttf: Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/kitchen/docs/licenses/three-MIT.txt b/packages/kitchen/docs/licenses/three-MIT.txt new file mode 100644 index 0000000..8ada2a5 --- /dev/null +++ b/packages/kitchen/docs/licenses/three-MIT.txt @@ -0,0 +1,21 @@ +The MIT License + +Copyright © 2010-2026 three.js authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/kitchen/package-lock.json b/packages/kitchen/package-lock.json new file mode 100644 index 0000000..c1911c7 --- /dev/null +++ b/packages/kitchen/package-lock.json @@ -0,0 +1,525 @@ +{ + "name": "agenttrail-kitchen", + "version": "0.1.0-alpha.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agenttrail-kitchen", + "version": "0.1.0-alpha.1", + "license": "MIT", + "bin": { + "agenttrail-kitchen": "bin/office.mjs" + }, + "devDependencies": { + "@fontsource/nunito": "^5.2.7", + "esbuild": "0.25.12", + "three": "0.186.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource/nunito": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/nunito/-/nunito-5.2.7.tgz", + "integrity": "sha512-pmtBq0H9ex9nk+RtJYEJOD9pag393iHETnl/PVKleF4i06cd0ttngK5ZCTgYb5eOqR3Xdlrjtev8m7bmgYprew==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.0.tgz", + "integrity": "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/kitchen/package.json b/packages/kitchen/package.json new file mode 100644 index 0000000..08808fd --- /dev/null +++ b/packages/kitchen/package.json @@ -0,0 +1,33 @@ +{ + "name": "agenttrail-kitchen", + "version": "0.1.0-alpha.1", + "type": "module", + "description": "An experimental local 3D kitchen view for Agenttrail. Watch coding agents contribute to shared tasks.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/sodiumsun/agenttrail.git", + "directory": "packages/kitchen" + }, + "homepage": "https://github.com/sodiumsun/agenttrail/tree/main/docs/kitchen", + "bugs": "https://github.com/sodiumsun/agenttrail/issues", + "files": ["bin", "src", "public/build", "public/fonts", "public/index.html", "public/kitchen.css", "public/favicon.svg", "public/paper.svg", "docs/THIRD-PARTY.md", "docs/licenses", "LICENSE", "README.md"], + "bin": { + "agenttrail-kitchen": "bin/office.mjs" + }, + "scripts": { + "start": "node bin/office.mjs", + "test": "node --test --test-concurrency=1", + "check": "node --check bin/office.mjs && node --check src/server.mjs", + "build": "node scripts/build.mjs", + "prepack": "npm run build" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "three": "0.186.0", + "@fontsource/nunito": "^5.2.7", + "esbuild": "0.25.12" + } +} diff --git a/packages/kitchen/public/favicon.svg b/packages/kitchen/public/favicon.svg new file mode 100644 index 0000000..1f3e50e --- /dev/null +++ b/packages/kitchen/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/packages/kitchen/public/fonts/nunito-400.woff2 b/packages/kitchen/public/fonts/nunito-400.woff2 new file mode 100644 index 0000000..8c68e57 Binary files /dev/null and b/packages/kitchen/public/fonts/nunito-400.woff2 differ diff --git a/packages/kitchen/public/fonts/nunito-600.woff2 b/packages/kitchen/public/fonts/nunito-600.woff2 new file mode 100644 index 0000000..dac52ff Binary files /dev/null and b/packages/kitchen/public/fonts/nunito-600.woff2 differ diff --git a/packages/kitchen/public/fonts/nunito-700.woff2 b/packages/kitchen/public/fonts/nunito-700.woff2 new file mode 100644 index 0000000..5b423ba Binary files /dev/null and b/packages/kitchen/public/fonts/nunito-700.woff2 differ diff --git a/packages/kitchen/public/fonts/nunito-800.woff2 b/packages/kitchen/public/fonts/nunito-800.woff2 new file mode 100644 index 0000000..21502e0 Binary files /dev/null and b/packages/kitchen/public/fonts/nunito-800.woff2 differ diff --git a/packages/kitchen/public/index.html b/packages/kitchen/public/index.html new file mode 100644 index 0000000..23cac1f --- /dev/null +++ b/packages/kitchen/public/index.html @@ -0,0 +1,24 @@ + + + + + Agenttrail Kitchen + + +
+
+
Opening the kitchen…Setting the table for your agents
+
+
agenttrail/ kitchen
+
Connecting
+
+
+
ON THE MENU
+ +
+ +
+
+
BRING A WORKING REPO

Open its kitchen

Keep your agents working where they are. Choose a recently active folder or paste any local project path. No PLAN.md or Agenttrail installation required.

Recently seen

+ + diff --git a/packages/kitchen/public/kitchen.css b/packages/kitchen/public/kitchen.css new file mode 100644 index 0000000..39fca4f --- /dev/null +++ b/packages/kitchen/public/kitchen.css @@ -0,0 +1,60 @@ +@font-face{font-family:Nunito;src:url('/fonts/nunito-400.woff2') format('woff2');font-weight:400;font-display:swap}@font-face{font-family:Nunito;src:url('/fonts/nunito-600.woff2') format('woff2');font-weight:600;font-display:swap}@font-face{font-family:Nunito;src:url('/fonts/nunito-700.woff2') format('woff2');font-weight:700;font-display:swap}@font-face{font-family:Nunito;src:url('/fonts/nunito-800.woff2') format('woff2');font-weight:800;font-display:swap} +:root{font-family:Nunito,ui-rounded,sans-serif;color:#304052;background:#be995b;font-synthesis:none;--paper:#fff1d6;--ink:#304052;--wood:#8d542e;--blue:#438caa;--ease-out:cubic-bezier(.23,1,.32,1)}*{box-sizing:border-box}body{margin:0;overflow:hidden}button,input,select{font:inherit}button,select{cursor:pointer}button{color:inherit;transition:transform 150ms var(--ease-out),background-color 150ms var(--ease-out)}button:focus-visible,select:focus-visible,input:focus-visible,canvas:focus-visible{outline:3px solid #337fba;outline-offset:4px}button:disabled{cursor:default;opacity:.5}button:active{transform:translateY(1px)}button svg{width:20px;height:20px}button,a{-webkit-tap-highlight-color:transparent}h1,h2,h3,p{margin:0;text-wrap:pretty}h1,h2,h3{text-wrap:balance}a{color:#356f91;text-underline-offset:3px}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}[hidden]{display:none!important}#app{position:relative;height:100dvh;min-height:480px;isolation:isolate}#world{position:absolute;inset:0;z-index:-2}#kitchen{width:100%;height:100%;display:block;touch-action:none}#loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;background:#eac991;z-index:8}#loading strong{font-size:24px} +.topbar{position:absolute;left:24px;right:24px;top:16px;display:flex;gap:12px;align-items:center;z-index:4}.brand{display:flex;gap:8px;align-items:center;height:52px;padding:8px 16px;background:#fff0d5;border:2px solid #d3ab76;border-radius:8px;box-shadow:0 4px 0 #8e582e44,0 8px 20px #4c321b20;transform:rotate(-1deg);white-space:nowrap}.brand svg{width:32px;height:32px}.brand strong{font-size:24px;letter-spacing:-1px;font-weight:800}.brand span{font-size:18px;color:#776b5b}.project-controls{margin-right:auto;display:flex;gap:8px;flex-direction:column;align-items:flex-start;padding-left:4px}select{color:var(--ink);border:0;background:#fff0d5ed;border-radius:8px;padding:4px 24px 4px 8px;font-size:14px;font-weight:800;max-width:220px}.connection{font-size:12px;line-height:16px;font-weight:800;background:#fff0d5e6;padding:2px 8px;border-radius:4px;color:#625740;display:flex;align-items:center;gap:6px}.connection i{width:6px;height:6px;border-radius:50%;background:#58a16a}.connection.offline i{background:#c95d41}.mode-switch{display:flex;background:#7e5b39de;border:2px solid #98663c;padding:3px;border-radius:8px;box-shadow:0 3px 0 #61432833}.mode-switch button{border:0;background:none;color:#ffedcc;padding:8px 12px;font-size:14px;font-weight:800;border-radius:4px}.mode-switch .active{background:#fff0d4;color:#384d57;box-shadow:0 2px 3px #33200d44}.cream-button{border:2px solid #c7a173;background:#fff0d6;border-radius:8px;padding:8px 12px;font-size:14px;font-weight:800;box-shadow:0 3px 0 #85613e55} +.order-board{position:absolute;top:82px;left:50%;transform:translateX(-50%);width:min(720px,60vw);z-index:3}.order-caption{display:flex;align-items:center;justify-content:space-between;margin:0 4px 4px;font-size:12px;font-weight:800;letter-spacing:1.5px;color:#fff8df;text-shadow:0 1px 3px #56381c}.order-caption>span{background:#87623bba;padding:2px 8px;border-radius:4px}.order-caption button{font-size:12px;border:0;background:#fff1d7e8;padding:2px 8px;border-radius:4px;font-weight:800;letter-spacing:0;text-shadow:none}.ticket-rail{display:flex;gap:16px;justify-content:center;position:relative}.ticket-rail:before{content:'';position:absolute;left:-24px;right:-24px;top:8px;height:5px;background:#81512e;box-shadow:0 2px 1px #fff0c944;border-radius:8px;z-index:-1}.ticket{position:relative;display:flex;flex-direction:column;align-items:center;flex:1;min-width:0;max-width:232px;padding:18px 12px 12px;border:3px solid #e1bd8d;border-radius:8px 8px 16px 8px;background:#fff0d5;box-shadow:0 3px 0 #86573070,0 7px 12px #6d442927;color:#2d4054}.ticket:nth-child(1){transform:rotate(-1.5deg)}.ticket:nth-child(3){transform:rotate(1.2deg)}.ticket:before,.ticket:after{content:'';position:absolute;top:-9px;width:10px;height:24px;border:1px solid #9b6c41;border-radius:8px;background:#bd8d56;box-shadow:2px 2px 2px #6a452955}.ticket:before{left:23px}.ticket:after{right:23px}.ticket-icon{height:37px;margin-bottom:4px;color:#7688c2}.ticket-icon svg{width:36px;height:36px;stroke-width:1.8}.ticket h2{font-size:18px;line-height:24px;font-weight:800;max-width:100%;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.ticket-meta{font-size:12px;line-height:16px;color:#8b785a;font-weight:800;margin-top:4px}.ticket-progress{display:flex;gap:5px;margin-top:8px;height:8px;align-items:center}.ticket-progress i{width:8px;height:8px;border-radius:50%;background:#d7c9b3}.ticket-progress i.done{background:#719c72}.ticket-progress i.active{background:#faf0dc;border:2px solid #6e91c0}.ticket-progress i.blocked{background:#de9d51}.ticket-badge{position:absolute;right:-6px;top:20px;background:#e4ad50;color:#694625;border:2px solid #fbe5b6;border-radius:50%;width:22px;height:22px;display:grid;place-items:center;font-weight:800}.ticket.empty{font-size:14px;text-align:center;min-height:104px}.ticket[data-selected=true]{outline:3px solid #5899b8;outline-offset:3px} +#chef-labels{position:absolute;inset:0;pointer-events:none;z-index:1}.chef-label{position:absolute;top:0;left:0;will-change:transform;text-align:center;white-space:nowrap;font-size:12px;font-weight:800;line-height:16px;pointer-events:none}.chef-label span{display:block;background:#fff2d9f0;border:1px solid #d8b987;border-radius:4px;padding:2px 8px;box-shadow:0 2px 5px #79522d33}.chef-label small{display:block;color:#3f3b2c;text-shadow:0 1px 1px #fff3d9;font-size:12px;font-weight:800;margin-top:2px} +.kitchen-picker{position:absolute;left:50%;bottom:100px;transform:translateX(-50%);display:flex;align-items:center;gap:8px;padding:6px 8px;background:#845a37f2;border:2px solid #b98a50;border-radius:12px;box-shadow:0 4px 0 #5f452b45,0 8px 20px #5e391d2b;z-index:3;max-width:90vw}.map-button{border:0;border-radius:6px;background:#ae8052;color:#ffefce;width:36px;height:36px;display:grid;place-items:center}.map-button svg{fill:none;stroke:currentColor;stroke-width:1.5}#kitchens{display:flex;gap:4px;overflow-x:auto}#kitchens button{background:none;border:0;border-radius:6px;padding:8px 12px;white-space:nowrap;color:#f5dfbc;font-size:14px;font-weight:800}#kitchens button.active{color:#354951;background:#ffedd0;box-shadow:0 2px 3px #5f361d66}#kitchens .attention-dot{display:inline-block;width:6px;height:6px;margin-left:6px;background:#dc9751;border-radius:50%}#kitchen-count{color:#f8dbac;font-size:12px;white-space:nowrap;margin:0 4px} +.bottom-bar{position:absolute;bottom:24px;left:24px;right:24px;display:flex;align-items:flex-end;gap:16px;justify-content:space-between;z-index:3}.crew-dock{background:#fff0d6f5;border:2px solid #cdab78;border-radius:12px;padding:8px 12px;box-shadow:0 4px 0 #82613e33,0 8px 24px #74533033;max-width:calc(100% - 224px)}.dock-title{font-size:12px;letter-spacing:1px;font-weight:800;color:#8c7759;display:block;margin-bottom:4px}.dock-title b{font-size:12px;background:#e7d5b6;border-radius:4px;padding:0 4px;margin-left:4px;color:#5b614c}#roster{display:flex;gap:6px;overflow-x:auto}#roster button{border:1px solid #d8c9ab;border-radius:8px;background:#fff6e5;padding:4px 8px;display:flex;align-items:center;gap:6px;white-space:nowrap;min-height:32px;font-size:12px;font-weight:800}#roster button.selected{border-color:#5a93a8;background:#e4f0ea}#roster button.needs-attention{background:#ffe4b6;border-color:#d9a15c}.chef-dot{width:20px;height:20px;border-radius:50%;background:var(--chef-color);display:grid;place-items:center;color:white;font-size:12px;box-shadow:inset 0 -2px 0 #0002}.provider-initial{font-size:10px;color:#99856b}.view-controls{display:flex;background:#fff0d7f5;border:2px solid #c8a273;border-radius:8px;overflow:hidden;box-shadow:0 4px 0 #81533033}.view-controls button{border:0;background:none;min-width:32px;padding:8px;font-size:14px;font-weight:800}.view-controls button[aria-pressed=true]{background:#dccaa7}.source-note{position:absolute;left:24px;bottom:4px;font-size:10px;line-height:16px;font-weight:800;color:#534a35;text-shadow:0 1px 1px #ffedc4;max-width:85vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none}#notice{position:absolute;left:24px;top:232px;background:#fff0d6f2;border:1px solid #ceae7d;border-radius:8px;padding:8px 12px;font-size:12px;max-width:232px;box-shadow:0 3px 10px #66442722}#notice:empty{display:none} +#inspector{position:absolute;right:24px;top:82px;bottom:120px;width:344px;max-width:calc(100vw - 48px);background:#fff2dcef;border:3px solid #d3af7c;border-radius:12px;box-shadow:0 8px 30px #56361744;padding:24px;overflow:auto;z-index:6;backdrop-filter:blur(12px)}.close-button{position:absolute;right:12px;top:8px;width:28px;height:28px;border:0;background:#ead6b6;border-radius:50%;font-size:24px;line-height:24px;display:grid;place-items:center;z-index:2}.eyebrow{font-size:12px;color:#998266;letter-spacing:1.5px;font-weight:800;margin-bottom:8px}#inspector h2{font-size:24px;line-height:32px;padding-right:12px;margin-bottom:8px}#inspector h3{font-size:14px;line-height:20px;margin-top:24px;margin-bottom:8px}#inspector p{font-size:14px;line-height:20px;color:#786b57;margin:8px 0}.detail-tags{display:flex;flex-wrap:wrap;gap:4px;margin:8px 0 16px}.detail-tags span,.state-tag{font-size:12px;font-weight:800;border-radius:4px;padding:2px 8px;background:#e2dcc5;color:#546450}.detail-tags .attention{background:#efd099;color:#815d2f}.detail-list{display:flex;flex-direction:column;gap:8px}.detail-row{padding:8px;background:#f4e5cb;border:1px solid #e4cea9;border-radius:8px;display:flex;gap:8px;align-items:flex-start;font-size:14px;line-height:20px}.detail-row .state{font-weight:800;color:#739073;flex-shrink:0;width:16px}.detail-row .state.blocked{color:#b27636}.detail-row small{display:block;font-size:12px;line-height:16px;color:#938065}.detail-row button{border:0;background:none;text-align:left;padding:0;font-weight:700;color:#356f91}.file-label{font-size:12px;line-height:20px;padding:8px;background:#ecdfc4;border-radius:6px;overflow-wrap:anywhere}.kitchen-card{width:100%;padding:16px;text-align:left;border:2px solid #d7bf96;background:#fff4db;border-radius:8px;margin-bottom:8px}.kitchen-card strong{display:block;font-size:16px}.kitchen-card small{display:block;margin-top:4px;color:#8f7a5d;font-size:12px}.kitchen-card.current{border-color:#69a0a9}.progress-summary{display:flex;gap:16px;margin:16px 0;padding:12px 0;border-block:1px solid #e5cfad}.progress-summary strong{display:block;font-size:24px;color:#50747b}.progress-summary small{font-size:12px;color:#8a775b}.primary-action,.blue-button{background:#4b8d9f;color:#fff6e3;border:2px solid #3d798e;border-radius:8px;font-weight:800;font-size:14px;padding:8px 12px}.primary-action{margin-top:16px}.kitchen-link{background:#e6d5b9;border:0;border-radius:4px;padding:4px 8px;font-size:12px;font-weight:800;margin:4px 4px 0 0} +dialog{background:#fff0d7;color:#304052;border:3px solid #d5b37f;border-radius:16px;padding:32px;width:560px;max-width:calc(100vw - 32px);box-shadow:0 20px 80px #30251555}dialog::backdrop{background:#493d2a88;backdrop-filter:blur(5px)}dialog h1{font-size:30px;line-height:36px;margin-bottom:12px}dialog p{font-size:14px;line-height:20px;color:#817059}#setup-content{display:flex;flex-direction:column;gap:8px;margin:24px 0}.provider-row{border:1px solid #ddc69e;background:#fff5e1;border-radius:8px;padding:12px;display:flex;align-items:center;gap:12px}.provider-row>div{flex:1}.provider-row strong{font-size:16px}.provider-row small{display:block;color:#8f7c5f;font-size:12px;line-height:16px}.provider-row button{font-size:12px;padding:6px 8px}.form-row{display:flex;gap:8px;margin-top:8px}.form-row input{min-width:0;flex:1;border:1px solid #ceb28b;border-radius:8px;padding:8px 12px;background:#fff9ec;font-size:14px}form label{font-size:14px;font-weight:800}#setup-message{margin-top:12px;white-space:pre-wrap}.setup-review{background:#efdfc4;padding:12px;border-radius:8px}.setup-review p{overflow-wrap:anywhere;margin-bottom:8px} +@media(hover:hover) and (pointer:fine){.cream-button:hover,.view-controls button:hover,#roster button:hover{background:#fff9ec}.ticket:hover{background:#fff6e4;box-shadow:0 3px 0 #86573070,0 9px 18px #6d442943}.kitchen-card:hover{background:#fff9e9}} +@media(max-width:1100px){.brand strong{font-size:20px}.brand span{font-size:14px}.brand{padding:8px 12px;gap:4px}.topbar{left:16px;right:16px;gap:8px}.project-controls{max-width:170px}select{max-width:170px}.order-board{width:62vw;top:88px}.ticket{padding:16px 8px 8px}.ticket h2{font-size:16px;line-height:20px}.ticket-icon{height:28px}.ticket-icon svg{width:28px;height:28px}#notice{left:16px;max-width:185px;top:218px}.bottom-bar{left:16px;right:16px}.crew-dock{max-width:calc(100% - 206px)}.source-note{left:16px}#inspector{right:16px}} +@media(max-width:760px){.topbar{top:8px;left:8px;right:8px;flex-wrap:wrap;gap:6px}.brand{height:44px}.brand svg{width:26px;height:26px}.brand span{display:none}.project-controls{margin-right:auto;flex:1;max-width:unset}.connection{display:none}.mode-switch{margin-left:auto}.mode-switch button{padding:6px 8px;font-size:12px}#connect{font-size:12px;padding:6px 8px}.order-board{top:108px;width:calc(100vw - 32px)}.ticket-rail{gap:8px}.ticket{border-width:2px;min-width:0;padding:14px 6px 8px}.ticket h2{font-size:14px;line-height:20px}.ticket-meta{font-size:10px}.ticket:before,.ticket:after{width:8px;height:20px}.ticket:before{left:12px}.ticket:after{right:12px}.ticket-progress{gap:3px}.ticket-progress i{width:6px;height:6px}.ticket-icon{height:24px}.ticket-icon svg{height:24px;width:24px}.order-caption{font-size:10px}.bottom-bar{left:8px;right:8px;bottom:24px;gap:8px;align-items:flex-end}.crew-dock{max-width:calc(100% - 44px);padding:6px 8px}.view-controls{flex-direction:column}.view-controls button{padding:4px;min-width:28px;line-height:20px}.view-controls #fit,.view-controls #zoom-in,.view-controls #zoom-out{display:none}.kitchen-picker{bottom:112px;padding:4px;gap:4px;max-width:calc(100vw - 16px)}#kitchen-count{display:none}#kitchens button{font-size:12px;padding:8px}.source-note{left:8px;font-size:10px}.chef-label{font-size:10px}.chef-label span{padding:1px 4px}.chef-label small{display:none}#notice{top:242px;left:8px;max-width:180px;font-size:10px;padding:6px 8px}#inspector{right:8px;top:64px;bottom:112px;width:calc(100vw - 16px);max-width:unset}dialog{padding:24px}.form-row{flex-direction:column}} +@media(prefers-reduced-motion:reduce){button{transition:none}.ticket:nth-child(1),.ticket:nth-child(3),.brand{transform:none}} +.order-caption button{color:#655640}.chef-label small{display:none}.chef-label.selected small,.chef-label.attention small{display:block}.chef-label span{font-size:10px;padding:1px 6px}.chef-label{max-width:110px} +#kitchen{filter:saturate(1.20) contrast(1.045)}.ticket,.brand,#inspector,dialog{background-image:url('/paper.svg')} +@media(min-width:761px) and (max-height:760px){.order-board{top:76px}.ticket{padding:14px 12px 8px}.ticket-icon{height:26px}.ticket-icon svg{height:26px;width:26px}.ticket h2{font-size:16px;line-height:20px}.ticket-progress{margin-top:6px}} + +/* The paper rail and the room share level axes and occupy separate space. */ +#app{display:grid;grid-template-rows:auto auto minmax(160px,1fr) auto auto auto;gap:0;background:#b7a36e;min-height:600px} +.topbar{position:relative;inset:auto;grid-row:1;margin:12px 24px 8px;min-height:48px}.brand{transform:none;height:48px} +.order-board{position:relative;inset:auto;transform:none;grid-row:2;width:100%;min-width:0;padding:0 24px}.order-caption{margin:0 8px 0}.rail-actions{display:flex;gap:8px}.ticket-rail{justify-content:flex-start;overflow-x:auto;padding:16px 8px 12px;gap:16px;scroll-padding:8px;scroll-snap-type:x proximity}.ticket-rail:before{display:none} +.ticket{transform:none;flex:0 0 calc((100% - 32px)/3);max-width:none;min-width:264px;align-items:stretch;text-align:left;padding:16px 16px 12px;scroll-snap-align:start}.ticket h2{font-size:18px;line-height:24px}.ticket:before,.ticket:after{height:24px;top:-12px}.ticket-icon{display:none} +#world{position:relative;inset:auto;grid-row:3;z-index:0;min-height:0;overflow:hidden}#chef-labels{inset:0}#notice{top:8px;left:16px;max-width:280px;z-index:2} +.kitchen-picker{position:relative;inset:auto;transform:none;grid-row:4;justify-self:center;margin:0 16px 8px;max-width:calc(100vw - 32px);padding:4px 8px}.bottom-bar{position:relative;inset:auto;grid-row:5;margin:0 24px 4px;align-items:center}.source-note{position:relative;inset:auto;grid-row:6;margin:0 24px 4px;max-width:calc(100vw - 48px)} +.chef-label{max-width:192px;font-size:12px;line-height:16px}.chef-label span{font-size:12px;padding:2px 8px}.chef-label small,.chef-label.selected small{display:block;background:#fff1d6ee;border-radius:4px;padding:2px 4px;color:#4b513e;text-shadow:none;font-size:12px;line-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.chef-label span{border:2px solid var(--chef-color,#d8b987)}.chef-label.related span{outline:2px solid #fff1b2}.chef-label.dim{opacity:.48}.chef-label.attention small{background:#f9d792;color:#694521}#kitchen{filter:saturate(1.12) contrast(1.02)} +@media(max-width:1000px){.topbar{margin:8px 16px}.brand span{display:none}.order-board{padding:0 12px}.ticket{flex-basis:calc((100% - 16px)/2)}.bottom-bar{margin:0 16px 4px}.project-controls{max-width:180px}.source-note{margin:0 16px 4px}.crew-dock{max-width:calc(100% - 200px)}} +@media(max-width:760px){#app{min-height:600px}.topbar{margin:8px;gap:8px}.brand{height:40px}.brand strong{font-size:18px}.topbar .project-controls{max-width:unset;min-width:120px}.mode-switch{margin-left:0}.order-board{padding:0 4px}.ticket{flex-basis:80%;min-width:240px;padding:16px 12px 8px}.ticket h2{font-size:16px;line-height:20px}.ticket-meta{font-size:12px}.ticket-rail{gap:12px;padding-top:16px}.kitchen-picker{margin-bottom:4px}.bottom-bar{margin:0 8px 4px}.crew-dock{max-width:calc(100% - 44px)}.source-note{margin:0 8px 4px}.chef-label{max-width:144px}.chef-label span,.chef-label small{font-size:12px}.order-caption{font-size:10px}#notice{left:8px;max-width:240px}.view-controls{flex-direction:column}} +.goal-title{border:0;background:none;text-align:left;padding:0 24px 0 0;min-height:24px;color:#304052}.pin-goal{position:absolute;right:8px;top:16px;border:0;background:none;font-size:20px;color:#847458;padding:0 4px}.pin-goal[aria-pressed=true]{color:#367d94}.ticket-task{font-size:12px;line-height:16px;color:#817157;margin:4px 0 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ticket-chefs{display:grid;gap:4px;min-height:40px}.ticket-chef{display:flex;align-items:center;gap:8px;text-align:left;background:#f4e6cde0;border:1px solid #ddcbaa;border-radius:8px;padding:4px 8px;min-width:0;width:100%;font-size:12px;line-height:16px}.ticket-chef>span:nth-child(2){min-width:0;flex:1}.ticket-chef strong{display:block;font-size:12px;line-height:16px}.ticket-chef small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#675e4d;font-size:12px;line-height:16px}.chef-dot{flex-shrink:0;width:24px;height:24px;font-size:12px;color:#fff;text-shadow:0 1px 2px #30405288;border:2px solid #fff1d6}.ticket-chef.urgent{background:#ffe1ab;border-color:#bc863e}.ticket-footer{display:flex;justify-content:space-between;gap:4px;align-items:center;font-size:12px;line-height:16px;color:#766a54;margin-top:8px}.ticket-footer button{border:0;background:none;padding:0;color:#3c7586;font-size:12px;font-weight:800}.ticket-footer b{color:#a36a27}.no-chef{font-size:12px;color:#81765e;padding:8px 0}.ticket.has-attention{border-color:#be8847}.ticket.empty{flex-basis:100%;min-height:120px;justify-content:center;align-items:center;gap:8px}.ticket.empty span{font-size:12px}.elsewhere{background:none;border:0;text-align:left;font-size:12px;color:#3c7586;padding:4px 0 0}.rail-actions #attention{background:#f6d092;color:#694620}.ticket[data-selected=true]{outline:3px solid #4d879e;outline-offset:2px}#roster button>span:nth-child(2){text-align:left}#roster small{display:block;font-size:12px;line-height:16px;font-weight:600;max-width:192px;overflow:hidden;text-overflow:ellipsis}.crew-dock{padding:4px 8px}.dock-title{margin-bottom:2px}.kitchen-picker #kitchens .attention-dot{width:16px;height:16px;font-size:12px;color:#59370e;background:#ffd594}.kitchen-picker #kitchens small{font-size:12px;font-weight:600;margin-left:4px}.session-task{margin:4px 8px!important}.ticket-chef:hover{background:#fff6e5}.ticket-chef.urgent:hover{background:#ffeabc} +#app{grid-template-columns:minmax(0,1fr)}.topbar,.order-board,#world,.bottom-bar{min-width:0}.ticket{align-self:flex-start}.ticket-chefs{align-content:start}.topbar{flex-wrap:wrap}.ticket-rail{scrollbar-width:thin;scrollbar-color:#a78655 transparent} + +.ticket:nth-child(1),.ticket:nth-child(3){transform:none}.ticket-chef>span:nth-child(2){display:flex;gap:8px;align-items:baseline}.ticket-chef strong{flex-shrink:0}.ticket-chef small{min-width:0;flex:1}.chef-label.compact:not(.selected):not(.attention):not(.active) small{display:none}.chef-label.compact span{font-size:12px;padding:2px 4px}.chef-label{width:max-content}.chef-label span{white-space:nowrap}.ticket-task{display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical} + +.ticket h2{display:block;white-space:nowrap;text-overflow:ellipsis;-webkit-line-clamp:unset}.goal-title{width:100%} + +.ticket-chef{flex-wrap:wrap}.ticket-chef .chef-task{flex-basis:100%;font-size:12px;line-height:16px;color:#73664e;padding-left:32px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + +/* One backdrop continues behind the transparent scene and the paper controls. */ +#app,#world{background:#b7a36e}#world{isolation:isolate}#kitchen{background:transparent} +#workflow-summary{font-size:12px;line-height:16px;white-space:nowrap;color:#fff0d7;padding:0 8px}.kitchen-picker:has(#workflow-summary:not(:empty)) #kitchen-count{display:none}.kitchen-picker{flex-wrap:wrap;justify-content:center}.counter-banner{padding:12px;border:1px solid #ceae7d;border-radius:8px;background:#f5dfb7;margin-bottom:12px;font-size:14px;line-height:20px}.queue-filters{display:flex;gap:4px;flex-wrap:wrap;margin:12px 0}.queue-filters [aria-pressed=true]{background:#4b8d9f;color:#fff6e3}.rail-actions{flex-wrap:wrap}.chef-label{max-width:192px}.crew-dock{max-width:calc(100% - 180px)}#roster{max-height:80px;overflow:auto}.ticket-chefs:has(.no-chef){min-height:40px} +@media(max-width:760px){.crew-dock{max-width:calc(100% - 44px)}#workflow-summary{width:100%;text-align:center}.order-caption{align-items:flex-start}.rail-actions{justify-content:flex-end}.chef-label{max-width:144px}} + +.chef-label.compact span{display:block;max-width:144px;overflow:hidden;text-overflow:ellipsis}.room-focus .order-board{display:none}#roster button>span:nth-child(2){max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#roster small{max-width:160px}.view-controls #room-view{font-size:12px;white-space:nowrap} +@media(max-height:850px){.topbar{min-height:40px;margin-top:8px}.brand{height:40px}.ticket{padding:12px 12px 8px}.ticket h2{font-size:16px;line-height:20px}.ticket-task{margin:4px 0}.ticket-footer{margin-top:4px}.ticket-chefs{min-height:32px}.ticket-rail{padding-top:12px;padding-bottom:8px}.ticket:before,.ticket:after{height:20px;top:-10px}.kitchen-picker{margin-bottom:4px}.kitchen-picker #kitchens button{padding:6px 8px}.bottom-bar{margin-bottom:4px}} +@media(max-width:760px){.view-controls #room-view{max-width:56px;white-space:normal;padding:4px}.chef-label.compact span{max-width:112px}} + +.bottom-bar .crew-dock{flex:1;min-width:0;max-width:none}.bottom-bar .view-controls{flex:0 0 auto}.view-controls button{white-space:nowrap} +@media(max-width:760px){.view-controls #room-view{white-space:normal}} + +#roster .unlinked-session .chef-dot{background:#e2dcc5;color:#675e4d;text-shadow:none}#roster .unlinked-session{border-style:dashed} + +/* An order number follows one native todo through the crew and onto the table. */ +.order-ticket{gap:8px;min-height:136px}.order-ticket-head{display:flex;align-items:center;justify-content:space-between;gap:8px;color:#78664d;font-size:12px;line-height:16px;font-weight:800}.order-number{background:var(--order-color);color:#fff8e8;padding:2px 8px;border-radius:4px;min-width:32px;text-align:center}.order-ticket .goal-title{padding:0}.order-ticket h2{white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;line-height:20px}.order-contributors{display:flex;flex-wrap:wrap;align-items:center;gap:4px;min-height:32px}.contributor{display:inline-flex;align-items:center;gap:4px;background:#f2e2c8;border:1px solid #ddc7a0;border-radius:8px;padding:2px 4px;font-size:12px;line-height:16px}.contributor .chef-dot{width:20px;height:20px}.contributor small{font-size:12px;color:#74805a}.contributor.cooking{background:#fff9e7;border-color:#8b9d64}.contributor.cooking small{color:#57713f}.order-ticket .ticket-footer{margin-top:auto}.order-ticket .ticket-footer button{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:72%;text-align:left}.order-ticket .ticket-footer>span{white-space:nowrap}#table-button{color:#395c52;background:#fff3d7}.ticket.empty{min-height:112px;align-items:flex-start;text-align:left}.ticket.empty span{font-weight:600}.order-caption{letter-spacing:1px}.order-ticket[data-selected=true]{outline-color:var(--order-color)} +@media(max-width:760px){.order-ticket{min-height:136px}.rail-actions{gap:4px}.rail-actions #queue-button{display:none}.order-caption{gap:8px}.order-caption>span{flex-shrink:0;font-size:10px}.order-caption .rail-actions button{font-size:10px;padding:2px 4px}.order-board .ticket-rail{padding-top:12px}.order-ticket .ticket-footer{font-size:10px}.order-ticket .ticket-footer button{font-size:10px}} +@media(prefers-reduced-motion:reduce){.order-ticket,.contributor{transition:none}} + +.recent-title,#setup-content h2{font-size:18px;line-height:24px;margin:24px 0 12px}.recent-repo{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;padding:8px 12px;margin:4px 0;border:1px solid #cfb48a;border-radius:8px;background:#fff5e0;text-align:left}.recent-repo>span{min-width:0}.recent-repo strong{display:block;font-size:14px;line-height:20px}.recent-repo small{display:block;font-size:12px;line-height:16px;color:#786950}.recent-repo b{font-size:12px;white-space:nowrap;color:#3e7480}.repo-path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:400px}.repo-help,.repo-empty{font-size:12px!important;line-height:16px!important;color:#77674e}.repo-help{margin:8px 0!important}#setup-content{margin-top:24px}.provider-row>div{min-width:0}.provider-row>button{flex-shrink:0}.provider-row small{max-width:400px}#open-repo{white-space:nowrap}#roster .unlinked-session{border-style:solid}#roster .unlinked-session .chef-dot{background:var(--chef-color);color:white}.topbar .project-controls{min-width:120px} +@media(max-width:760px){#open-repo{font-size:12px;padding:6px 8px}.repo-path{max-width:240px}.provider-row{gap:8px}.provider-row>button{font-size:12px;padding:4px 8px}} +@media(hover:hover) and (pointer:fine){.recent-repo:hover{background:#fffbed}} +#record-kitchen{display:flex;align-items:center;justify-content:center;gap:8px;white-space:nowrap;font-size:14px;font-weight:700}.record-dot{width:8px;height:8px;border-radius:50%;background:#a54738;flex-shrink:0}#record-kitchen[aria-pressed=true]{background:#f9d792}#record-kitchen[aria-pressed=true] .record-dot{border-radius:2px}#record-time{font-variant-numeric:tabular-nums}#recording-note{position:absolute;right:0;bottom:calc(100% + 8px);max-width:min(320px,calc(100vw - 32px));padding:8px 12px;background:var(--paper);border:1px solid #c8a273;border-radius:8px;font-size:12px;line-height:16px;box-shadow:0 4px 0 #81533033;z-index:2}#recording-status{display:block}#recording-download{font-weight:700;display:inline-block;margin-top:8px}#recording-download:focus-visible{outline:3px solid #337fba;outline-offset:4px}#recording-download[hidden],#record-time[hidden]{display:none}.bottom-bar .crew-dock{min-width:0}.view-controls{flex-shrink:0} +@media(max-width:760px){#record-kitchen{flex-wrap:wrap;max-width:104px;font-size:12px;gap:4px}.bottom-bar .crew-dock{max-width:calc(100% - 112px)}} diff --git a/packages/kitchen/public/paper.svg b/packages/kitchen/public/paper.svg new file mode 100644 index 0000000..3f7d197 --- /dev/null +++ b/packages/kitchen/public/paper.svg @@ -0,0 +1 @@ + diff --git a/packages/kitchen/public/src/activity.js b/packages/kitchen/public/src/activity.js new file mode 100644 index 0000000..c426662 --- /dev/null +++ b/packages/kitchen/public/src/activity.js @@ -0,0 +1,51 @@ +// Shared presentation rules. These never assign or complete real work. +export const providerName={codex:'Codex',claude:'Claude',cursor:'Cursor'}; +export const needsAttention=s=>['permission','input','error'].includes(s.state)&&!s.ended; +export const isWorking=s=>s.roleId?s.workingCount>0:!s.ended&&s.freshness!=='quiet'&&['reading','writing','executing','working'].includes(s.state); +export const isCurrent=s=>needsAttention(s)||isWorking(s); +export const kitchenForSession=(s,p)=>p.kitchens.find(k=>k.components.includes(s?.component?.id))||p.kitchens[0]; +export function activityText(s,connected=true){ + if(!connected)return 'Connection lost'; + if(s.roleId&&s.state==='idle')return s.roleStatus||'Not running'; + const file=s.currentFile||(s.source==='example'?s.file:null),leaf=file?.split('/').at(-1); + const labels={reading:leaf?`Reading ${leaf}`:/search|grep|glob|find/i.test(s.tool||'')?'Searching files':'Reading',writing:leaf?`Editing ${leaf}`:'Editing files',executing:'Running a command',working:'Working',permission:'Needs permission',input:'Needs your input',error:'Tool failed',complete:'Turn complete',interrupted:'Interrupted',offline:'Session ended',unknown:'Action unavailable'}; + let text=s.state==='working'&&/(?:^|\.)(?:update_plan|TodoWrite|TaskCreate|TaskUpdate|TaskList|TaskGet|todo_write|write_todos)$/i.test(s.tool||'')?(s.activeToolCount?'Updating the plan':'Plan updated'):labels[s.state]||'Action unavailable'; + if(s.workContext?.label&&['working','unknown'].includes(s.state)&&!/(?:update_plan|TodoWrite|TaskCreate|TaskUpdate|TaskList|TaskGet|todo_write|write_todos)$/i.test(s.tool||''))text=(s.workContext.completed&&s.freshness!=='quiet'?'Last: ':'')+s.workContext.label; + if(s.freshness==='quiet'&&!needsAttention(s))text=`Last seen: ${text.toLowerCase()}`; + if(s.activeToolCount>1)text+=` · +${s.activeToolCount-1} actions`; + if(s.roleId&&s.executors?.length)text+=` · ${providerName[s.provider]||'Agent'}${s.executors.length>1?` +${s.executors.length-1}`:''}`; + return text; +} +export class IdentityBook { + constructor(saved=[]){this.records=new Map((Array.isArray(saved)?saved:[]).filter(r=>typeof r?.key==='string'&&Number.isInteger(r.index)&&r.index>=0&&r.index<12&&typeof r.badge==='string').slice(-256).map(r=>[r.key,r]));} + assign(crew){ + const ordered=[...crew].sort((a,b)=>(a.startedAt||0)-(b.startedAt||0)||a.id.localeCompare(b.id)); + const used=new Map(); + for(const s of ordered){const record=this.records.get(s.project+'|'+s.id);if(record&&!s.ended){if(!used.has(s.project))used.set(s.project,new Set());const colors=used.get(s.project);if(colors.has(record.index)){record.index=Array.from({length:12},(_,i)=>i).find(i=>!colors.has(i))??record.index;}colors.add(record.index);}} + for(const s of ordered){const key=s.project+'|'+s.id;if(this.records.has(key))continue; + const colors=used.get(s.project)||new Set(),count=[...this.records.keys()].filter(k=>k.startsWith(s.project+'|')).length; + const index=Array.from({length:12},(_,i)=>i).find(i=>!colors.has(i))??count%12; + const badge=count<26?String.fromCharCode(65+count):String(count+1); + this.records.set(key,{key,index,badge});if(!s.ended)colors.add(index);used.set(s.project,colors); + } + while(this.records.size>256)this.records.delete(this.records.keys().next().value); + return crew.map(s=>{const r=this.records.get(s.project+'|'+s.id);return {...s,visualIndex:r.index,badge:r.badge,displayName:s.name||`${providerName[s.provider]||'Agent'} ${r.badge}`};}); + } + save(){return [...this.records.values()];} +} +export function goalCards(p,crew){ + const sessions=crew.filter(s=>s.project===p.id&&!s.ended); + const cards=p.components.map(c=>{ + const chefs=sessions.filter(s=>s.roleComponents?s.roleComponents.includes(c.id):s.component?.id===c.id).map(s=>s.roleId&&isCurrent(s)&&s.activityComponentId!==c.id?{...s,state:'idle',workingCount:0,currentTask:null,roleStatus:s.activityComponentId?'Working on '+(p.components.find(n=>n.id===s.activityComponentId)?.title||'another goal'):'Working · goal not linked'}:s),tasks=c.tasks||[]; + return {...c,chefs,counts:{total:tasks.length,done:tasks.filter(t=>t.state==='x').length,active:tasks.filter(t=>t.state==='~').length,blocked:tasks.filter(t=>t.state==='!').length},currentTask:tasks.find(t=>t.state==='~')?.title||null,kitchenId:p.kitchens.find(k=>k.components.includes(c.id))?.id}; + }); + const unlinked=sessions.filter(s=>!s.component&&!s.roleId); + if(unlinked.length)cards.push({id:'__unlinked',title:p.workflow?'Role not linked':'Goal not linked',tasks:[],chefs:unlinked,counts:{total:0,done:0,active:0,blocked:0},currentTask:null,kitchenId:p.kitchens[0]?.id}); + return cards; +} +export function rankedGoals(cards,{kitchenId,pinned=[],order=[]}={}){ + const rank=c=>pinned.includes(c.id)?0:c.chefs.some(needsAttention)||c.counts.blocked?1:c.chefs.some(isCurrent)?2:c.counts.active?3:4; + return cards.filter(c=>pinned.includes(c.id)||c.kitchenId===kitchenId) + .filter(c=>pinned.includes(c.id)||rank(c)<4||c.id==='__unlinked'||c.counts.dones.roleId)||c.kind) + .sort((a,b)=>rank(a)-rank(b)||(order.includes(a.id)?order.indexOf(a.id):999)-(order.includes(b.id)?order.indexOf(b.id):999)); +} diff --git a/packages/kitchen/public/src/app.js b/packages/kitchen/public/src/app.js new file mode 100644 index 0000000..47559cc --- /dev/null +++ b/packages/kitchen/public/src/app.js @@ -0,0 +1,170 @@ +import {projectOrders,orderForChef,orderCrew,orderState,orderColor,sharedStations} from './orders.js'; +import {KitchenWorld} from './world.js'; +import {demoState} from './demo.js'; +import {apronColors} from './chefs.js'; +import {IdentityBook,activityText,goalCards,isCurrent,isWorking,needsAttention,kitchenForSession,providerName} from './activity.js'; +import {setupKitchenRecording} from './recording.js'; + +const $=id=>document.getElementById(id),esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +setupKitchenRecording(); +const icons={map:'',gear:'',network:'',box:'',image:''}; +const icon=kind=>``; +const stateLabel={reading:'Reading',writing:'Writing',executing:'Cooking · command',working:'Working',permission:'Needs permission',input:'Needs your input',error:'Tool error',complete:'Turn complete',interrupted:'Interrupted',offline:'Session ended',idle:'Not running',unknown:'State unknown',quiet:'Last seen'}; +const taskName={' ':'Planned','~':'In progress','x':'Complete','!':'Blocked'}; +const title=s=>s.displayName||s.name||`${providerName[s.provider]||'Agent'} ${s.sessionId.slice(-4)}`; +const attention=needsAttention; +const color=s=>'#'+apronColors[s.visualIndex??0].toString(16).padStart(6,'0'); +const age=at=>{const n=Math.max(0,Math.round((Date.now()-at)/1000));return n<10?'just now':n<60?`${n}s ago`:n<3600?`${Math.floor(n/60)}m ago`:`${Math.floor(n/3600)}h ago`;}; +const htmlCache=new WeakMap();function html(el,value){if(htmlCache.get(el)!==value){const focused=document.activeElement,field=focused&&el.contains(focused)?['chef','goal','pin','deliverable','kitchen','station','plate','order','table'].find(k=>focused.dataset[k]):null,identity=field&&focused.dataset[field];el.innerHTML=value;htmlCache.set(el,value);if(field)[...el.querySelectorAll(`[data-${field}]`)].find(n=>n.dataset[field]===identity)?.focus({preventScroll:true});}} +const launchParams=new URLSearchParams(location.search); +let mode=launchParams.get('mode')==='live'?'live':launchParams.get('mode')==='demo'?'demo':localStorage.getItem('kitchen-mode')==='demo'?'demo':'live',live={projects:[],crew:[],artifacts:[],transfers:[]},example=demoState(),demoStep=0,projectId=launchParams.get('project')||localStorage.getItem('kitchen-project'),kitchenId=null,selection=null,token='',connected=false,world=null,current={},labels=new Map(),setupProject=null,setupReview=null; +const state=()=>mode==='demo'?example:live; +function saveLocation(){const url=new URL(location.href);url.searchParams.set('mode',mode);if(mode==='live'&&projectId)url.searchParams.set('project',projectId);else url.searchParams.delete('project');history.replaceState(null,'',url);} +function getProject(){return state().projects.find(p=>p.id===projectId)||state().projects[0];} +function crewForKitchen(p,k){return state().crew.filter(s=>s.project===p.id&&!s.ended&&kitchenForSession(s,p)?.id===k.id).sort((a,b)=>a.id.localeCompare(b.id));} + +const readSetting=(key,fallback)=>{try{return JSON.parse(localStorage.getItem(key))||fallback;}catch{return fallback;}}; +const identities=new IdentityBook(readSetting('kitchen-identities',[])); +const action=s=>activityText(s,mode==='demo'||connected); +const selectedOrder=()=>selection?.kind==='order'?selection.id:selection?.kind==='chef'?orderForChef(current.orders||[],selection.id)?.id:null; +const selectedGoal=()=>selection?.kind==='chef'?(state().crew.find(s=>s.id===selection.id)?.component?.id||'__unlinked'):['goal','station'].includes(selection?.kind)?selection.id:null; +function chefRow(s,showTask=false){return ``;} +function renderTickets(orders){ + const active=selectedOrder(),p=current.p; + html($('tickets'),orders.map(o=>{ + const contributors=orderCrew(o,current.data.crew),status=orderState(o),table=current.tables.find(t=>t.id===o.tableId); + return `
#${o.number}${esc(status)}
${[...contributors].sort((a,b)=>Number(b.active)-Number(a.active)).slice(0,3).map(c=>``).join('')||'Waiting for observed contributions'}${contributors.length>3?``:''}
`; + }).join('')||(()=>{const unplanned=(current.data.unplanned||[]).filter(u=>u.project===p.id),sessions=(current.data.executors||current.crew).filter(s=>s.project===p.id&&!s.ended&&unplanned.some(u=>u.sessionId===s.id)),session=sessions.find(isCurrent)||sessions[0],finished=current.orders.some(o=>!o.withdrawn&&o.status==='completed'),unknown=unplanned.length&&(!finished||session&&isCurrent(session));return `
${unknown?'Current work · progress unknown':finished?'Reported orders complete':'Waiting for native orders'}${unknown&&session?esc(action(session)):'New todos from your agent will appear here.'}${unknown?'No native todo list is available for this activity.':finished?'Completed todos are on the deliverable table. The larger outcome is tracked separately.':'The project map remains available for context.'}
`;})()); +} +function orderDetails(o){ + if(!o)return '

This order is no longer available.

'; + const contributors=orderCrew(o,current.data.crew),table=current.tables.find(t=>t.id===o.tableId); + return `
SHARED DISH · #${o.number}

${esc(o.title)}

${esc(orderState(o))}${esc(o.source)}

One native todo, prepared by its contributing chefs.

Who contributed

${contributors.map(c=>`
${c.chef?``:`${esc(c.name)} · role not linked`}${c.active?'Working on this dish now':'Contributed earlier'} · ${esc(c.association.kind)}${esc(c.sessionId)}${esc(c.association.reason)}
`).join('')||'

No contributing role has been observed yet.

'}

Contributions can happen in sequence within one session. They do not, by themselves, confirm an artifact transfer.

Shared inputs and outputs

${current.data.artifacts.filter(a=>a.orderId===o.id).map(a=>``).join('')||'

No artifact has been explicitly linked to this dish.

'}${current.data.transfers.filter(t=>t.orderId===o.id).map(transferRow).join('')}

Native plan history

${[...o.history].reverse().map(h=>`
${esc(h.title)}${esc(h.status==='withdrawn'?'Withdrawn from plan':h.status==='completed'?'Todo marked complete':h.status==='in_progress'?'In progress':'Queued')} · ${age(h.at)}
`).join('')}

Order identity

${esc(o.id)}

A completed todo moves to the table. It does not confirm publication or delivery of the larger outcome.

`; +} +function tableDetails(id){ + const tables=id==='all'?current.tables:current.tables.filter(t=>t.id===id); + return `
DELIVERABLE TABLE

What we’re making together

The conveyor brings completed todos here. The table collects their contributions toward the larger outcome.

${tables.map(t=>`

${esc(t.title)}

${t.reported?'Source-reported outcome':'Project collection · larger outcome not reported'}

${t.completedIds.length} completed ${t.completedIds.length===1?'dish':'dishes'} · outcome progress unknown
${t.completedIds.map(id=>current.orders.find(o=>o.id===id)).filter(Boolean).map(o=>``).join('')||'

No completed dishes yet.

'}`).join('')}

Withdrawn orders

${current.orders.filter(o=>o.withdrawn).slice(-12).reverse().map(o=>``).join('')||'

None observed.

'}`; +} +function render(){ + const data=state(),p=getProject();if(!p){$('notice').textContent=connected?'Choose a project to open its kitchen.':'The local service is reconnecting. You can explore the example kitchen.';return;} + data.crew=identities.assign(data.crew);try{localStorage.setItem('kitchen-identities',JSON.stringify(identities.save()));}catch{} + projectId=p.id;const kitchens=p.kitchens?.length?p.kitchens:[{id:'shared',title:'Main kitchen',components:[],counts:{total:0,done:0,active:0,blocked:0}}];p.kitchens=kitchens; + const k=kitchens.find(k=>k.id===kitchenId)||kitchens[0];kitchenId=k.id; + const components=p.components.filter(c=>k.components.includes(c.id)),crew=crewForKitchen(p,k),crewIds=new Set(crew.map(s=>s.id)); + const artifacts=data.artifacts.filter(a=>a.project===p.id&&(k.components.includes(a.componentId)||crewIds.has(a.producer)||data.transfers.some(h=>h.artifactKey===a.id&&crewIds.has(h.recipient))||(!a.componentId&&k.id===kitchens[0].id&&!data.crew.some(s=>s.id===a.producer)))); + const transfers=data.transfers.filter(t=>t.project===p.id),goals=goalCards(p,data.crew);current={data,p,k,components,crew,artifacts,transfers,goals,orders:projectOrders(data,p.id),tables:(data.tables||[]).filter(t=>t.project===p.id)}; + html($('project'),data.projects.map(p=>``).join(''));$('project').value=p.id; + for(const [id,active] of [['live-mode',mode==='live'],['demo-mode',mode==='demo']]){$(id).classList.toggle('active',active);$(id).setAttribute('aria-pressed',String(active));} + $('next-example').hidden=mode!=='demo';$('next-example').textContent=demoStep>=8?'Replay example ↻':'Next example step →'; + html($('connection'),`${mode==='demo'?'Example activity':connected?'Watching locally':'Reconnecting'}`);$('connection').classList.toggle('offline',mode==='live'&&!connected); + const ordered=current.orders.filter(o=>!o.withdrawn&&o.status!=='completed').sort((a,b)=>a.scope.localeCompare(b.scope)||a.index-b.index); + renderTickets(ordered); + $('rail-caption').textContent=`ON THE MENU · ${ordered.length} ${ordered.length===1?'ORDER':'ORDERS'}`; + const complete=current.tables.reduce((n,t)=>n+t.completedIds.length,0);$('table-button').textContent=`Deliverable table · ${complete} complete ↗`; + const urgent=data.crew.filter(s=>s.project===p.id&&attention(s)),blocked=goals.filter(g=>g.counts.blocked&&!g.chefs.some(attention)); + $('attention').hidden=urgent.length+blocked.length===0;$('attention').textContent=`Needs you · ${urgent.length+blocked.length}`; + html($('kitchens'),kitchens.map(room=>{const cs=crewForKitchen(p,room),n=cs.filter(isWorking).length;return ``;}).join(''));$('kitchen-count').textContent=`${kitchens.length} ${kitchens.length===1?'kitchen':'kitchens'}`; + $('crew-count').textContent=crew.length; + html($('roster'),crew.map(s=>``).join('')||'Ready for an agent to join'); + const sessions=(data.executors||data.crew).filter(s=>s.project===p.id&&!s.ended),unlinked=crew.filter(s=>s.unlinkedRole); + $('workflow-summary').textContent=`${crew.length} ${crew.length===1?'chef':'chefs'} · ${sessions.length} ${sessions.length===1?'session':'sessions'} · ${crew.filter(isWorking).length} working`; + $('queue-button').hidden=!p.workflow?.queue;$('queue-button').textContent=p.workflow?.queue?(p.workflow.queue.available?`Draft queue · ${p.workflow.queue.counts?.queue||0} need review`:'Draft queue unavailable'):''; + $('source-note').textContent=mode==='demo'?'EXAMPLE · One Codex session, several roles, shared orders · Use Next example step to follow the handoff':'Native todos = dishes · Chefs = observed agents or project roles · Conveyor = completed todos'; + $('notice').textContent=mode==='live'&&!connected?'Connection lost. Showing the last observed state.':p.warnings?.[0]||(p.workflow?.origin==='inferred'?`One shared workflow · ${crew.length} chefs · roles inferred from this repo. ${sessions.length===1?'One session moves between them.':!sessions.length?'Waiting for an agent session.':''}`:unlinked.length?`${unlinked.length} ${unlinked.length===1?'agent is shown as a session chef':'agents are shown as session chefs'} until a project role is known.`:!crew.length?(p.activity?.length?'Files are changing. Connect an agent to see who is working.':'Waiting for agent activity. Open repo or Connect agents to get started.'):crew.length>12?`${crew.length} chefs observed; 12 shown. All are available in the roster.`:''); + const visibleCrew=[...crew].sort((a,b)=>Number(isCurrent(b))-Number(isCurrent(a))).slice(0,12).sort((a,b)=>a.id.localeCompare(b.id)); + const active=selectedGoal(),next=new Map();for(const s of visibleCrew){let el=labels.get(s.id);if(!el){el=document.createElement('div');$('chef-labels').append(el);}const dish=orderForChef(current.orders,s.id),chosen=current.orders.find(o=>o.id===selectedOrder()),related=chosen?chosen.contributors.some(c=>c.chefId===s.id):active===(s.component?.id||'__unlinked');el.className='chef-label'+(isWorking(s)?' active':'')+(selection?.id===s.id?' selected':'')+(related?' related':'')+((active||chosen)&&!related?' dim':'')+(attention(s)?' attention':'');el.style.setProperty('--chef-color',color(s));html(el,`${esc(s.badge)} · ${esc(title(s))}${dish?' · #'+dish.number:''}${attention(s)?' !':''}${esc(dish?'#'+dish.number+' · '+action(s):action(s))}`);next.set(s.id,el);}for(const [id,el] of labels)if(!next.has(id))el.remove();labels=next; + const stations=sharedStations(visibleCrew.length); + world?.setData({components:stations,crew:visibleCrew,artifacts,transfers,demo:mode==='demo',kitchenId,projectId,connected:mode==='demo'||connected,orders:current.orders,tables:current.tables});world?.select(selection); + if(selection)renderInspector(); +} +function setMode(next){mode=next;if(next==='demo'){demoStep=0;example=demoState();}localStorage.setItem('kitchen-mode',mode);projectId=mode==='demo'?example.projects[0].id:localStorage.getItem('kitchen-project');kitchenId=null;closeInspector();saveLocation();render();} +function switchKitchen(id){kitchenId=id;closeInspector();world?.fit();render();} +function select(item){const p=getProject(),s=item.kind==='chef'&&state().crew.find(s=>s.id===item.id),goal=['goal','station'].includes(item.kind)&¤t.goals.find(g=>g.id===item.id),room=s?kitchenForSession(s,p):goal&&p.kitchens.find(k=>k.id===goal.kitchenId);if(room&&room.id!==kitchenId){kitchenId=room.id;world?.fit();}selection=item;$('inspector').hidden=false;renderInspector();world?.select(item);render();const focusGoal=selectedOrder();[...$('tickets').children].find(el=>el.dataset.orderId===focusGoal)?.scrollIntoView({block:'nearest',inline:'nearest',behavior:'instant'});} +function closeInspector(){selection=null;$('inspector').hidden=true;world?.select(null);} +function counts(c){return `
${c.done}/${c.total}tasks complete
${c.active}in progress
${c.blocked}blocked
`;} +function taskRows(tasks){return `
${tasks.map(t=>`
${t.state==='x'?'✓':t.state==='~'?'◉':t.state==='!'?'!':'○'}
${esc(t.title)}${taskName[t.state]}${t.by?' · '+esc(t.by):''}${t.componentId?' · '+esc(current.p.components.find(c=>c.id===t.componentId)?.title||''):''}
`).join('')||'

No declared tasks yet.

'}
`;} +function kitchenButtons(ids){return ids.map(id=>``).join('');} +function inspectorChefs(chefs){return `
${chefs.map(s=>`
${chefRow(s)}${s.currentTask?`

Session task: ${esc(s.currentTask.title)}

`:''}
`).join('')||'

No agent observed here.

'}
`;} +function renderInspector(){ + if(!selection||!current.p)return;const {p,data}=current;let content=''; + if(selection.kind==='order'){content=orderDetails(current.orders.find(o=>o.id===selection.id)); + }else if(selection.kind==='table'){content=tableDetails(selection.id); + }else if(selection.kind==='workstation'){content=`
SHARED WORKSTATION

${esc(sharedStations(12).find(s=>s.id===selection.id)?.title||'Shared counter')}

Any chef can use this counter while preparing an order. Follow the order number to see the shared dish.

`; + }else if(selection.kind==='deliverable'){ + const d=p.deliverables.find(d=>d.id===selection.id);if(!d){closeInspector();return;} + content=`
ON THE MENU

${esc(d.title)}

${d.source==='component'?'This component is an outcome on the project map.':'One deliverable, with work across its contributing stations.'}

${counts(d.counts)}${d.missing?.length?'

Some task references are missing; completion is unknown.

':''}

Contributing kitchens

${kitchenButtons(d.kitchenIds)}

What needs to happen

${taskRows(d.tasks)}`; + }else if(selection.kind==='chef'){ + const s=data.crew.find(s=>s.id===selection.id);if(!s){closeInspector();return;} + const delivered=data.transfers.filter(t=>t.sender===s.id||t.recipient===s.id); + content=s.roleId?roleInspector(s,data):`
YOUR CHEF

${esc(title(s))}

${esc(providerName[s.provider])}${esc(stateLabel[s.state]||'Unknown')}${esc(s.source)}

${s.freshness==='quiet'?'Quiet observation · ':''}Last update ${age(s.lastEventAt)}.

At this station

${s.component?``:'

Shared prep. No unique component match is available.

'}

${esc(s.association?.reason||'Source reports this goal')}${s.association?.kind==='inferred'?' · inferred link':''}

Current activity

${esc(action(s))}
${s.currentTask?`

Session task

${esc(s.currentTask.title)}

`:''}

Last reported tool

${esc(s.tool||'No tool reported')}
${s.file?`

Last observed file

${esc(s.file)}
`:''}${attention(s)?'

Respond in the agent’s source tool. This kitchen observes the request.

':''}

Recent activity

${s.recent.slice(0,6).map(e=>`
${esc(e.tool||e.kind)}${esc(e.kind)} · ${age(e.at)}
`).join('')}

Plates

${delivered.map(t=>transferRow(t)).join('')||'

No confirmed artifact exchange has been reported.

'}`; + }else if(selection.kind==='station'||selection.kind==='goal'){ + const c=current.goals.find(c=>c.id===selection.id);if(!c){closeInspector();return;} + const k=p.kitchens.find(k=>k.components.includes(c.id)); + content=`${workflowCounter(c,p)}
AGENTTRAIL GOAL

${esc(c.title)}

${k?kitchenButtons([k.id]):''}

Working on this goal

${inspectorChefs(c.chefs)}

Recorded tasks

${taskRows(c.tasks)}

Owns these files

${(c.files||[]).map(esc).join('
')||'No files declared'}

Needs first

${(c.needs||[]).map(id=>``).join('')||'

No prerequisites declared.

'}

Works with

${(c.links||[]).map(id=>``).join('')||'

No links declared.

'}

These relationships come from the plan; they do not imply an artifact exchange.

`; + }else if(selection.kind==='plate'){ + const a=data.artifacts.find(a=>a.id===selection.id);if(!a){closeInspector();return;} + const producer=data.crew.find(s=>s.id===a.producer)||(data.executors||[]).find(s=>s.id===a.producer); + content=`
ON THIS PLATE

${esc(a.label)}

${esc(a.kind)}${esc(a.source)}
${a.orderId?``:''}

Artifact

${esc(a.file||a.artifactId)}

Revision

${esc(a.revisionId)}
${a.workflowItem?`

Workflow state

${esc(a.workflowItem.label)}

${esc(a.workflowItem.subreddit)}

`:''}

Prepared by

${producer?``:`

${esc(a.producer||'Author not reported by the workflow')}

`}

Handoffs

${data.transfers.filter(t=>t.artifactKey===a.id).map(transferRow).join('')||'

Available output. No recipient has been reported.

'}`; + }else if(selection.kind==='kitchens'){ + content=`
THE WHOLE PROJECT

${esc(p.name)}

One project, ${p.kitchens.length} ${p.kitchens.length===1?'kitchen':'kitchens'}. Chefs share workstations, while native orders and the deliverable table follow the project.

${p.kitchens.map(k=>{const cs=crewForKitchen(p,k);return ``;}).join('')}

Recent repository changes

${(p.activity||[]).slice(0,6).map(a=>`
${esc(a.file)}${age(a.at)} · file observation, agent unknown
`).join('')||'

No recent repository changes.

'}

Plates between kitchens

${data.transfers.filter(t=>{const a=data.crew.find(s=>s.id===t.sender),b=data.crew.find(s=>s.id===t.recipient);return a&&b&&kitchenForSession(a,p).id!==kitchenForSession(b,p).id;}).map(transferRow).join('')||'

No confirmed transfers between kitchens yet.

'}${p.boardUrl?`Open Agenttrail ↗`:''}`; + }else if(selection.kind==='queue'){ + content=queueInspector(p); + }else if(selection.kind==='orders'){ + content=`
THE PROJECT MAP

All goals

Current activity and recorded task completion stay separate.

${current.goals.map(g=>``).join('')}

Project deliverables

Outcomes that can span goals and kitchens.

${p.deliverables.filter(d=>d.source!=='component').map(d=>``).join('')||'

No additional deliverables configured.

'}`; + }else if(selection.kind==='attention'){ + const urgent=data.crew.filter(s=>s.project===p.id&&attention(s)); + content=`
NEEDS YOU

Requests across kitchens

${inspectorChefs(urgent)}${current.goals.filter(g=>g.counts.blocked).map(g=>``).join('')}

Respond in the agent's source tool.

`; + + } + html($('inspector-content'),content); +} +function safeLink(url){try{const u=new URL(url);return ['http:','https:'].includes(u.protocol)?esc(u.href):'';}catch{return '';}} +function workflowCounter(c,p){ + const q=p.workflow?.queue;if(!c.kind)return ''; + if(c.kind==='knowledge')return '
Shared pantry · knowledge used by the crew
'; + const link=safeLink(c.url);return `
Your review counter${q?` · ${q.counts?.queue||0} items waiting`:''}
${link?`Open review console ↗`:''}${q?'':''}`; +} +function roleInspector(s,data){ + const recent=s.recentWork; + const roleNotes=`${s.roleDescription?'

'+esc(s.roleDescription)+'

':''}${s.roleOrigin==='inferred'?'

Role suggested from this repo’s structure. Activity attribution is inferred from observed operations.

':''}${recent?`

Last observed contribution

${esc(recent.label)}${recent.file?' · '+esc(recent.file):''}
${esc(providerName[recent.provider])} · ${age(recent.at)}

`:''}`; + return `
WORKFLOW CHEF

${esc(title(s))}

${s.roleOrigin==='inferred'?'Inferred role':'Persistent role'}${s.executors.length} ${s.executors.length===1?'session':'sessions'}${esc(stateLabel[s.state]||s.state)}

${esc(action(s))}

Shared dishes

${current.orders.filter(o=>o.contributors.some(c=>c.chefId===s.id)).slice(-8).map(o=>``).join('')||'

No contributions observed yet.

'}

Responsibilities

${roleNotes}${s.roleComponents.map(id=>``).join('')}

Who is executing

${s.executors.map(e=>`
${esc(providerName[e.provider])}${esc(e.sessionId)}

${esc(activityText(e,mode==='demo'||connected))}

${e.currentTask?`

${esc(e.currentTask.title)}

`:''}${esc(e.association?.reason)} · ${esc(e.association?.kind)}
Last observed ${age(e.lastEventAt)}
`).join('')||'

No session assigned. This chef stays here, ready for the next task.

'}${s.queueCount?`

On the counter

${esc(s.roleStatus)}

`:''}

Role identity stays the same when the executing session changes.

`; +} +function queueInspector(p){ + const q=p.workflow?.queue;if(!q)return '

No workflow queue is connected.

'; + const filter=selection.id||'all',stages={all:'All',write:'Revise',evaluate:'Evaluate',queue:'Your review',publisher:'Dispatch',history:'History',unknown:'Unknown'},items=q.items.filter(i=>filter==='all'||i.stage===filter); + return `
WORKFLOW PLATES

Draft queue

${esc(q.summary)} · ${q.items.length} items

These are recorded item states, separate from the project checklist.

${q.blocked?'
Dispatch is blocked in the workflow.
':''}
${Object.entries(stages).map(([id,label])=>``).join('')}
${items.slice(0,100).map(i=>``).join('')||'

No items in this stage.

'}${items.length>100?`

Showing the first 100 of ${items.length} items.

`:''}`; +} +function transferRow(t){const data=state(),p=current.p,a=data.artifacts.find(a=>a.id===t.artifactKey),sender=[...data.crew,...(data.executors||[])].find(s=>s.id===t.sender),recipient=[...data.crew,...(data.executors||[])].find(s=>s.id===t.recipient);return `
${esc(p.workflow?.roles.find(r=>r.id===t.senderRoleId)?.title||(sender?title(sender):t.sender))} → ${esc(p.workflow?.roles.find(r=>r.id===t.recipientRoleId)?.title||(recipient?title(recipient):t.recipient))}${esc(t.state)} · ${esc(t.source)}${recipient?kitchenButtons([kitchenForSession(recipient,p).id]):''}
`;} + +document.addEventListener('click',e=>{const el=e.target.closest('button');if(!el)return;if(el.dataset.queue){selection={kind:'queue',id:el.dataset.queue};$('inspector').hidden=false;renderInspector();return;}for(const [attr,kind] of [['order','order'],['table','table'],['goal','goal'],['chef','chef'],['deliverable','deliverable'],['station','station'],['plate','plate']])if(el.dataset[attr]){select({kind,id:el.dataset[attr]});return;}if(el.dataset.kitchen)switchKitchen(el.dataset.kitchen);}); +$('next-example').onclick=()=>{demoStep=demoStep>=8?0:demoStep+1;example=demoState(demoStep);render();}; +$('live-mode').onclick=()=>setMode('live');$('demo-mode').onclick=()=>setMode('demo');$('project').onchange=e=>{projectId=e.target.value;if(mode==='live')localStorage.setItem('kitchen-project',projectId);kitchenId=null;closeInspector();saveLocation();render();}; +$('close-inspector').onclick=()=>{closeInspector();render();};$('queue-button').onclick=()=>select({kind:'queue',id:'all'});$('table-button').onclick=()=>select({kind:'table',id:'all'});$('all-orders').onclick=()=>select({kind:'orders'});$('attention').onclick=()=>select({kind:'attention'});$('kitchen-map').onclick=()=>select({kind:'kitchens'}); +$('pause').onclick=()=>{if(!world)return;world.paused=!world.paused;$('pause').setAttribute('aria-pressed',String(world.paused));$('pause').textContent=world.paused?'▶':'Ⅱ';$('pause').title=world.paused?'Resume chef motion':'Pause chef motion';}; +$('room-view').onclick=()=>{const active=document.body.classList.toggle('room-focus');$('room-view').setAttribute('aria-pressed',String(active));$('room-view').textContent=active?'Show orders':'Room view';};$('fit').onclick=()=>world?.fit();$('zoom-in').onclick=()=>world?.changeZoom(.12);$('zoom-out').onclick=()=>world?.changeZoom(-.12); +document.addEventListener('keydown',e=>{if(e.key==='Escape'&&!$('setup-dialog').open){closeInspector();render();}}); + +async function post(endpoint,body){const res=await fetch(endpoint,{method:'POST',headers:{'content-type':'application/json','x-office-token':token},body:JSON.stringify(body)});const data=await res.json();if(!res.ok)throw new Error(data.error||'The action could not be completed.');return data;} +function renderSetup(){ + const p=live.projects.find(p=>p.id===setupProject)||live.projects[0];setupProject=p?.id;const installed=live.installed?.[p?.id]||{},sessions=(live.executors||[]).filter(s=>s.project===p?.id&&!s.ended); + html($('recent-repos'),(live.recentProjects||[]).slice(0,8).map(repo=>``).join('')||'

No recent agent projects detected yet. Paste a folder path below.

'); + html($('setup-content'),`

Agent connections

${p?.components.length?'Your project map adds role names and context.':'No project map needed. A shared crew follows your session’s workflow.'}

${['codex','claude','cursor'].map(provider=>{const observed=sessions.filter(s=>s.provider===provider),active=observed.filter(isCurrent),latest=observed.sort((a,b)=>b.lastEventAt-a.lastEventAt)[0],todos=observed.filter(s=>s.planAvailable).length;return `
${providerName[provider]}${observed.length?`${active.length?'Receiving activity':'Last activity '+age(latest.lastEventAt)} · ${observed.length} ${observed.length===1?'session':'sessions'}${todos?' · native todos available':' · todo progress unknown'}`:provider==='cursor'?(installed[provider]?'Hooks configured · waiting for an event':'Connect activity hooks for this repo'):'Watching local sessions · none seen in this repo yet'}
${provider==='cursor'?'New hooks may require starting a new agent conversation.':provider==='claude'?'Local logs work automatically; hooks add detail.':'Local observation · no repo setup required.'}
${provider==='codex'?'Automatic':``}
`;}).join('')}

Orders follow native todos when available. Precise data handoffs require explicit artifact information.

`); + if($('setup-project')){$('setup-project').value=p?.id||'';$('setup-project').onchange=e=>{setupProject=e.target.value;setupReview=null;renderSetup();};} +} +async function openRepo(path){ + const result=await post('/api/projects',{path});mode='live';projectId=result.id;kitchenId=null;selection=null;localStorage.setItem('kitchen-mode','live');localStorage.setItem('kitchen-project',projectId);saveLocation();await refresh();closeInspector();$('setup-dialog').close();world?.fit();render(); +} +function openConnections(){setupProject=mode==='live'?projectId:live.projects[0]?.id;setupReview=null;renderSetup();$('setup-message').textContent='';$('setup-dialog').showModal();} +$('connect').onclick=openConnections;$('open-repo').onclick=openConnections;$('close-setup').onclick=()=>$('setup-dialog').close(); +$('recent-repos').addEventListener('click',async e=>{const button=e.target.closest('[data-open-repo]');if(!button)return;button.disabled=true;try{await openRepo(button.dataset.openRepo);}catch(error){$('setup-message').textContent=error.message;}finally{button.disabled=false;}}); +$('setup-content').addEventListener('click',async e=>{const button=e.target.closest('[data-provider]');if(!button)return;button.disabled=true;try{const provider=button.dataset.provider,remove=button.dataset.remove==='true';setupReview={project:setupProject,provider,remove,...await post('/api/setup/preview',{project:setupProject,provider,remove})};htmlCache.delete($('setup-message'));html($('setup-message'),`

${remove?'Remove this kitchen’s hook from':'Add activity hooks to'} ${esc(setupReview.file)}. Existing settings are preserved.

`);$('apply-setup').onclick=async()=>{try{await post('/api/setup/apply',setupReview);$('setup-message').textContent=remove?'Disconnected.':'Connected. Start or continue a session in this project.';await refresh();renderSetup();}catch(error){$('setup-message').textContent=error.message;}};}catch(error){$('setup-message').textContent=error.message;}finally{button.disabled=false;}}); +$('add-project').onsubmit=async e=>{e.preventDefault();const button=e.target.querySelector('button');button.disabled=true;try{await openRepo($('project-path').value.trim());$('project-path').value='';}catch(error){$('setup-message').textContent=error.message;}finally{button.disabled=false;}}; +async function refresh(){const res=await fetch('/api/bootstrap');if(!res.ok)throw new Error('The local service could not be reached.');const data=await res.json();token=data.token;live=data;connected=true;render();} +await document.fonts.load('800 18px Nunito'); +try{world=new KitchenWorld($('kitchen'),select,positions=>{for(const p of positions){const el=labels.get(p.id);if(el){el.style.transform=`translate(${p.x}px,${p.y}px) translate(-50%,0)`;el.hidden=!p.visible;el.classList.toggle('compact',p.compact);}}});$('loading').hidden=true;} +catch(error){console.error(error);$('loading').hidden=true;$('notice').textContent='3D graphics could not start. Deliverables and the agent roster are still available.';} +render(); +try{await refresh();}catch(error){connected=false;render();} +const events=new EventSource('/api/events');events.onmessage=e=>{try{const next=JSON.parse(e.data);live={...next,token};connected=true;if(mode==='live')render();if($('setup-dialog').open&&!setupReview&&!$('setup-dialog').contains(document.activeElement))renderSetup();}catch(error){console.error('Could not read a kitchen update.',error);}};events.onerror=()=>{connected=false;if(mode==='live')render();}; +// Read-only diagnostics for local performance and integration checks. +window.kitchenDiagnostics=()=>({mode,projectId,kitchenId,connected,render:world?.stats(),sceneReady:!!world,visibleChefs:current.crew?.length||0}); diff --git a/packages/kitchen/public/src/art.js b/packages/kitchen/public/src/art.js new file mode 100644 index 0000000..b81df2b --- /dev/null +++ b/packages/kitchen/public/src/art.js @@ -0,0 +1,75 @@ +import * as T from 'three'; +import {RoundedBoxGeometry} from 'three/addons/geometries/RoundedBoxGeometry.js'; + +export const colors={cream:0xfff2d7,white:0xfff9e9,wood:0x93502a,trim:0xc47c40,blue:0x52b7d0,dark:0x24344b,gold:0xe6ae48,green:0x509644,coral:0xe66a55}; +let seed=24871;export const random=()=>{seed=(seed*1664525+1013904223)>>>0;return seed/4294967296;}; +const cache=new Map(),materials=new Map(); +export function material(color,extra={}){const key=JSON.stringify([color,extra]);if(!materials.has(key))materials.set(key,new T.MeshStandardMaterial({color,roughness:.65,...extra}));return materials.get(key);} +export function mesh(parent,geo,mat,x=0,y=0,z=0){const m=new T.Mesh(geo,typeof mat==='number'?material(mat):mat);m.position.set(x,y,z);m.castShadow=true;m.receiveShadow=true;parent.add(m);return m;} +export function box(parent,w,h,d,mat,x=0,y=0,z=0,r=.06){const key=`b:${w}:${h}:${d}:${r}`;if(!cache.has(key))cache.set(key,new RoundedBoxGeometry(w,h,d,2,Math.min(r,w/3,h/3,d/3)));return mesh(parent,cache.get(key),mat,x,y,z);} +export function ball(parent,sx,sy,sz,mat,x=0,y=0,z=0){if(!cache.has('sphere'))cache.set('sphere',new T.SphereGeometry(1,20,14));const m=mesh(parent,cache.get('sphere'),mat,x,y,z);m.scale.set(sx,sy,sz);return m;} +export function cylinder(parent,rt,rb,h,mat,x=0,y=0,z=0){const key=`c:${rt}:${rb}:${h}`;if(!cache.has(key))cache.set(key,new T.CylinderGeometry(rt,rb,h,24));return mesh(parent,cache.get(key),mat,x,y,z);} +export function torus(parent,r,tube,mat,x=0,y=0,z=0){const key=`t:${r}:${tube}`;if(!cache.has(key))cache.set(key,new T.TorusGeometry(r,tube,8,32));return mesh(parent,cache.get(key),mat,x,y,z);} +export function group(parent,x=0,y=0,z=0){const g=new T.Group();g.position.set(x,y,z);parent.add(g);return g;} +export function rod(parent,a,b,r,mat){const va=new T.Vector3(...a),vb=new T.Vector3(...b),delta=vb.clone().sub(va);const m=cylinder(parent,r,r,delta.length(),mat,...va.clone().add(vb).multiplyScalar(.5).toArray());m.quaternion.setFromUnitVectors(new T.Vector3(0,1,0),delta.normalize());return m;} +export function tube(parent,points,r,mat){return mesh(parent,new T.TubeGeometry(new T.CatmullRomCurve3(points.map(p=>new T.Vector3(...p))),24,r,6,false),mat);} +function canvasTexture(size,draw){const c=document.createElement('canvas');c.width=c.height=size;const ctx=c.getContext('2d');draw(ctx,size);const tex=new T.CanvasTexture(c);tex.colorSpace=T.SRGBColorSpace;tex.anisotropy=4;return tex;} +const woodTexture=()=>canvasTexture(256,(c,s)=>{c.fillStyle='#ba7940';c.fillRect(0,0,s,s);for(let i=0;i<85;i++){const x=random()*s;c.strokeStyle=`rgba(${random()>.5?'248,192,108':'69,28,8'},${random()*.12})`;c.lineWidth=1+random()*3;c.beginPath();c.moveTo(x,0);c.bezierCurveTo(x+10,80,x-6,180,x+4,s);c.stroke();}for(let i=0;i<4;i++){c.strokeStyle='rgba(74,38,13,.15)';c.beginPath();c.ellipse(random()*s,random()*s,3+random()*4,15+random()*12,0,0,Math.PI*2);c.stroke();}}); +let woodMat,tileMat,groundMat; +export function wood(){return woodMat??=new T.MeshStandardMaterial({map:woodTexture(),color:0xe2b78b,roughness:.78});} +export function tile(){return tileMat??=new T.MeshStandardMaterial({map:canvasTexture(256,(c,s)=>{const blue=['#68c8de','#3496b9','#81d4e3','#54b4d1'];[[[0,0],[s,0],[s/2,s/2]],[[s,0],[s,s],[s/2,s/2]],[[s,s],[0,s],[s/2,s/2]],[[0,s],[0,0],[s/2,s/2]]].forEach((pts,i)=>{c.fillStyle=blue[i];c.beginPath();pts.forEach(([x,y])=>c.lineTo(x,y));c.fill();});for(let i=0;i<1600;i++){c.fillStyle=`rgba(255,255,255,${random()*.09})`;c.fillRect(random()*s,random()*s,1+random()*3,1);}c.strokeStyle='#a6dce5';c.lineWidth=5;c.strokeRect(0,0,s,s);}),roughness:.44});} +export function ground(){return groundMat??=new T.MeshStandardMaterial({map:canvasTexture(1024,(c,s)=>{c.fillStyle='#dba152';c.fillRect(0,0,s,s);for(let i=0;i<18000;i++){const x=random()*s,y=random()*s;c.fillStyle=`rgba(${random()>.47?'255,212,126':'166,94,42'},${.02+random()*.1})`;c.beginPath();c.ellipse(x,y,2+random()*12,1+random()*6,random()*3,0,Math.PI*2);c.fill();}for(let i=0;i<2100;i++){const x=random()*s,y=random()*s;if(x>160&&x<860&&y>160&&y<860)continue;c.strokeStyle=`rgba(255,212,93,${.15+random()*.45})`;c.lineWidth=1+random();c.beginPath();c.moveTo(x,y);c.lineTo(x+(random()-.5)*35,y+(random()-.5)*35);c.stroke();}}),roughness:1});} +let shadowMat; +export function contact(parent,x,z,sx,sz,opacity=.3){shadowMat??=new T.MeshBasicMaterial({map:canvasTexture(128,(c,s)=>{const g=c.createRadialGradient(s/2,s/2,0,s/2,s/2,s/2);g.addColorStop(0,'rgba(61,33,17,.65)');g.addColorStop(.45,'rgba(61,33,17,.35)');g.addColorStop(1,'rgba(61,33,17,0)');c.fillStyle=g;c.fillRect(0,0,s,s);}),transparent:true,depthWrite:false,opacity});const key=`shadow:${sx}:${sz}`;if(!cache.has(key))cache.set(key,new T.PlaneGeometry(sx,sz));const m=mesh(parent,cache.get(key),shadowMat,x,.015,z);m.rotation.x=-Math.PI/2;m.castShadow=false;return m;} + +export function plaque(parent,text,w=1.5,h=.4,x=0,y=1,z=0,bg='#f7e6bf',fg='#483226'){ + const c=document.createElement('canvas');c.width=768;c.height=Math.round(768*h/w);const ctx=c.getContext('2d');ctx.fillStyle=bg;ctx.fillRect(0,0,c.width,c.height);ctx.strokeStyle='#d7b17b';ctx.lineWidth=8;ctx.strokeRect(5,5,c.width-10,c.height-10);ctx.fillStyle=fg;ctx.font=`800 ${Math.min(c.height*.53,68)}px Nunito, sans-serif`;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(text,c.width/2,c.height/2+3,c.width-40);const tex=new T.CanvasTexture(c);tex.colorSpace=T.SRGBColorSpace;const m=mesh(parent,new T.PlaneGeometry(w,h),new T.MeshBasicMaterial({map:tex,side:T.DoubleSide}),x,y,z);m.castShadow=false;return m; +} +export function counter(parent,x,z,w=1.45,d=1.35){const g=group(parent,x,0,z); + contact(g,0,0,w+.55,d+.55);box(g,w,.89,d,colors.wood,0,.49,0,.07); + const count=Math.max(2,Math.round(w/.42));for(let i=0;inew T.Vector2(...p));cache.set('plate',new T.LatheGeometry(pts,36));}mesh(g,cache.get('plate'),material(colors.white,{roughness:.22}));torus(g,.36,.009,0xe4dfca,0,.101,0).rotation.x=Math.PI/2;return g;} +export function ingredient(parent,type,x=0,y=0,z=0,scale=1){const g=group(parent,x,y,z);g.scale.setScalar(scale); + if(type==='json'){ + ball(g,.22,.195,.22,material(0xe54429,{roughness:.31}),0,.18,0); + for(let i=0;i<5;i++){const a=i*Math.PI*.4;const leaf=ball(g,.035,.018,.13,0x467939,Math.sin(a)*.063,.36,Math.cos(a)*.063);leaf.rotation.y=a;leaf.rotation.z=.18;} + rod(g,[0,.35,0],[.01,.44,.018],.021,0x537334); + }else if(type==='image'){ + const carrot=group(g,0,.13,0);carrot.rotation.z=-.6;carrot.rotation.x=.6; + if(!cache.has('carrot'))cache.set('carrot',new T.LatheGeometry([[.012,-.28],[.06,-.18],[.10,-.05],[.12,.10],[.10,.2],[.03,.23]].map(p=>new T.Vector2(...p)),20));mesh(carrot,cache.get('carrot'),material(0xf38a24,{roughness:.48})); + for(let i=0;i<4;i++){const l=ball(carrot,.035,.15,.028,0x548832,(i-1.5)*.042,.32,0);l.rotation.z=(i-1.5)*-.27;} + for(let i=0;i<3;i++)box(carrot,.09,.012,.012,0xd56920,.02,i*.095-.1,.106,.005); + }else if(type==='code'){ + cylinder(g,.045,.06,.24,0x92b252,0,.17,0);for(let i=0;i<7;i++){const a=i*2.4;ball(g,.105,.10,.095,[0x388337,0x4d943a,0x5b9c41][i%3],Math.cos(a)*.12,.29+Math.sin(i)*.02,Math.sin(a)*.10);}ball(g,.12,.1,.12,0x579a3d,0,.37,0); + }else if(type==='text'){ + const bread=group(g,0,.085,0);bread.rotation.y=-.18;box(bread,.37,.13,.35,0xb97130,0,0,0,.07);box(bread,.31,.135,.29,0xffdf9d,0,.01,0,.06);for(let i=0;i<13;i++)ball(bread,.01,.006,.014,0xd4a455,(random()-.5)*.24,.082,(random()-.5)*.22); + }else{ball(g,.25,.22,.25,material(0xb9c4c1,{metalness:.6,roughness:.25}),0,.09,0);ball(g,.045,.04,.045,colors.gold,0,.32,0);} + return g; +} +export function board(parent,x=0,z=0){const g=group(parent,x,1.16,z);box(g,.91,.07,.72,wood());box(g,.09,.055,.2,colors.trim,0,0,-.43);const knife=box(g,.045,.17,.3,material(0xcbd7d9,{metalness:.5,roughness:.28}),.30,.15,.1,.015);knife.rotation.z=-.25;box(g,.06,.065,.22,0x454a45,.30,.19,-.13,.018);return g;} +export function stove(parent,x=0,z=0){const g=group(parent,x,1.15,z);box(g,1.15,.10,1.08,material(0xaebaaf,{metalness:.35,roughness:.36}),0,0,0);box(g,1.09,.03,.94,0x4c524c,0,.065,0); + for(const xx of [-.30,.30]){torus(g,.22,.036,0x242c2c,xx,.1,0).rotation.x=Math.PI/2;for(let i=0;i<4;i++){const a=i*Math.PI/2;rod(g,[xx+Math.cos(a)*.1,.12,Math.sin(a)*.1],[xx+Math.cos(a)*.24,.12,Math.sin(a)*.24],.022,0x222d30);}} + for(const xx of [-.34,0,.34])ball(g,.045,.036,.043,0xda694b,xx,-.015,.58);return g; +} +export function pot(parent,x=0,y=0,z=0){const g=group(parent,x,y,z);const pts=[[0,0],[.19,0],[.25,.035],[.285,.28],[.28,.31],[.255,.31],[.23,.06],[0,.06]].map(p=>new T.Vector2(...p));mesh(g,new T.LatheGeometry(pts,28),material(0x879f9e,{metalness:.6,roughness:.26}));torus(g,.273,.018,0xdce0ce,0,.30,0).rotation.x=Math.PI/2; + for(const s of [-1,1]){const h=torus(g,.095,.026,0x465153,s*.31,.20,0);h.rotation.y=Math.PI/2;} + cylinder(g,.232,.232,.012,0xebbc56,0,.18,0);rod(g,[.07,.14,.01],[.18,.62,.08],.028,0xaa6c35);return g; +} +export function sink(parent,x=0,z=0){const g=group(parent,x,1.15,z);box(g,1.18,.08,1.1,material(0xc9d5d1,{metalness:.45,roughness:.3}));box(g,.91,.06,.72,0x657f83,0,.055,.07,.12);box(g,.76,.025,.59,material(0x9fc6c5,{roughness:.2,metalness:.2}),0,.092,.075,.12);tube(g,[[.35,0,-.42],[.35,.45,-.42],[.12,.45,-.42],[.12,.29,-.35]],.037,material(0xd0d9ce,{metalness:.6,roughness:.2}));ball(g,.06,.05,.04,0xd78756,-.35,.12,-.4);return g;} +export function bell(parent,x=0,y=0,z=0){const g=group(parent,x,y,z);cylinder(g,.23,.25,.045,0x304a43,0,.02,0);const pts=[[.22,0],[.22,.05],[.15,.08],[.11,.21],[.02,.26]].map(p=>new T.Vector2(...p));mesh(g,new T.LatheGeometry(pts,24),material(0xebbd48,{metalness:.7,roughness:.22}));ball(g,.045,.036,.045,0xf5d570,0,.29,0);return g;} +export function recipe(parent,x=0,y=0,z=0){const g=group(parent,x,y,z);g.rotation.x=-.25;box(g,.66,.045,.77,colors.wood);box(g,.60,.032,.70,colors.cream,0,.035,0);for(let i=0;i<5;i++)box(g,.40-i%2*.08,.004,.018,0xb39b6e,0,.056,-.20+i*.083,.002);return g;} +export function barrel(parent,x,z,scale=1){const g=group(parent,x,0,z);g.scale.setScalar(scale);const pts=[[.33,0],[.39,.08],[.46,.48],[.40,.89],[.34,.95]].map(p=>new T.Vector2(...p));mesh(g,new T.LatheGeometry(pts,16),wood());cylinder(g,.34,.34,.04,colors.trim,0,.96,0);for(const y of [.14,.77])torus(g,y<.5?.416:.427,.04,0x525c56,0,y,0).rotation.x=Math.PI/2;for(let i=0;i<12;i++){const a=i*Math.PI/6;rod(g,[Math.sin(a)*.345,.93,Math.cos(a)*.345],[Math.sin(a)*.44,.18,Math.cos(a)*.44],.009,0x875129);}contact(g,0,0,1.4,1.4);return g;} +export function plant(parent,x,z,scale=1,flowers=false){const g=group(parent,x,0,z);g.scale.setScalar(scale);cylinder(g,.30,.22,.4,0xb86137,0,.2,0);torus(g,.3,.035,0xe19755,0,.4,0).rotation.x=Math.PI/2;cylinder(g,.27,.27,.02,0x57402b,0,.4,0); + for(let i=0;i<9;i++){const a=i*2.4,r=.1+random()*.16;rod(g,[0,.4,0],[Math.sin(a)*r,.7+random()*.25,Math.cos(a)*r],.016,0x557138);const l=ball(g,.09,.26,.055,[0x4b8139,0x5d913c,0x3d7039][i%3],Math.sin(a)*r,.72,Math.cos(a)*r);l.rotation.set(Math.cos(a)*.65,0,-Math.sin(a)*.65);if(flowers&&i%2===0){const yy=.93+random()*.12;for(let j=0;j<6;j++)ball(g,.043,.043,.045,0xf4c947,Math.sin(a)*r+Math.cos(j)*.055,yy,Math.cos(a)*r+Math.sin(j)*.055);ball(g,.035,.04,.035,0x925230,Math.sin(a)*r,yy+.02,Math.cos(a)*r);}} + return g; +} +export function crate(parent,x,y,z){const g=group(parent,x,y,z);for(const yy of [.1,.30,.50]){for(const zz of [-.34,.34])box(g,.92,.14,.05,wood(),0,yy,zz,.02);for(const xx of [-.43,.43])box(g,.05,.14,.68,wood(),xx,yy,0,.02);}box(g,.86,.06,.68,wood(),0,.03,0);return g;} +export function wheel(parent,x,y,z){const g=group(parent,x,y,z);torus(g,.55,.055,colors.trim);torus(g,.60,.023,0x65706b);for(let i=0;i<10;i++){const a=i*Math.PI/5;rod(g,[0,0,0],[Math.cos(a)*.54,Math.sin(a)*.54,0],.035,colors.trim);}ball(g,.1,.1,.075,colors.wood);return g;} +export function bunting(parent,a,b){const points=[];for(let i=0;i<=18;i++){const t=i/18;points.push([a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t-Math.sin(t*Math.PI)*.38,a[2]+(b[2]-a[2])*t]);}tube(parent,points,.014,0x704c31);for(let i=1;i<18;i+=1){const [x,y,z]=points[i];const geo=new T.BufferGeometry();geo.setAttribute('position',new T.Float32BufferAttribute([-.22,0,0,.22,0,0,0,-.43,.035],3));geo.computeVertexNormals();const m=mesh(parent,geo,material([0xe87554,0x67aaba,0xf0c465,0x91af60][i%4],{side:T.DoubleSide}),x,y,z);m.rotation.y=.13*Math.sin(i);}} +export function balloonBunch(parent,x,z){const g=group(parent,x,0,z);const tones=[0xe85552,0x59b1d0,0xeec248,0x77a755,0xc482b5];for(let i=0;i<5;i++){const a=i*2.4,xx=Math.sin(a)*.37,zz=Math.cos(a)*.32,y=2.1+i*.18;ball(g,.28,.34,.28,material(tones[i],{roughness:.27}),xx,y,zz);cylinder(g,.03,.0,.065,tones[i],xx,y-.36,zz);tube(g,[[0,.1,0],[xx*.45,y*.5,zz],[xx,y-.37,zz]],.006,0xd9cda7);}box(g,.2,.15,.2,colors.wood,0,.07,0);return g;} diff --git a/packages/kitchen/public/src/batch.js b/packages/kitchen/public/src/batch.js new file mode 100644 index 0000000..b993ed9 --- /dev/null +++ b/packages/kitchen/public/src/batch.js @@ -0,0 +1,21 @@ +import * as T from 'three'; +import {mergeGeometries} from 'three/addons/utils/BufferGeometryUtils.js'; + +// Merge static parts in their parent's coordinates. Animated rig groups stay separate. +export function batchMeshes(root,recursive=true){ + root.updateWorldMatrix(true,true); + const inverse=root.matrixWorld.clone().invert(),buckets=new Map(); + const collect=m=>{ + if(!m.isMesh||m.material.transparent||Array.isArray(m.material))return; + if(!buckets.has(m.material))buckets.set(m.material,[]);buckets.get(m.material).push(m); + }; + if(recursive)root.traverse(collect);else root.children.forEach(collect); + for(const [mat,meshes] of buckets){ + if(meshes.length<2)continue; + const geometries=meshes.map(m=>{let g=m.geometry.clone();if(g.index){const indexed=g;g=g.toNonIndexed();indexed.dispose();}g.applyMatrix4(new T.Matrix4().multiplyMatrices(inverse,m.matrixWorld));return g;}); + const merged=mergeGeometries(geometries); + if(merged){merged.userData.kitchenBatch=true;const mesh=new T.Mesh(merged,mat);mesh.castShadow=meshes.some(m=>m.castShadow);mesh.receiveShadow=meshes.some(m=>m.receiveShadow);for(const m of meshes)m.removeFromParent();root.add(mesh);} + for(const g of geometries)g.dispose(); + } +} +export function disposeBatches(root){root.traverse(o=>{if(o.geometry?.userData.kitchenBatch)o.geometry.dispose();});} diff --git a/packages/kitchen/public/src/chefs.js b/packages/kitchen/public/src/chefs.js new file mode 100644 index 0000000..f3600df --- /dev/null +++ b/packages/kitchen/public/src/chefs.js @@ -0,0 +1,86 @@ +import * as T from 'three'; +import {group,ball,box,cylinder,torus,rod,material,colors,contact,plate,ingredient} from './art.js'; +import {batchMeshes} from './batch.js'; + +export const apronColors=[0xdf5949,0x489bcc,0x64a14b,0xeab73f,0xa079bf,0xe58e36,0xb75e8c,0x4ca394,0x81694f,0x7987bd,0xb9a840,0x548572]; +export const hash=s=>[...s].reduce((a,c)=>(a*31+c.charCodeAt(0))>>>0,0); +let sharedHat; + +export function createChef(parent,id,index=0){ + const root=group(parent),body=group(root),color=apronColors[index%apronColors.length],skin=[0xecc494,0xc78551,0xf2cfaa,0x986144][index%4]; + root.userData={kind:'chef',id};contact(root,0,0,1.35,1.1); + const ring=torus(root,.49,.034,color,0,.035,0);ring.rotation.x=-Math.PI/2; + for(let j=0;j<3;j++){const dot=ball(root,.04,.016,.04,colors.cream,Math.sin(j*.2)*.5,.048,Math.cos(j*.2)*.5);dot.castShadow=false;} + const legs=[];for(const s of [-1,1]){ + const leg=group(body,s*.14,.19,0);cylinder(leg,.09,.10,.18,0x3a464c,0,0,0);ball(leg,.125,.085,.18,material(0x33424b,{roughness:.45}),0,-.10,.045);legs.push(leg); + } + ball(body,.31,.29,.25,colors.white,0,.47,0);ball(body,.317,.225,.257,color,0,.405,0);ball(body,.24,.25,.064,color,0,.45,.214); + box(body,.26,.13,.035,material(color),0,.44,.275,.035);rod(body,[-.24,.65,.19],[-.17,.3,.23],.022,color);rod(body,[.24,.65,.19],[.17,.3,.23],.022,color); + for(const s of [-1,1]){ball(body,.085,.042,.033,colors.white,s*.07,.42,.289);ball(body,.022,.022,.015,colors.gold,s*.11,.64,.226);} + cylinder(body,.16,.17,.07,color,0,.735,0); + const scarf=box(body,.13,.15,.055,color,.07,.66,.235,.03);scarf.rotation.z=-.35; + const head=group(body,0,.99,0);ball(head,.31,.295,.29,material(skin,{roughness:.78})); + for(const s of [-1,1]){ + ball(head,.075,.08,.057,skin,s*.295,-.015,0);ball(head,.026,.04,.023,0xcf8f6d,s*.323,-.006,.043); + ball(head,.043,.064,.022,0x252d31,s*.112,.035,.265);ball(head,.012,.019,.007,colors.white,s*.102,.057,.286); + ball(head,.05,.025,.010,0xe69677,s*.187,-.045,.222);const brow=ball(head,.057,.017,.017,0x825437,s*.117,.13,.25);brow.rotation.z=s*-.09; + } + ball(head,.071,.065,.073,skin,0,-.005,.304);ball(head,.043,.016,.018,0x824c35,0,-.125,.255); + if(index%4===1){for(const s of [-1,1]){const m=ball(head,.079,.036,.031,0x624133,s*.052,-.077,.291);m.rotation.z=s*.2;}for(const s of [-1,1]){torus(head,.075,.012,0x374a47,s*.116,.038,.296);rod(head,[s*.18,.04,.29],[s*.28,.025,.09],.011,0x374a47);}rod(head,[-.04,.05,.31],[.04,.05,.31],.012,0x374a47);} + if(index%4===2){for(const s of [-1,1]){const ear=meshEar(head,s);ear.rotation.z=-s*.26;}ball(head,.17,.11,.085,0xffd9a3,0,-.09,.245);ball(head,.05,.035,.035,0x3c382f,0,-.03,.338);} + cylinder(head,.28,.267,.19,colors.white,0,.28,-.025);torus(head,.269,.025,0xe9e4d4,0,.19,-.025).rotation.x=Math.PI/2; + const hat=group(head,0,.46,-.03); + if(!sharedHat){sharedHat=new T.SphereGeometry(1,40,24);const vertices=sharedHat.attributes.position;for(let i=0;i2)chef.carried.remove(chef.carried.children.at(-1));ingredient(chef.carried,type,0,.08,0,.9);} +export function animateChef(c,time,dt,reduced=false,paused=false){ + c.ring.material=material(c.selected?0xffe9a0:c.color,{roughness:.65}); + c.attention.visible=['permission','input','error'].includes(c.state); + // Pause freezes the current pose instead of resetting every joint to rest. + if(paused)return; + const motion=!reduced,pose=c.pose||c.state,phase=time*5+c.phase,distance=c.root.position.distanceTo(c.target),walking=distance>.035&&!reduced; + if(reduced)c.root.position.copy(c.target); + if(walking){const delta=c.target.clone().sub(c.root.position);c.facing=Math.atan2(delta.x,delta.z);if(motion)c.root.position.addScaledVector(delta.normalize(),Math.min(distance,dt*2.0));else if(!paused)c.root.position.copy(c.target);} + let angle=c.facing-c.root.rotation.y;angle=Math.atan2(Math.sin(angle),Math.cos(angle));c.root.rotation.y+=angle*(motion?Math.min(1,dt*10):1); + c.body.position.y=motion?(walking?Math.abs(Math.sin(phase*1.6))*.047:Math.sin(phase*.35)*.013):0; + c.body.rotation.z=motion?(walking?Math.sin(phase*1.6)*.045:Math.sin(phase*.23)*.024):0; + c.head.rotation.z=motion&&pose==='reading'?Math.sin(phase*.45)*.12:0; + c.head.rotation.y=motion&&!walking?Math.sin(phase*(c.atWorktop?.35:.18))*(c.atWorktop?.12:.22):0; + c.head.rotation.x=c.atWorktop?.10:0; + c.hat.rotation.z=motion&&walking?Math.sin(phase*1.6+.7)*.055:0; + c.legs.forEach((l,i)=>{l.rotation.x=motion&&walking?Math.sin(phase*1.6+i*Math.PI)*.5:0;}); + const carry=c.carrying;c.carried.visible=!!carry; + const working=c.atWorktop&&!walking&&!carry&&['writing','executing','reading','working'].includes(pose); + const hands=[new T.Vector3(-.20,.91,.44),new T.Vector3(.20,.95,.46)]; + if(working&&pose==='working'){ + hands[0].set(-.22,1.04+(motion?Math.sin(phase*.65)*.14:0),.44); + hands[1].set(.18+(motion?Math.sin(phase*.65)*.10:0),1.08+(motion?Math.cos(phase*.65)*.16:0),.48); + } + if(working&&pose==='reading'){ + hands[0].set(-.20,1.0,.48);hands[1].set(.14+(motion?Math.sin(phase*.45)*.15:0),1.03+(motion?Math.max(0,Math.sin(phase*.9))*.13:0),.54); + } + if(working&&pose==='writing')hands[1].y+=motion?Math.abs(Math.sin(phase*1.4))*.24:0; + if(working&&pose==='executing'){hands[1].set((motion?Math.sin(phase*.9)*.17:0),1.22,.43+(motion?Math.cos(phase*.9)*.15:0));hands[0].set(-.22,1.05,.40);} + c.arms.forEach((a,i)=>{ + a.scale.set(1,1,1);a.rotation.set(0,0,(i===0?1:-1)*.15); + if(working){const direction=hands[i].clone().sub(a.position);a.quaternion.setFromUnitVectors(new T.Vector3(0,-1,0),direction.clone().normalize());a.scale.y=direction.length()/.285;} + else if(carry){a.rotation.x=-1.72;a.rotation.z=(i===0?-1:1)*.07;} + else if(walking&&motion)a.rotation.x=Math.sin(phase*1.6+i*Math.PI)*.45; + else if((c.state==='permission'||c.state==='input')&&i===1){a.rotation.z=-2.4;a.rotation.x=motion?Math.sin(phase*.5)*.10:0;} + }); + c.utensil.visible=working&&pose==='writing';c.utensil.position.copy(hands[1]); + c.spoon.visible=working&&pose==='executing';c.spoon.position.copy(hands[1]); + c.attention.rotation.y=-c.root.rotation.y;c.attention.position.y=1.98+(motion?Math.sin(phase)*.035:0); + c.ring.scale.setScalar((c.selected?1.18:c.related?1.12:1)+(motion&&working?.05*(1+Math.sin(phase*.6)):0)); +} diff --git a/packages/kitchen/public/src/demo.js b/packages/kitchen/public/src/demo.js new file mode 100644 index 0000000..b2d604e --- /dev/null +++ b/packages/kitchen/public/src/demo.js @@ -0,0 +1,18 @@ +// A clearly labeled example of one session contributing through several roles. +const project='example-weekly-post',names=['Researcher','Writer','Evaluator','Publisher','Head chef']; +const roles=['researcher','writer','evaluator','publisher','head-chef']; +const components=roles.map((id,i)=>({id,title:names[i],files:[],tasks:[],needs:[],links:[]})); +const chefId=id=>`role:${project}:project:${id}`; +const nativeTitles=['Prepare the weekly post','Check the references and final copy','Package the approved draft for review']; +const steps=[{order:0,role:0,state:'reading'},{order:0,role:1,state:'writing'},{order:0,role:2,state:'reading'},{order:1,role:1,state:'writing'},{order:1,role:2,state:'reading'},{order:2,role:0,state:'reading'},{order:2,role:1,state:'writing'},{order:2,role:2,state:'reading'},{order:3,role:4,state:'complete'}]; +export function demoState(step=0){ + const now=Date.now(),beat=steps[Math.min(step,steps.length-1)],p={id:project,name:'Weekly post · example',components,kitchens:[{id:'shared',title:'One shared kitchen',components:roles,counts:{total:0,done:0,active:0,blocked:0}}],deliverables:[],workflow:{id:'project',roles:roles.map((id,i)=>({id,title:names[i],components:[id]}))},warnings:[],boardUrl:null,contextSource:'example',activity:[]}; + const exec={id:'codex:example',sessionId:'example',provider:'codex',project,state:beat.state,source:'example',freshness:'recent',lastEventAt:now,currentFile:['research/references.md','drafts/weekly-post.md','evals/weekly-post.md'][beat.role%3],currentTask:beat.order<3?{title:nativeTitles[beat.order],status:'in_progress'}:null,recent:[],tool:beat.state==='writing'?'Edit':'Read'}; + const crew=roles.map((roleId,index)=>({...exec,id:chefId(roleId),roleId,name:names[index],roleComponents:[roleId],component:{id:roleId,title:names[index]},activityComponentId:roleId,startedAt:0,state:index===beat.role&&beat.state!=='complete'?beat.state:'idle',workingCount:index===beat.role&&beat.state!=='complete'?1:0,executors:index===beat.role?[exec]:[],roleStatus:'Ready for the next order',association:{kind:'explicit',reason:'Example role assignment'},currentTask:index===beat.role?exec.currentTask:null})); + const orders=nativeTitles.slice(0,step<5?2:3).map((title,index)=>{ + const contributions=[...new Set(steps.slice(0,step+1).filter(b=>b.order===index).map(b=>b.role))]; + const completed=beat.order>index,status=completed?'completed':beat.order===index?'in_progress':'pending'; + return {id:'example-order-'+index,number:index+1,scope:'example-plan',index,title,project,sessionId:exec.id,provider:'codex',source:'example',status,withdrawn:false,completionVersion:completed?1:0,completedAt:completed?now:null,tableId:'example-table',outcomeTitle:'A weekly post ready for review',activeChefIds:beat.order===index?[chefId(roles[beat.role])]:[],activeSessionIds:beat.order===index?[exec.id]:[],contributors:contributions.map(role=>({identity:roles[role],chefId:chefId(roles[role]),roleId:roles[role],name:names[role],sessionId:exec.id,provider:'codex',association:{kind:'explicit',reason:'Simulated contribution to this shared order'},firstAt:now,lastAt:now})),history:[{title,status,at:now,revision:step+1}]}; + }); + return {version:2,projects:[p],crew,executors:[exec],orders,tables:[{id:'example-table',project,title:'A weekly post ready for review',reported:true,orderIds:orders.map(o=>o.id),completedIds:orders.filter(o=>o.status==='completed').map(o=>o.id)}],unplanned:[],artifacts:[],transfers:[],installed:{},observers:{},observing:false}; +} diff --git a/packages/kitchen/public/src/layout.js b/packages/kitchen/public/src/layout.js new file mode 100644 index 0000000..2ed6a50 --- /dev/null +++ b/packages/kitchen/public/src/layout.js @@ -0,0 +1,5 @@ +export function stationLayout(count){ + const columns=Math.max(2,Math.ceil(Math.min(count,8)/2)),extra=(columns-2)*2.7; + const positions=Array.from({length:columns*2},(_,i)=>[(i%columns-(columns-1)/2)*(columns===2?6.9:5.4),i({data,index})).sort((a,b)=>Number(isWorking(b.data)||needsAttention(b.data))-Number(isWorking(a.data)||needsAttention(a.data))||a.index-b.index); +} +// Poses illustrate observed work; they never change the source state or its progress. +export function workPose(s){ + if(!isWorking(s)||s.state!=='working')return s.state; + const work=s.workContext; + if(work&&s.lastEventAt-work.at<45_000)return {execute:'executing',review:'reading',research:'reading',build:'writing'}[work.category]||'working'; + return 'working'; +} +export const approachPoint=home=>[home.x,home.z<0?-1.25:1.65]; +export function nextWorkBeat(memory,s,time,wallNow,{newRoom=false,paused=false,reduced=false}={}){ + const stamp=Math.max(s.lastEventAt||0,s.workContext?.at||0),changed=stamp!==memory.seenWorkAt;memory.seenWorkAt=stamp; + if(newRoom){memory.beatAt=time;return false;} + if(!changed||!isWorking(s)||wallNow-stamp>12_000||paused||reduced||memory.walkRoute?.length||time-(memory.beatAt??-100)<8)return false; + memory.beatAt=time;return true; +} diff --git a/packages/kitchen/public/src/orders.js b/packages/kitchen/public/src/orders.js new file mode 100644 index 0000000..5b887c4 --- /dev/null +++ b/packages/kitchen/public/src/orders.js @@ -0,0 +1,16 @@ +// Source statuses stay authoritative; these helpers only arrange their presentation. +export function projectOrders(data,project){return (data.orders||[]).filter(o=>o.project===project);} +export function orderForChef(orders,id){return orders.find(o=>!o.withdrawn&&o.status!=='completed'&&o.activeChefIds.includes(id));} +export function orderCrew(order,crew){return order.contributors.map(c=>({...c,chef:crew.find(s=>s.id===c.chefId),active:order.activeChefIds.includes(c.chefId)}));} +export const orderState=o=>o.withdrawn?'Withdrawn':o.status==='completed'?'Todo complete':o.attention?'Needs attention':o.activeChefIds.length?'Cooking':o.status==='in_progress'?'In progress · last reported':'Queued'; +export const orderColor=o=>['#4b8d9f','#c77745','#8b79b0','#799652','#b85e62','#b79541'][(o.number-1)%6]; +export function sharedStations(count){ + const stations=[{id:'shared-prep',title:'Gather & prepare'},{id:'shared-make',title:'Make & assemble'},{id:'shared-cook',title:'Run & cook'},{id:'shared-check',title:'Check & review'}]; + if(count>4)stations.push({id:'shared-prep-2',title:'Shared preparation'},{id:'shared-check-2',title:'Shared review'}); + return stations; +} +export function stationForChef(chef,index,count){ + if(chef.state==='idle'||chef.freshness==='quiet'||['complete','offline','interrupted'].includes(chef.state))return index%count; + if(/review|eval|check/i.test(chef.name||''))return 3; + return {reading:0,writing:1,executing:2}[chef.state]??3; +} diff --git a/packages/kitchen/public/src/recording.js b/packages/kitchen/public/src/recording.js new file mode 100644 index 0000000..f294684 --- /dev/null +++ b/packages/kitchen/public/src/recording.js @@ -0,0 +1,68 @@ +// Record the browser-selected tab or screen locally, including its HTML overlays. +export function setupKitchenRecording(){ + const button=document.getElementById('record-kitchen'),label=document.getElementById('record-label'),elapsed=document.getElementById('record-time'),note=document.getElementById('recording-note'),status=document.getElementById('recording-status'),download=document.getElementById('recording-download'); + let phase='idle',stream=null,recorder=null,timer=null,chunks=[],recordingUrl='',startedAt=0,clockStart=0,failure='',leaving=false; + const supported=()=>!!navigator.mediaDevices?.getDisplayMedia&&typeof MediaRecorder!=='undefined'; + function controls(next){ + phase=next;button.dataset.state=next;button.disabled=next==='picking'||next==='stopping'; + const name=next==='recording'?'Stop recording':next==='picking'?'Choose a tab…':next==='stopping'?'Preparing recording…':'Record kitchen'; + label.textContent=name;button.setAttribute('aria-label',name);button.setAttribute('aria-pressed',String(next==='recording')); + elapsed.hidden=next!=='recording'; + } + function message(text){status.textContent=text;note.hidden=!text&&!recordingUrl;download.hidden=!recordingUrl;} + function releaseCapture(){ + clearInterval(timer);timer=null; + for(const track of stream?.getTracks()||[])track.stop(); + stream=null; + } + function updateTime(){ + const seconds=Math.floor((performance.now()-clockStart)/1000); + elapsed.textContent=`${String(Math.floor(seconds/60)).padStart(2,'0')}:${String(seconds%60).padStart(2,'0')}`; + } + function finish(){ + releaseCapture(); + if(leaving){chunks=[];return;} + const mime=recorder?.mimeType||'video/webm',blob=new Blob(chunks,{type:mime}); + chunks=[];recorder=null;controls('idle'); + if(blob.size){ + if(recordingUrl)URL.revokeObjectURL(recordingUrl); + recordingUrl=URL.createObjectURL(blob);download.href=recordingUrl; + download.download=`agenttrail-kitchen-${new Date(startedAt).toISOString().replace(/[:.]/g,'-')}.webm`; + download.textContent=failure?'Download partial recording':'Download recording'; + message(failure?'Recording stopped unexpectedly. The captured portion is ready.':'Recording ready. Download it before closing this tab.'); + }else message(failure||'No video was captured. Choose the kitchen tab and try again.'); + } + function stop(){ + if(phase!=='recording')return; + controls('stopping'); + if(recorder?.state!=='inactive')recorder.stop(); + releaseCapture(); + } + async function start(){ + if(!supported()){message('This browser cannot record a screen. Open the kitchen in Chrome or another browser with screen recording support.');return;} + const mimeType=['video/webm;codecs=vp9','video/webm;codecs=vp8','video/webm'].find(type=>MediaRecorder.isTypeSupported(type)); + if(!mimeType){message('This browser cannot save WebM recordings. Open the kitchen in Chrome to record.');return;} + controls('picking');message('Choose this kitchen tab in the browser’s share picker. Audio stays off.'); + try{ + stream=await navigator.mediaDevices.getDisplayMedia({video:{frameRate:{ideal:30,max:30}},audio:false,preferCurrentTab:true}); + if(leaving){releaseCapture();return;} + const tracks=stream.getVideoTracks(); + if(!tracks.some(track=>track.readyState==='live'))throw new Error('No live video'); + chunks=[];failure='';recorder=new MediaRecorder(stream,{mimeType,videoBitsPerSecond:6_000_000}); + recorder.ondataavailable=event=>{if(event.data.size&&!leaving)chunks.push(event.data);}; + recorder.onstop=finish; + recorder.onerror=()=>{failure='The browser could not finish this recording.';stop();}; + for(const track of tracks)track.addEventListener('ended',stop,{once:true}); + recorder.start(1000);startedAt=Date.now();clockStart=performance.now(); + controls('recording');updateTime();timer=setInterval(updateTime,1000);note.hidden=true; + }catch(error){ + releaseCapture();recorder=null;chunks=[];controls('idle'); + message(error.name==='NotAllowedError'||error.name==='AbortError'?'Recording was not started. You can choose a tab and try again.':error.name==='NotReadableError'?'The browser could not access the selected screen. Check screen recording access and try again.':'Recording could not start. Keep this tab focused and try again.'); + } + } + button.title='Record this kitchen tab to a local video. Choose the tab in the browser’s share picker.'; + button.addEventListener('click',()=>{if(phase==='recording')stop();else if(phase==='idle')void start();}); + window.addEventListener('beforeunload',event=>{if(phase==='recording'||phase==='stopping'){event.preventDefault();event.returnValue='';}}); + window.addEventListener('pagehide',()=>{leaving=true;if(recorder?.state==='recording')recorder.stop();releaseCapture();if(recordingUrl)URL.revokeObjectURL(recordingUrl);}); + controls('idle'); +} diff --git a/packages/kitchen/public/src/routes.js b/packages/kitchen/public/src/routes.js new file mode 100644 index 0000000..166bf0b --- /dev/null +++ b/packages/kitchen/public/src/routes.js @@ -0,0 +1,13 @@ +const step=.3; +export function walkable(x,z,width=5.15){ + if(x< -width||x>width||z< -2.4||z>2.5)return false; + return !(x> -2.24&&x<2.24&&z>-.81&&z<1.4); +} +export function routeBetween(from,to,width=5.15){ + const start=from.map(v=>Math.round(v/step)),goal=to.map(v=>Math.round(v/step)),key=p=>p.join(','),end=key(goal); + const open=[start],parents=new Map(),scores=new Map([[key(start),0]]),closed=new Set(); + while(open.length&&closed.size<1500){open.sort((a,b)=>scores.get(key(a))+Math.abs(a[0]-goal[0])+Math.abs(a[1]-goal[1])-scores.get(key(b))-Math.abs(b[0]-goal[0])-Math.abs(b[1]-goal[1]));const point=open.shift(),id=key(point);if(closed.has(id))continue;if(id===end){const path=[to];let current=id;while(parents.has(current)){const parent=parents.get(current);path.unshift(parent.map(v=>v*step));current=key(parent);}path[0]=from;return path.filter((p,i,ps)=>i===0||i===ps.length-1||Math.abs((p[0]-ps[i-1][0])*(ps[i+1][1]-p[1])-(p[1]-ps[i-1][1])*(ps[i+1][0]-p[0]))>.001);} + closed.add(id);for(const [dx,dz] of [[1,0],[-1,0],[0,1],[0,-1]]){const next=[point[0]+dx,point[1]+dz],nid=key(next);if(!walkable(next[0]*step,next[1]*step,width)||closed.has(nid))continue;const score=scores.get(id)+1;if(score<(scores.get(nid)??Infinity)){scores.set(nid,score);parents.set(nid,point);open.push(next);}} + } + return []; +} diff --git a/packages/kitchen/public/src/world.js b/packages/kitchen/public/src/world.js new file mode 100644 index 0000000..14f65c6 --- /dev/null +++ b/packages/kitchen/public/src/world.js @@ -0,0 +1,239 @@ +import * as T from 'three'; +import {RoomEnvironment} from 'three/addons/environments/RoomEnvironment.js'; +import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js'; +import {RenderPass} from 'three/addons/postprocessing/RenderPass.js'; +import {SSAOPass} from 'three/addons/postprocessing/SSAOPass.js'; +import {OutputPass} from 'three/addons/postprocessing/OutputPass.js'; +import {batchMeshes,disposeBatches} from './batch.js'; +import {group,box,ball,cylinder,torus,rod,tube,material,colors,ground,wood,random,counter,plate,ingredient,board,stove,pot,sink,bell,recipe,barrel,plant,crate,wheel,bunting,balloonBunch,plaque} from './art.js'; +import {createChef,animateChef,hash} from './chefs.js'; +import {routeBetween,walkable} from './routes.js'; +import {needsAttention,isWorking} from './activity.js'; +import {workingFirst,workPose,approachPoint,nextWorkBeat} from './motion.js'; +import {stationForChef,orderForChef,orderColor} from './orders.js'; +import {stationLayout} from './layout.js'; + + +export class KitchenWorld { + constructor(canvas,onSelect,onLabels){ + this.layout=stationLayout(4);this.canvas=canvas;this.onSelect=onSelect;this.onLabels=onLabels;this.crew=new Map();this.plates=new Map();this.dishes=new Map();this.selected=null;this.zoom=1;this.time=0;this.paused=false;this.reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;this.demo=false;this.roomKey='';this.components=[];this.hovered=null;this.pan=new T.Vector3(); + this.renderer=new T.WebGLRenderer({canvas,alpha:true,antialias:true,powerPreference:'high-performance'});this.renderer.setPixelRatio(Math.min(devicePixelRatio,1.5));this.renderer.shadowMap.enabled=true;this.renderer.shadowMap.type=T.PCFShadowMap;this.renderer.toneMapping=T.ACESFilmicToneMapping;this.renderer.toneMappingExposure=.83; + this.scene=new T.Scene();this.scene.background=null;this.renderer.setClearColor(0x000000,0); + this.camera=new T.OrthographicCamera(-10,10,8,-8,.1,80);this.camera.position.set(0,22,14);this.target=new T.Vector3(0,.05,.25);this.camera.lookAt(this.target); + const env=new T.PMREMGenerator(this.renderer);this.scene.environment=env.fromScene(new RoomEnvironment(),.06).texture;this.scene.environmentIntensity=.28;env.dispose(); + this.scene.add(new T.HemisphereLight(0xfff3df,0x8c7150,.5)); + const sun=new T.DirectionalLight(0xffe3b5,2.8);sun.position.set(-5,14,7);sun.castShadow=true;sun.shadow.mapSize.set(2048,2048);sun.shadow.camera.left=-18;sun.shadow.camera.right=18;sun.shadow.camera.top=12;sun.shadow.camera.bottom=-12;sun.shadow.camera.near=.5;sun.shadow.camera.far=36;sun.shadow.normalBias=.025;sun.shadow.bias=-.0002;sun.shadow.radius=4;this.scene.add(sun); + const fill=new T.DirectionalLight(0xcde8ff,.4);fill.position.set(7,9,-5);this.scene.add(fill); + this.room=group(this.scene);this.characters=group(this.scene);this.characters.position.y=.07;this.outputs=group(this.scene);this.signs=group(this.scene);this.effects=[];this.worktops=group(this.scene);this.workSlots=[];this.beltMotion=group(this.scene); + this.buildRoom();this.batchRoom(); + this.composer=new EffectComposer(this.renderer);this.composer.addPass(new RenderPass(this.scene,this.camera)); + this.ao=new SSAOPass(this.scene,this.camera,600,400,16);this.ao.kernelRadius=.28;this.ao.minDistance=.0005;this.ao.maxDistance=.045;this.composer.addPass(this.ao);this.composer.addPass(new OutputPass()); + this.renderer.info.autoReset=false;this.raycaster=new T.Raycaster();this.pointer=new T.Vector2();this.bind();this.resize(); + this.observer=new ResizeObserver(()=>this.resize());this.observer.observe(canvas.parentElement); + let last=performance.now(),frames=0,measure=last;this.renderer.setAnimationLoop(now=>{if(now-last<1000/30)return;const dt=Math.min(.1,(now-last)/1000);last=now;if(!this.paused&&!document.hidden)this.time+=dt;if(!document.hidden){this.update(dt);this.renderer.info.reset();this.composer.render();frames++;if(now-measure>2000){const fps=Math.round(frames*1000/(now-measure));canvas.dataset.fps=String(fps);canvas.dataset.drawCalls=String(this.renderer.info.render.calls);canvas.dataset.chefs=String(this.crew.size);canvas.dataset.geometries=String(this.renderer.info.memory.geometries);if(fps<22&&!this.performanceMode){this.performanceMode=true;this.ao.enabled=false;this.renderer.setPixelRatio(1);this.composer.setPixelRatio(1);this.resize();canvas.dataset.quality='performance';}frames=0;measure=now;}}else{frames=0;measure=now;}}); + } + batchRoom(){batchMeshes(this.room);} + buildRoom(){ + const {width,extra,positions,columns}=this.layout,edge=x=>x+Math.sign(x)*extra; + box(this.room,width,.32,12.6,0x9e653d,0,-.16,0,.24);box(this.room,width-.3,.07,12.3,ground(),0,.035,0,.17); + for(const z of [-6.05,6.05])box(this.room,width-.1,.13,.17,wood(),0,.05,z); + for(const x of [edge(-8.25),edge(8.25)])box(this.room,.16,.13,12.3,wood(),x,.05,0); + // The perimeter stays low at the front so it never hides the working chefs. + for(let x=edge(-8);x<=edge(8);x+=1.35){for(const z of [-5.8,5.75]){if(z>0&&Math.abs(x)<3.5)continue;box(this.room,.17,z<0?1.55:.65,.17,wood(),x,z<0?.78:.33,z);ball(this.room,.115,.055,.115,colors.trim,x,z<0?1.57:.70,z);}} + for(const z of [-5.8,5.75])for(const y of z<0?[.5,1.12]:[.28,.55]){if(z<0)box(this.room,width-.6,.18,.12,wood(),0,y,z);else for(const s of [-1,1])box(this.room,4.6+extra,.14,.10,wood(),s*(5.8+extra/2),y,z);} + for(const x of [edge(-8.0),edge(8.0)]){for(let z=-4.7;z<=4.8;z+=1.35)box(this.room,.17,.9,.17,wood(),x,.45,z);for(const y of [.30,.72])box(this.room,.12,.16,10.4,wood(),x,y,0);} + // Whole counters are added without stretching characters or furniture. + for(let i=0;i0?1:0))%4],(j-1)*.23,.22,.02,.78);} + box(pantry,2.16,.49,.14,wood(),0,2.78,.18);plaque(pantry,'PANTRY',1.80,.30,0,2.79,.265,'#9e602f','#fff0c9'); + const hatch=group(this.room,edge(7.23),0,-2.15);hatch.rotation.y=-.28;box(hatch,1.25,1.25,1.1,wood(),0,.63,0);box(hatch,1.44,.13,1.22,colors.trim,0,1.32,0);for(const s of [-1,1])box(hatch,.11,2.7,.11,wood(),s*.66,1.36,-.38); + this.awning(hatch,0,2.69,-.10,1.75,1.55);box(hatch,1.30,.5,.12,wood(),0,1.95,-.27);plaque(hatch,'SERVE',1.12,.34,0,1.96,-.19,'#744229','#fff0d2');bell(hatch,0,1.4,.3); + for(const [x,z,s] of [[-7.2,2.7,.9],[-7.15,4.05,1],[-5.85,5.25,.83],[7.1,3.4,.85],[5.95,-5.1,.8]]){barrel(this.room,edge(x),z,s);} + const herbs=plant(this.room,edge(-7.2),2.7,.8);herbs.position.y=.85;plant(this.room,edge(7.1),3.4,.75,true).position.y=.8; + for(const [x,z,s] of [[-6,-5.1,1.05],[6.95,-4.5,.9],[-4.8,5.3,.8],[2.5,-5.15,.75]])plant(this.room,edge(x),z,s,true); + for(const [x,z] of [[-7.2,.75],[7.15,4.85]]){const c=crate(this.room,edge(x),0,z);c.rotation.y=.16;ingredient(c,'image',.05,.28,0,1.4);ingredient(c,'code',-.2,.28,.04,1.15);} + const cart=group(this.room,edge(-7.3),0,4.9);box(cart,1.8,.48,1.15,wood(),0,.65,0);wheel(cart,-.53,.47,.64);wheel(cart,.58,.47,.64);this.awning(cart,0,2.0,0,2.15,1.65);for(const x of [-.87,.87])box(cart,.07,1.5,.07,colors.trim,x,1.2,0); + balloonBunch(this.room,edge(7.4),-4.85);balloonBunch(this.room,edge(-6.2),5.4); + for(const x of [edge(-7.8),edge(7.8)]){box(this.room,.12,3.4,.12,wood(),x,1.7,-5.8);ball(this.room,.12,.12,.12,colors.gold,x,3.43,-5.8);} + bunting(this.room,[edge(-7.8),3.35,-5.8],[edge(7.8),3.35,-5.8]);bunting(this.room,[edge(-7.9),2.7,-4.7],[edge(-7.9),1.65,4.3]);bunting(this.room,[edge(7.9),2.7,-4.7],[edge(7.9),1.6,4.3]); + for(let i=0;i<80;i++){const x=(random()-.5)*(width-1.2),z=(random()-.5)*11.5;if(Math.abs(x)<6.7+extra&&Math.abs(z)<4.6)continue;const straw=box(this.room,.018,.012,.18+random()*.25,0xf5c567,x,.092,z,.004);straw.rotation.y=random()*Math.PI;} + } + buildConveyor(){ + const extra=this.layout.extra;this.tableX=4.7+extra*.35;this.beltStart=-4.4-extra*.3;this.beltEnd=this.tableX-1.65;this.beltZ=5.05; + const width=this.beltEnd-this.beltStart,center=(this.beltStart+this.beltEnd)/2; + box(this.room,width+.35,.30,1.1,0x435855,center,.77,this.beltZ,.12); + box(this.room,width,.08,.80,0x526a63,center,.96,this.beltZ,.035); + for(const z of [-.51,.51])box(this.room,width+.4,.12,.09,0xc7aa67,center,1.0,this.beltZ+z,.025); + for(const x of [this.beltStart,this.beltEnd]){ + for(const z of [-.36,.36])box(this.room,.15,.66,.15,wood(),x,.38,this.beltZ+z); + const roller=cylinder(this.room,.18,.18,.90,0x83aaa3,x,.82,this.beltZ);roller.rotation.x=Math.PI/2; + } + this.beltMotion.clear();this.beltSlats=[]; + for(let i=0;i<18;i++){const mesh=box(this.beltMotion,.05,.025,.77,0x92a398,this.beltStart+width*i/18,1.02,this.beltZ,.006);this.beltSlats.push(mesh);} + for(let i=0;i<3;i++){const sign=plaque(this.room,'›',.28,.22,this.beltStart+width*(i+.5)/3,1.04,this.beltZ,'#536e65','#f3d28a');sign.rotation.x=-Math.PI/2;} + // A shared table collects completed work. It does not declare the outcome shipped. + for(const x of [-1.25,1.25])for(const z of [-.52,.52])box(this.room,.16,.9,.16,wood(),this.tableX+x,.5,this.beltZ+z); + box(this.room,3.25,.18,1.62,wood(),this.tableX,1.0,this.beltZ,.14); + box(this.room,2.95,.04,1.38,colors.cream,this.tableX,1.12,this.beltZ,.10); + const label=plaque(this.room,'DELIVERABLE TABLE',2.7,.32,this.tableX,1.14,this.beltZ+.82,'#faf0d2','#4a624e');label.rotation.x=-Math.PI/4; + } + clearDish(mesh){mesh.traverse(o=>{if(o.material?.isMeshBasicMaterial&&o.material.map){o.material.map.dispose();o.material.dispose();o.geometry.dispose();}});mesh.removeFromParent();} + setOrders(orders,tables,newRoom){ + if(newRoom){for(const mesh of this.dishes.values())this.clearDish(mesh);this.dishes.clear();} + this.orders=orders;this.tables=tables; + const completed=orders.filter(o=>!o.withdrawn&&o.status==='completed').slice(-8),open=orders.filter(o=>!o.withdrawn&&o.status!=='completed').sort((a,b)=>b.activeChefIds.length-a.activeChefIds.length||a.index-b.index).slice(0,8),visible=[...open,...completed],keep=new Set(); + visible.forEach((order,index)=>{ + keep.add(order.id);let dish=this.dishes.get(order.id),created=!dish; + if(!dish){dish=plate(this.outputs);dish.userData={kind:'order',id:order.id,version:order.completionVersion}; + ingredient(dish,'text',-.07,.08,0,.60);ingredient(dish,'json',.10,.10,.06,.62); + const rim=torus(dish,.385,.025,orderColor(order),0,.13,0);rim.rotation.x=Math.PI/2; + const badge=plaque(dish,'#'+order.number,.40,.22,0,.16,.43,orderColor(order),'#fff7e3');badge.rotation.x=-Math.PI/3; + this.dishes.set(order.id,dish); + } + const done=order.status==='completed',wasDone=dish.userData.order?.status==='completed',chef=this.crew.get(order.activeChefIds.find(id=>this.crew.has(id))),slot=chef&&this.workSlots[chef.workSlot]; + const j=completed.indexOf(order),target=done?new T.Vector3(this.tableX+(j%4-1.5)*.68,1.17,this.beltZ+(Math.floor(j/4)-.5)*.65):slot?new T.Vector3(slot.x+.42,1.30,slot.z):new T.Vector3((index%4-1.5)*.76,1.23,.22+Math.floor(index/4)*.35); + if(!done){delete dish.userData.delivery;if(!created&&!this.reduced&&!this.paused&&dish.position.distanceTo(target)>.15&&!dish.userData.move)dish.userData.move={from:dish.position.clone(),to:target,start:this.time};} + if(done&&!newRoom&&!this.reduced&&!this.paused&&((!created&&!wasDone)||dish.userData.version!==order.completionVersion)){ + dish.userData.delivery={start:this.time,from:dish.position.clone(),target};delete dish.userData.move; + } + if(done)delete dish.userData.move; + if(done&&dish.userData.delivery)dish.userData.delivery.target=target; + if(!dish.userData.delivery&&!dish.userData.move)dish.position.copy(target); + if(dish.userData.move){dish.userData.move.to=target;if(this.reduced||this.paused){dish.position.copy(target);delete dish.userData.move;}} + dish.userData.order=order;dish.userData.version=order.completionVersion;dish.userData.target=target; + }); + for(const [id,mesh] of this.dishes)if(!keep.has(id)){this.clearDish(mesh);this.dishes.delete(id);} + if(!this.tableHit){this.tableHit=box(this.outputs,3.3,.12,1.65,new T.MeshBasicMaterial({transparent:true,opacity:0,depthWrite:false}),this.tableX,1.13,this.beltZ);this.tableHit.userData={kind:'table',id:'all'};}this.tableHit.position.set(this.tableX,1.13,this.beltZ); + } + awning(parent,x,y,z,w,d){const g=group(parent,x,y,z);for(let i=0;i<7;i++){const color=i%2?colors.cream:colors.coral;const roof=box(g,w/7+.012,.07,d,color,-w/2+(i+.5)*w/7,0,0,.04);roof.rotation.x=.18;ball(g,w/14,.15,.048,color,-w/2+(i+.5)*w/7,-.18,d/2-.04);}} + addSteam(parent,x,y,z,station){for(let i=0;i<3;i++){const puff=ball(parent,.07,.1,.07,new T.MeshBasicMaterial({color:0xfff9db,transparent:true,opacity:.16,depthWrite:false}),x,y,z);puff.castShadow=false;this.effects.push({puff,origin:new T.Vector3(x,y,z),phase:i/3,station});}} + bind(){let down=null,drag=false;this.canvas.addEventListener('pointerdown',e=>{down={x:e.clientX,y:e.clientY,px:this.pan.x,pz:this.pan.z};drag=false;this.canvas.setPointerCapture(e.pointerId);}); + this.canvas.addEventListener('pointermove',e=>{if(down&&Math.hypot(e.clientX-down.x,e.clientY-down.y)>5){drag=true;this.pan.x=T.MathUtils.clamp(down.px-(e.clientX-down.x)*.013/this.zoom,-3,3);this.pan.z=T.MathUtils.clamp(down.pz-(e.clientY-down.y)*.018/this.zoom,-2,2);this.updateCamera();}else if(!down)this.hit(e,false);}); + this.canvas.addEventListener('pointerup',e=>{if(!drag)this.hit(e,true);down=null;});this.canvas.addEventListener('pointercancel',()=>{down=null;}); + this.canvas.addEventListener('wheel',e=>{e.preventDefault();this.zoom=T.MathUtils.clamp(this.zoom*Math.exp(-e.deltaY*.0006),.8,1.65);this.resize();},{passive:false}); + this.canvas.addEventListener('keydown',e=>{if(e.key==='0'){this.fit();e.preventDefault();}if(['+','=','-'].includes(e.key)){this.changeZoom(e.key==='-'?-.12:.12);e.preventDefault();}}); + matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change',e=>{this.reduced=e.matches;}); + } + hit(e,select){const rect=this.canvas.getBoundingClientRect();this.pointer.set((e.clientX-rect.left)/rect.width*2-1,-(e.clientY-rect.top)/rect.height*2+1);this.raycaster.setFromCamera(this.pointer,this.camera);const hits=this.raycaster.intersectObjects([this.characters,this.outputs,this.signs],true);let object=null;for(const h of hits){let p=h.object;while(p&&!p.userData.kind)p=p.parent;if(p?.userData.kind){object=p.userData;break;}} + this.canvas.style.cursor=object?'pointer':'grab';if(select&&object)this.onSelect(object);} + resize(){const rect=this.canvas.parentElement.getBoundingClientRect();this.width=rect.width;this.height=rect.height;this.renderer.setSize(rect.width,rect.height,false);this.composer?.setSize(rect.width,rect.height);const aspect=rect.width/Math.max(1,rect.height),span=Math.max(13.4,(this.layout.width+1.7)/aspect)/this.zoom,offset=0;this.camera.left=-span*aspect/2;this.camera.right=span*aspect/2;this.camera.top=span/2+offset;this.camera.bottom=-span/2+offset;this.camera.updateProjectionMatrix();this.updateCamera();} + updateCamera(){this.camera.position.set(this.pan.x,22,14+this.pan.z);this.camera.lookAt(this.target.clone().add(this.pan));} + fit(){this.zoom=1;this.pan.set(0,0,0);this.resize();} + changeZoom(delta){this.zoom=T.MathUtils.clamp(this.zoom+delta,.8,1.65);this.resize();} + setData({components,crew,artifacts,transfers,demo,kitchenId,projectId,connected=true,orders=[],tables=[]}){ + const layout=stationLayout(components.length); + if(layout.columns!==this.layout.columns){ + this.layout=layout;this.room.traverse(o=>{if(o.isMesh){o.geometry.dispose();if(o.material.isMeshBasicMaterial&&!o.material.transparent&&o.material.map){o.material.map.dispose();o.material.dispose();}}});for(const e of this.effects)e.puff.material.dispose();this.room.clear();disposeBatches(this.worktops);this.worktops.clear();this.effects=[];this.workSlots=[];this.buildRoom();this.batchRoom();this.resize();this.roomKey=''; + } + const {positions,columns,walkWidth}=this.layout; + const scope=`${demo}:${projectId}:${kitchenId}`,newRoom=scope!==this.scope;if(newRoom){this.scope=scope;this.seenTransfers=new Set(transfers.map(t=>t.id+':'+t.state));for(const p of this.plates.values()){delete p.userData.flight;delete p.userData.receivedAt;}} + this.demo=demo;this.projectId=projectId;this.kitchenId=kitchenId;this.components=components;this.transfers=transfers; + const key=JSON.stringify(components.map(c=>[c.id,c.title]));if(key!==this.roomKey){this.roomKey=key;while(this.signs.children.length){const s=this.signs.children[0];s.traverse(o=>{if(o.material?.map){o.material.map.dispose();o.material.dispose();o.geometry.dispose();}});this.signs.remove(s);}components.slice(0,8).forEach((c,i)=>{const [x,z]=positions[i],sign=group(this.signs,x,1.28,z+(z<0?-.35:.3));sign.userData={kind:'workstation',id:c.id};box(sign,2.3,.34,.06,colors.wood);plaque(sign,c.title,2.18,.28,0,0,.04);sign.rotation.x=-.45;});} + const occupied=[],incoming=new Set(),seats=new Set();this.activeStoves=new Set(); + for(const slot of this.workSlots){for(const [kind,kit] of Object.entries(slot.kits))kit.visible=kind===['executing','writing','reading'][slot.seat];} + components.forEach((c,i)=>{if(c.kind)for(const slot of this.workSlots.filter(s=>s.station===i)){for(const kit of Object.values(slot.kits))kit.visible=false;slot.kits.reading.visible=c.kind==='knowledge';slot.kits.review.visible=c.kind==='human';}}); + workingFirst(crew.slice(0,12)).forEach(({data:s,index})=>{ + incoming.add(s.id);let c=this.crew.get(s.id); + if(c&&c.visualIndex!==s.visualIndex){disposeBatches(c.root);this.characters.remove(c.root);this.crew.delete(s.id);c=null;}if(!c){c=createChef(this.characters,s.id,s.visualIndex??hash(s.id)%12);c.visualIndex=s.visualIndex;c.root.scale.setScalar(1.3);this.crew.set(s.id,c);} + c.data=s;c.state=!connected?'quiet':s.freshness==='quiet'&&!needsAttention(s)?'quiet':s.state;c.pose=c.state==='quiet'?'quiet':workPose(s); + const active=connected&&isWorking(s),ci=stationForChef({...s,state:c.pose},index,components.length),prefer={executing:0,writing:1,reading:2}[c.pose]??1; + let seat=active&&ci>=0?[prefer,...[0,1,2].filter(i=>i!==prefer)].find(i=>!seats.has(ci+':'+i)):undefined; + c.atWorktop=seat!==undefined;c.workSlot=seat===undefined?null:ci*3+seat; + let desired; + if(c.atWorktop){seats.add(ci+':'+seat);const slot=this.workSlots[c.workSlot];desired=[slot.x,ci=0?[positions[ci][0],ci[-4.7,-3.4,-2.1,2.1,3.4,4.7].map(x=>[x,z])),...[-4.7,-3.4,3.4,4.7].map(x=>[x,0])].filter(p=>walkable(...p,walkWidth)&&!occupied.some(o=>Math.hypot(o[0]-p[0],o[1]-p[1])<1.06)); + options.sort((a,b)=>Math.hypot(a[0]-desired[0],a[1]-desired[1])-Math.hypot(b[0]-desired[0],b[1]-desired[1]));const chosen=options[0]||desired;occupied.push(chosen); + if(Math.hypot(chosen[0]-desired[0],chosen[1]-desired[1])>.1){c.atWorktop=false;this.activeStoves.delete(c.workSlot);} + const home=new T.Vector3(chosen[0],0,chosen[1]);c.baseFacing=ci>=0?(ci.1){c.walkRoute=routeBetween([c.root.position.x,c.root.position.z],chosen,walkWidth).slice(1);} + else if(beat&&c.atWorktop){c.walkRoute=[...routeBetween([c.root.position.x,c.root.position.z],approach,walkWidth).slice(1),...routeBetween(approach,chosen,walkWidth).slice(1)];} + c.home=home;c.facing=c.baseFacing;c.target.copy(home); + }); + for(const [id,c] of this.crew)if(!incoming.has(id)){disposeBatches(c.root);this.characters.remove(c.root);this.crew.delete(id);} + const outputIds=new Set();artifacts.slice(0,8).forEach((a,i)=>{outputIds.add(a.id);let p=this.plates.get(a.id);if(!p){p=plate(this.outputs);ingredient(p,a.kind,0,.07,0,.83);p.userData={kind:'plate',id:a.id};this.plates.set(a.id,p);}p.userData.artifact=a;const receipt=[...transfers].reverse().find(t=>t.artifactKey===a.id&&t.state==='received'),recipient=receipt&&crew.find(c=>receipt.recipientRoleId?c.roleId===receipt.recipientRoleId:c.id===receipt.recipient),ci=components.findIndex(c=>c.id===(a.componentId||recipient?.component?.id));const spot=ci>=0?positions[ci]:[0,-3.45];const order=orders.find(o=>o.id===a.orderId),orderChef=order&&this.crew.get(order.activeChefIds.find(id=>this.crew.has(id))),actor=orderChef||(recipient&&this.crew.get(recipient.id)),slot=actor&&this.workSlots[actor.workSlot];p.position.set(slot?slot.x-.43:spot[0]+(i%3-1)*.60,1.2,slot?slot.z:spot[1]);});for(const [id,p] of this.plates)if(!outputIds.has(id)){this.outputs.remove(p);this.plates.delete(id);} + // Receipts belong at the pass; they must not commandeer a chef's newer action. + if(!newRoom)for(const transfer of transfers){const key=transfer.id+':'+transfer.state;if(this.seenTransfers.has(key))continue;this.seenTransfers.add(key);if(transfer.state==='received'){const p=this.plates.get(transfer.artifactKey),sender=transfer.senderRoleId?[...this.crew.values()].find(c=>c.data.roleId===transfer.senderRoleId):this.crew.get(transfer.sender),recipient=transfer.recipientRoleId?[...this.crew.values()].find(c=>c.data.roleId===transfer.recipientRoleId):this.crew.get(transfer.recipient);if(p){p.userData.receivedAt=this.time;if(recipient&&!this.reduced&&!this.paused)p.userData.flight={from:sender?new T.Vector3(sender.home.x,1.35,sender.home.z):new T.Vector3(0,1.35,.3),to:p.position.clone(),start:this.time};}}} + this.setOrders(orders,tables,newRoom); + } + select(selection){ + this.selected=selection;const selectedChef=selection?.kind==='chef'?this.crew.get(selection.id):null; + const selectedOrder=selection?.kind==='order'?this.orders?.find(o=>o.id===selection.id):selectedChef?orderForChef(this.orders||[],selectedChef.data.id):null; + const component=selectedChef?(selectedChef.data.component?.id||'__unlinked'):['goal','station'].includes(selection?.kind)?selection.id:null; + for(const c of this.crew.values()){c.selected=selection?.kind==='chef'&&selection.id===c.id;c.related=selectedOrder?selectedOrder.contributors.some(person=>person.chefId===c.data.id):!!component&&(c.data.component?.id||'__unlinked')===component;} + for(const dish of this.dishes.values())dish.scale.setScalar(selectedOrder?.id===dish.userData.id?1.12:1); + for(const sign of this.signs.children){const active=sign.userData.id===component;sign.scale.setScalar(active?1.06:1);} + } + update(dt){ + const t=this.time; + this.activeStoves=new Set();let walkers=0,workers=0; + for(const c of this.crew.values()){ + c.carrying=false; + if(this.reduced&&!this.paused)c.walkRoute=[]; + const point=c.walkRoute?.[0]; + c.target.copy(point?new T.Vector3(point[0],0,point[1]):c.home); + if(point&&c.root.position.distanceTo(c.target)<.08)c.walkRoute.shift(); + if(!point&&c.root.position.distanceTo(c.target)<.04)c.facing=c.baseFacing; + animateChef(c,t,dt,this.reduced,this.paused); + if(c.root.position.distanceTo(c.home)>.08||c.walkRoute?.length){if(!this.paused&&!this.reduced)walkers++;} + else if(c.atWorktop&&isWorking(c.data)){workers++;if(c.pose==='executing')this.activeStoves.add(c.workSlot);} + } + this.canvas.dataset.walking=String(walkers);this.canvas.dataset.working=String(workers);this.canvas.dataset.motion=this.paused?'paused':this.reduced?'reduced':'playing'; + for(const p of this.plates.values()){ + const flight=p.userData.flight;if(flight){const progress=T.MathUtils.clamp((t-flight.start)/.7,0,1);p.position.lerpVectors(flight.from,flight.to,progress);p.position.y+=Math.sin(progress*Math.PI)*.5;if(progress===1||this.reduced){p.position.copy(flight.to);delete p.userData.flight;}} + const since=t-(p.userData.receivedAt??-100);p.scale.setScalar(!this.reduced&&!this.paused&&since<.7?1+Math.sin(since/.7*Math.PI)*.12:1); + } + let delivering=false; + for(const dish of this.dishes.values()){ + const delivery=dish.userData.delivery,move=dish.userData.move; + if(delivery){ + const elapsed=t-delivery.start;delivering=true; + if(elapsed<.6){const progress=elapsed/.6;dish.position.lerpVectors(delivery.from,new T.Vector3(this.beltStart,1.09,this.beltZ),progress);dish.position.y+=Math.sin(progress*Math.PI)*.6;} + else if(elapsed<3.6)dish.position.set(T.MathUtils.lerp(this.beltStart,this.beltEnd,(elapsed-.6)/3),1.09,this.beltZ); + else{const progress=Math.min(1,(elapsed-3.6)/.5);dish.position.lerpVectors(new T.Vector3(this.beltEnd,1.09,this.beltZ),delivery.target,progress);if(progress===1)delete dish.userData.delivery;} + if(this.reduced){dish.position.copy(delivery.target);delete dish.userData.delivery;} + }else if(move){const progress=Math.min(1,(t-move.start)/.8);dish.position.lerpVectors(move.from,move.to,progress);dish.position.y+=Math.sin(progress*Math.PI)*.25;if(progress===1||this.reduced){dish.position.copy(move.to);delete dish.userData.move;}} + } + if(delivering&&!this.paused&&!this.reduced)for(let i=0;id.userData.delivery).length); + for(const e of this.effects){e.puff.visible=!this.paused&&!this.reduced&&this.activeStoves?.has(e.station);const phase=(t*.35+e.phase)%1;e.puff.position.copy(e.origin).add(new T.Vector3(Math.sin(t+e.phase)*.06,phase*.7,0));const size=.6+phase*1.2;e.puff.scale.set(.07*size,.1*size,.07*size);e.puff.material.opacity=(1-phase)*.15;} + if(this.onLabels){ + const labels=[],placed=[]; + for(const c of this.crew.values()){ + const p=c.root.getWorldPosition(new T.Vector3()).add(new T.Vector3(0,0,c.baseFacing===Math.PI?.5:-.12)).project(this.camera); + const visible=Math.abs(p.x)<1.15&&Math.abs(p.y)<1.1; + const compact=this.width<1000||this.height<450,w=compact?144:176,h=compact&&!c.selected&&!needsAttention(c.data)&&!isWorking(c.data)?24:46; + const fx=(p.x*.5+.5)*this.width,fy=(-p.y*.5+.5)*this.height; + const candidates=[0,1,-1,2,-2,3].flatMap(row=>[0,-1,1].map(col=>({x:T.MathUtils.clamp(fx+col*w,w/2,this.width-w/2),y:T.MathUtils.clamp(fy+row*h,4,Math.max(4,this.height-h-4))}))); + const score=a=>placed.reduce((n,b)=>n+(Math.abs(a.x-b.x)<(w+b.w)/2&&a.yb.y?10000:0),0)+Math.hypot(a.x-fx,(a.y-fy)*1.2); + candidates.sort((a,b)=>score(a)-score(b));const {x,y}=candidates[0]; + placed.push({x,y,w,h});labels.push({id:c.id,x,y,visible,compact,color:'#'+c.color.toString(16).padStart(6,'0')}); + } + this.onLabels(labels); + } + } + stats(){return {...this.renderer.info.render,geometries:this.renderer.info.memory.geometries,textures:this.renderer.info.memory.textures,chefs:this.crew.size};} +} diff --git a/packages/kitchen/scripts/build.mjs b/packages/kitchen/scripts/build.mjs new file mode 100644 index 0000000..814277e --- /dev/null +++ b/packages/kitchen/scripts/build.mjs @@ -0,0 +1,7 @@ +import {build} from 'esbuild'; +import fs from 'node:fs/promises'; +await fs.mkdir('public/build',{recursive:true}); +await fs.mkdir('public/fonts',{recursive:true}); +for(const weight of [400,600,700,800])await fs.copyFile(`node_modules/@fontsource/nunito/files/nunito-latin-${weight}-normal.woff2`,`public/fonts/nunito-${weight}.woff2`); +await build({entryPoints:['public/src/app.js'],bundle:true,format:'esm',target:'es2022',outfile:'public/build/app.js',minify:true,legalComments:'eof'}); +console.log('Kitchen built.'); diff --git a/packages/kitchen/scripts/check-package.mjs b/packages/kitchen/scripts/check-package.mjs new file mode 100644 index 0000000..ae8c23c --- /dev/null +++ b/packages/kitchen/scripts/check-package.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {execFile} from 'node:child_process'; +import {promisify} from 'node:util'; +import {pathToFileURL} from 'node:url'; + +const run=promisify(execFile); +const archive=process.argv[2]&&path.resolve(process.argv[2]); +if(!archive)throw new Error('Provide the .tgz produced by npm pack.'); +const fixture=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'agenttrail-kitchen-package-'))); +let service; +try{ + const npm=process.platform==='win32'?'npm.cmd':'npm'; + await run(npm,['install','--prefix',fixture,'--omit=dev','--ignore-scripts','--offline','--no-audit','--no-fund',archive],{timeout:60000}); + const installed=path.join(fixture,'node_modules/agenttrail-kitchen'); + const pkg=JSON.parse(await fs.readFile(path.join(installed,'package.json'),'utf8')); + assert.equal(pkg.license,'MIT'); + assert.equal(pkg.private,undefined); + assert.deepEqual(Object.keys(pkg.dependencies||{}),[]); + for(const excluded of ['test','scripts','concepts','recordings','.office','.runs','public/src']){ + await assert.rejects(fs.access(path.join(installed,excluded)),{code:'ENOENT'}); + } + await assert.rejects(fs.access(path.join(fixture,'node_modules/three')),{code:'ENOENT'}); + const cli=path.join(installed,pkg.bin['agenttrail-kitchen']); + assert.match((await run(process.execPath,[cli,'--help'])).stdout,/agenttrail-kitchen/); + const first=path.join(fixture,'first repo'),second=path.join(fixture,"repo with spaces ' and $(literal)"); + const stateDir=path.join(fixture,'state'),observerHome=path.join(fixture,'empty-observer-home'); + await Promise.all([first,second,observerHome].map(p=>fs.mkdir(p))); + const {startOffice}=await import(pathToFileURL(path.join(installed,'src/server.mjs'))); + service=await startOffice({roots:[first],home:observerHome,stateDir,port:0,observe:false}); + for(const [asset,mime] of [['/','text/html'],['/build/app.js','text/javascript'],['/kitchen.css','text/css'],['/fonts/nunito-800.woff2','font/woff2'],['/favicon.svg','image/svg+xml']]){ + const response=await fetch(service.url+asset); + assert.equal(response.status,200,asset); + assert.equal(response.headers.get('content-type')?.split(';')[0],mime); + assert.ok((await response.arrayBuffer()).byteLength>20,asset); + } + const initial=await fetch(service.url+'/api/state').then(r=>r.json()); + assert.equal(initial.app,'agenttrail-kitchen'); + assert.deepEqual(initial.executors,[]); + const {stdout}=await run(process.execPath,[cli,second,'--state-dir',stateDir,'--no-open'],{timeout:15000}); + const live=new URL(stdout.split('\n')[0].replace('Kitchen updated: ','')); + assert.equal(live.searchParams.get('project'),second); + assert.equal(live.searchParams.get('mode'),'live'); + const example=await run(process.execPath,[cli,second,'--example','--state-dir',stateDir,'--no-open'],{timeout:15000}); + const demo=new URL(example.stdout.split('\n')[0].replace('Kitchen updated: ','')); + assert.equal(demo.searchParams.get('mode'),'demo'); + assert.equal(demo.searchParams.has('project'),false); + assert.deepEqual(await fs.readdir(first),[]); + assert.deepEqual(await fs.readdir(second),[]); + console.log('Packed kitchen verified: offline install, no dev dependencies, local assets, live attachment and labeled example launch.'); +}finally{ + await service?.close(); + await fs.rm(fixture,{recursive:true,force:true}); +} diff --git a/packages/kitchen/src/agenttrail/crew-profile.mjs b/packages/kitchen/src/agenttrail/crew-profile.mjs new file mode 100644 index 0000000..0f3897b --- /dev/null +++ b/packages/kitchen/src/agenttrail/crew-profile.mjs @@ -0,0 +1,38 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +// A crew is a set of responsibilities, not a generated plan or extra executors. +export function inferCrew(files,name){ + const has=pattern=>files.some(f=>pattern.test(f)),roles=[]; + const add=(id,title,category,description,paths=[])=>roles.push({id,title,category,description,files:paths,components:[],origin:'inferred'}); + add('coordinator','Head chef','coordinate','Keeps the shared request moving and coordinates the next piece of work.'); + add('researcher','Researcher','research','Reads references, explores the repo, and gathers inputs for the same request.',['research/**','docs/**','references/**','notes/**']); + const simulation=has(/^(sim|simulation)\//),world=has(/(^|\/)(world|characters|interior)\.[^/]+$|\.blend$|^unreal\//),content=has(/^(drafts|articles|posts|content)\//); + if(world)add('world-builder','World builder','build','Builds the scene, characters, and their visual interactions.',['src/**','unreal/**','assets/**','public/**','scripts/*world*','scripts/*assets*']); + if(simulation)add('simulation-engineer','Simulation engineer','simulation','Builds and checks the rules, state, and runtime of the simulation.',['sim/**','simulation/**']); + if(content)add('writer','Writer','build','Turns research and inputs into the shared draft.',['drafts/**','articles/**','posts/**','content/**']); + if(!world&&!content)add('builder',simulation?'App builder':'Builder','build','Implements the request using the inputs prepared by the crew.',['src/**','app/**','lib/**','scripts/**','server.*']); + add('reviewer',content?'Evaluator':'Reviewer','review','Checks the result, exercises the preview, and catches issues before delivery.',['test/**','tests/**','evals/**','evaluations/**','**/*.test.*','**/*.spec.*']); + if(content&&has(/^publisher\//))add('publisher','Publisher','publish','Handles delivery when the source workflow explicitly runs it.',['publisher/**']); + return {id:'project',title:`${name} workflow`,origin:'inferred',roles,adapter:null,evidence:files.filter(f=>/^(src|sim|simulation|research|docs|drafts|articles|posts|content|tests|evals|publisher|unreal)\//.test(f)||/\.blend$/.test(f)).slice(0,12)}; +} + +export class CrewProfiles { + constructor(){this.cache=new Map();} + async get(root,name){ + const old=this.cache.get(root);if(old&&Date.now()-old.at<30_000)return old.workflow; + const files=[],skip=/^(\.|node_modules$|dist$|build$|coverage$|runtime$|vendor$)/; + async function read(relative,depth){ + if(files.length>=300)return; + let entries;try{entries=await fs.readdir(path.join(root,relative),{withFileTypes:true});}catch{return;} + const folders=[]; + for(const entry of entries.sort((a,b)=>a.name.localeCompare(b.name))){ + if(skip.test(entry.name)||entry.isSymbolicLink())continue; + const file=relative+entry.name;if(entry.isDirectory()){files.push(file+'/');if(depth<1)folders.push(file+'/');}else if(entry.isFile())files.push(file); + if(files.length>=300)break; + } + for(const folder of folders)await read(folder,depth+1); + } + await read('',0);const workflow=inferCrew(files,name);this.cache.set(root,{at:Date.now(),workflow});return workflow; + } +} diff --git a/packages/kitchen/src/agenttrail/kitchens.mjs b/packages/kitchen/src/agenttrail/kitchens.mjs new file mode 100644 index 0000000..1441f3d --- /dev/null +++ b/packages/kitchen/src/agenttrail/kitchens.mjs @@ -0,0 +1,37 @@ +import { clean } from '../runtime/crew.mjs'; + +const idPattern=/^[\w-]{1,100}$/; +export function taskCounts(tasks) { + return {total:tasks.length,done:tasks.filter(t=>t.state==='x').length,active:tasks.filter(t=>t.state==='~').length,blocked:tasks.filter(t=>t.state==='!').length}; +} + +// Kitchens only group the project map. They never create, assign, or complete work. +export function kitchenMap(components,config=null) { + const warnings=[],kitchens=[],deliverables=[],owned=new Set(),ids=new Set(),capacity=config?.workflow?8:4; + const byId=new Map(components.map(c=>[c.id,c])); + if(config&&(config.version!==1||!Array.isArray(config.kitchens)||!Array.isArray(config.deliverables))){warnings.push('Kitchen configuration must have version 1, kitchens, and deliverables. Using the project map.');config=null;} + for(const k of (config?.kitchens||[]).slice(0,24)){ + if(!k||!idPattern.test(k.id)||ids.has(k.id)||!Array.isArray(k.components)){warnings.push('Skipped a kitchen with missing or repeated identity.');continue;} + const members=[]; + for(const id of k.components){if(!byId.has(id)){warnings.push(`Unknown component: ${clean(id)}`);continue;}if(owned.has(id)){warnings.push(`Component appears in multiple kitchens: ${id}`);continue;}owned.add(id);members.push(id);} + if(members.length){for(let offset=0;offset!owned.has(c.id)); + for(let i=0;ic.id)});} + if(!kitchens.length)kitchens.push({id:'shared',title:'Main kitchen',components:[]}); + const tasks=components.flatMap(c=>c.tasks.map((t,index)=>({...t,componentId:c.id,key:`${c.id}:${t.id||'row-'+index}`}))); + const taskIds=new Map();for(const t of tasks){if(t.id)taskIds.set(t.id,[...(taskIds.get(t.id)||[]),t]);} + const usedDeliverables=new Set(); + for(const d of (config?.deliverables||[]).slice(0,32)){ + if(!d||!idPattern.test(d.id)||usedDeliverables.has(d.id)||!Array.isArray(d.tasks)){warnings.push('Skipped a deliverable with missing or repeated identity.');continue;} + const refs=[],seen=new Set(),missing=[]; + for(const id of d.tasks){const matches=taskIds.get(id)||[];if(matches.length!==1){missing.push(clean(id));continue;}if(!seen.has(id)){refs.push(matches[0]);seen.add(id);}} + usedDeliverables.add(d.id); + deliverables.push({id:d.id,title:clean(d.title)||'Untitled deliverable',icon:['map','image','gear','box','network'].includes(d.icon)?d.icon:'box',tasks:refs,missing,source:'configured'}); + if(missing.length)warnings.push(`Deliverable “${clean(d.title)}” has missing or ambiguous task references.`); + } + if(!deliverables.length){for(const c of components)deliverables.push({id:'component-'+c.id,title:c.title,icon:'gear',tasks:tasks.filter(t=>t.componentId===c.id),missing:[],source:'component'});} + for(const d of deliverables){d.counts=taskCounts(d.tasks);d.kitchenIds=kitchens.filter(k=>d.tasks.some(t=>k.components.includes(t.componentId))).map(k=>k.id);d.status=d.missing.length?'unknown':d.counts.blocked?'blocked':d.counts.total&&d.counts.done===d.counts.total?'complete':d.counts.active?'active':'planned';} + for(const k of kitchens)k.counts=taskCounts(tasks.filter(t=>k.components.includes(t.componentId))); + return {kitchens,deliverables,warnings}; +} diff --git a/packages/kitchen/src/agenttrail/projects.mjs b/packages/kitchen/src/agenttrail/projects.mjs new file mode 100644 index 0000000..dda89a3 --- /dev/null +++ b/packages/kitchen/src/agenttrail/projects.mjs @@ -0,0 +1,104 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { clean } from '../runtime/crew.mjs'; +import { kitchenMap } from './kitchens.mjs'; +import {workflowConfig,workflowStations,WorkflowQueues} from './workflows.mjs'; +import {CrewProfiles} from './crew-profile.mjs'; + +export function parsePlan(text) { + const components=[];let current=null,currentTask=null; + for(const line of text.split('\n')) { + const heading=line.match(/^##\s+(.+?)\s*\{#([\w-]+)\}\s*$/); + if(heading){current={id:heading[2],title:clean(heading[1]),files:[],tasks:[],needs:[],links:[]};currentTask=null;components.push(current);continue;} + if(/^##\s/.test(line)){current=null;currentTask=null;continue;} + if(!current)continue; + const kind=line.match(/^kind:\s*(human|knowledge)\s*$/);if(kind)current.kind=kind[1]; + const url=line.match(/^url:\s*(https?:\/\/\S+)\s*$/);if(url)current.url=clean(url[1],400); + const files=line.match(/^files:\s*\[(.*?)\]/);if(files)current.files=files[1].split(',').map(s=>s.trim()); + const edges=line.match(/^(needs|links):\s*\[(.*?)\]/);if(edges)current[edges[1]]=edges[2].split(',').map(s=>s.trim()).filter(Boolean); + const task=line.match(/^\s*-\s*\[([ x~!])\]\s+(.+?)(?:\s*\{#([\w-]+)\})?\s*$/); + if(task){currentTask={id:task[3]||null,title:clean(task[2]),state:task[1]};current.tasks.push(currentTask);continue;} + const meta=line.match(/^\s+(by|from):\s*(.+)/);if(meta&¤tTask)currentTask[meta[1]]=clean(meta[2]); + } + return components; +} +export function matchesGlob(file,glob) { + let p='';for(let i=0;i/(^|\/)(\.git|node_modules|\.office|\.agenttrail|dist|coverage)(\/|$)|\.DS_Store|\.(swp|tmp)$/.test(f); +export class Projects { + constructor(roots,home,store){this.roots=roots;this.home=home;this.store=store;this.data=new Map();this.watchers=[];this.runContext=new Map();this.queues=new WorkflowQueues();this.profiles=new CrewProfiles();} + watch(root){ + if(this.data.has(root))return; + const project={id:root,name:path.basename(root),components:[],activity:[],boardUrl:null,contextSource:'plan',watchStatus:'watching',planStamp:-1};this.data.set(root,project); + try{this.watchers.push(fs.watch(root,{recursive:true},(_,file)=>{ + const f=String(file||'').split(path.sep).join('/');if(!f||ignored(f))return; + const at=Date.now();project.activity=[{file:clean(f,300),at},...project.activity.filter(a=>a.file!==f)].slice(0,10); + }));}catch{project.watchStatus='plan only';} + } + async poll(){ + await Promise.all(this.roots.map(async root=>{ + this.watch(root);const p=this.data.get(root); + try{const stat=await fsp.stat(path.join(root,'PLAN.md'));if(stat.mtimeMs!==p.planStamp){p.components=parsePlan((await fsp.readFile(path.join(root,'PLAN.md'),'utf8')).slice(0,512_000));p.planStamp=stat.mtimeMs;}}catch{p.components=[];} + let config=null,configError=null; + try{const text=await fsp.readFile(path.join(root,'.office/kitchen.json'),'utf8');if(text.length>64_000)throw new Error('Kitchen configuration is too large.');config=JSON.parse(text);}catch(e){if(e.code!=='ENOENT')configError='Kitchen configuration could not be read. Showing the full project map.';} + config=workflowConfig(p.components,config,p.name); + Object.assign(p,kitchenMap(p.components,config));p.workflow=config?.version===1&&Array.isArray(config.kitchens)&&Array.isArray(config.deliverables)&&Array.isArray(config.workflow?.roles)?config.workflow:null; + if(!p.workflow&&!p.components.length&&config?.workflow!==false)p.workflow=await this.profiles.get(root,p.name); + if(p.workflow){p.workflow.stations=workflowStations(p.components,p.workflow.roles);if(p.workflow.adapter==='reddit-loop')p.workflow.queue=await this.queues.snapshot(root);} + if(configError)p.warnings.push(configError); + p.boardUrl=null;p.contextSource='plan'; + try{ + const hash=crypto.createHash('sha1').update(root).digest('hex').slice(0,12); + const saved=JSON.parse(await fsp.readFile(path.join(this.home,'.agenttrail',hash+'.json'),'utf8')); + if(saved.repoPath!==root||!Number.isInteger(saved.port)||saved.port<1024||saved.port>65535)return; + const base=`http://127.0.0.1:${saved.port}`; + const who=await fetch(base+'/whoami',{signal:AbortSignal.timeout(400)}).then(r=>r.json()); + if(who.repoPath!==root)return; + const model=await fetch(base+'/board-lite',{signal:AbortSignal.timeout(700)}).then(r=>r.json()); + if(!Array.isArray(model.plan))return; + p.boardUrl=base;p.contextSource='agenttrail'; + this.applyBoard(root,model); + }catch{p.boardUrl=null;p.contextSource='plan';} + })); + } + applyBoard(root,model){ + const components=this.data.get(root)?.components||[]; + for(const run of (model.runs||[]).slice(0,160)){ + if(typeof run.id!=='string'||!['claude','codex','cursor'].includes(run.agent))continue; + const at=Number(run.lastEventAt);if(!Number.isFinite(at)||at<=0||at>Date.now()+60_000)continue; + const id=`${run.agent}:${clean(run.id,200)}`,old=this.runContext.get(id); + if(old&&old.at>at)continue; + const component=components.find(c=>c.id===run.componentId); + const todos=(Array.isArray(run.todos)?run.todos:[]).slice(0,24).filter(t=>typeof t?.content==='string'&&['pending','in_progress','completed'].includes(t.status)).map(t=>({...((typeof t.id==='string'||typeof t.id==='number')?{id:clean(String(t.id),100)}:{}),title:clean(t.content,180),status:t.status})); + this.runContext.set(id,{project:root,at,componentId:component?.id||null,todos,hasPlan:Array.isArray(run.todos),ended:!!run.ended}); + const existing=this.store.sessions.get(id); + // Board context augments native observations; it cannot overwrite their lifecycle. + if(!existing||existing.source==='agenttrail')this.store.accept({id:`board:${run.id}:${at}:${!!run.ended}:${clean(run.currentTool?.name,80)}`,provider:run.agent,sessionId:run.id,cwd:root,at,source:'agenttrail',kind:run.ended?'session-end':run.currentTool?'activity':'unknown',tool:clean(run.currentTool?.name,80)}); + } + for(const [id,c] of this.runContext)if(Date.now()-c.at>24*3600_000)this.runContext.delete(id); + } + snapshot(){return [...this.data.values()].map(({planStamp,...p})=>p);} + enrich(crew){return crew.map(s=>{ + const components=this.data.get(s.project)?.components||[]; + const context=this.runContext.get(s.id),board=context?.project===s.project&&Date.now()-context.at<15*60_000?context:null; + const matches=s.file?components.filter(c=>c.files.some(g=>matchesGlob(s.file,g))):[]; + const fileComponent=matches.length===1?matches[0]:null,boardComponent=components.find(c=>c.id===board?.componentId); + const conflict=!!(fileComponent&&boardComponent&&fileComponent.id!==boardComponent.id); + let component=null,association={kind:'unknown',source:null,reason:matches.length>1?'File belongs to several goals':'No goal link reported'}; + if(fileComponent&&(!boardComponent||!conflict||(s.fileAt||0)>board.at)){ + component=fileComponent;association={kind:'inferred',source:'file',reason:'Based on the last observed file'}; + }else if(boardComponent&&!conflict){ + component=boardComponent;association={kind:'inferred',source:'agenttrail',reason:'Agenttrail matched this session to a component'}; + }else if(conflict){association.reason='File and Agenttrail associations disagree';} + const useNative=s.sessionTasks&&(!board||(s.taskContextAt||0)>=board.at),sessionTasks=useNative?s.sessionTasks:board?.todos||[]; + return {...s,component:component?{id:component.id,title:component.title}:null,componentCandidates:[...new Set([...matches.map(c=>c.id),...(boardComponent?[boardComponent.id]:[])])],association,sessionTasks,planAvailable:!!(useNative||board?.hasPlan),taskSource:useNative?'native plan':board?.hasPlan?'Agenttrail run':null,currentTask:sessionTasks.find(t=>t.status==='in_progress')||null,contextAt:useNative?s.taskContextAt:board?.at||null}; + });} + close(){for(const watcher of this.watchers)watcher.close();} +} diff --git a/packages/kitchen/src/agenttrail/workflows.mjs b/packages/kitchen/src/agenttrail/workflows.mjs new file mode 100644 index 0000000..bac9573 --- /dev/null +++ b/packages/kitchen/src/agenttrail/workflows.mjs @@ -0,0 +1,80 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import {clean,within} from '../runtime/crew.mjs'; + +const idPattern=/^[\w-]{1,100}$/; +const redditRoles=[ + {id:'researcher',title:'Researcher',components:['research','radar']}, + {id:'writer',title:'Writer',components:['write']}, + {id:'evaluator',title:'Evaluator',components:['evaluate']}, + {id:'publisher',title:'Publisher',components:['publisher']}, + {id:'head-chef',title:'Head chef',components:['manager','distill']} +]; + +// Roles describe responsibility. They never declare that an executor is running. +export function workflowConfig(components,config,name){ + if(config?.workflow===false)return config; + if(config&&(config.version!==1||!Array.isArray(config.kitchens)||!Array.isArray(config.deliverables)))return config; + if(!components.length){ + if(!Array.isArray(config?.workflow?.roles))return config; + const roles=[];for(const r of config.workflow.roles.slice(0,12)){if(!idPattern.test(r?.id)||roles.some(role=>role.id===r.id))continue;roles.push({id:r.id,title:clean(r.title)||r.id,description:clean(r.description,250),components:[],files:(Array.isArray(r.files)?r.files:[]).filter(f=>typeof f==='string'&&f.length<200).slice(0,24),category:['coordinate','research','build','simulation','review','publish'].includes(r.category)?r.category:null,origin:'configured'});} + return {...config,workflow:{id:idPattern.test(config.workflow.id)?config.workflow.id:'project',title:clean(config.workflow.title)||`${name} workflow`,roles,origin:'configured',adapter:null}}; + } + const reddit=['research','write','evaluate','queue','publisher','manager'].every(id=>components.some(c=>c.id===id)); + const requested=config?.workflow,claimed=new Set(),roles=[]; + const candidates=Array.isArray(requested?.roles)?requested.roles:reddit?redditRoles:[]; + for(const role of candidates.slice(0,32)){ + if(typeof role?.id!=='string'||!idPattern.test(role.id)||roles.some(r=>r.id===role.id)||!Array.isArray(role.components))continue; + const members=role.components.filter(id=>components.some(c=>c.id===id&&c.kind!=='human')&&!claimed.has(id)); + if(!members.length)continue; + members.forEach(id=>claimed.add(id));roles.push({id:role.id,title:clean(role.title)||role.id,components:members}); + } + for(const c of components){if(claimed.has(c.id)||['human','knowledge'].includes(c.kind))continue;let id=c.id;while(roles.some(r=>r.id===id))id+='-chef';roles.push({id,title:c.title,components:[c.id]});} + const workflow={id:typeof requested?.id==='string'&&idPattern.test(requested.id)?requested.id:'project',title:clean(requested?.title)||(reddit?'Reddit loop':`${name} workflow`),roles,adapter:reddit?'reddit-loop':null}; + return {version:1,deliverables:[],...config,kitchens:config?.kitchens?.length?config.kitchens:[{id:'workflow',title:workflow.title,components:components.map(c=>c.id)}],workflow}; +} + +export function workflowStations(components,roles){ + return roles.map(r=>({id:r.components.find(id=>components.some(c=>c.id===id&&c.kind!=='knowledge'))||r.components[0]||r.id,title:r.title,roleId:r.id,components:r.components})).concat(components.filter(c=>['human','knowledge'].includes(c.kind)).map(c=>({id:c.id,title:c.kind==='human'?'Your review':'Shared pantry',kind:c.kind,components:[c.id],url:c.url}))); +} + +function frontmatter(text){ + const front=text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1]||'',fields={}; + for(const key of ['title_a','subreddit','status','approved','approved_at','scheduled_at','created_at','posted_url','posted_at','delivery_status']){ + const value=front.match(new RegExp('^'+key+':\\s*(.*)$','m'))?.[1];if(value)fields[key]=clean(value.trim().replace(/^(['"])(.*)\1$/,'$2'),key==='posted_url'?400:180); + } + // Only the digest leaves this function. Draft bodies never enter the snapshot. + const content=text.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/,''); + return {fields,revision:crypto.createHash('sha256').update(JSON.stringify([fields.title_a,content])).digest('hex').slice(0,16)}; +} + +export class WorkflowQueues { + constructor(){this.cache=new Map();} + async read(root,relative,max=256_000){ + try{const file=await fs.realpath(path.join(root,relative));if(!within(file,root))return null;const st=await fs.stat(file);if(!st.isFile()||st.size>max)return null;const key=root+'|'+relative,stamp=`${st.mtimeMs}:${st.size}`,old=this.cache.get(key);if(old?.stamp===stamp)return old; + const text=await fs.readFile(file,'utf8'),entry={stamp,at:st.mtimeMs,text,item:old?.item};this.cache.set(key,entry);if(this.cache.size>1800)this.cache.delete(this.cache.keys().next().value);return entry; + }catch{return null;} + } + async snapshot(root){ + let files,total;try{root=await fs.realpath(root);files=(await fs.readdir(path.join(root,'drafts'))).filter(f=>f.endsWith('.md')).sort();total=files.length;files=files.slice(-500);}catch{return {items:[],available:false,summary:'Draft queue unavailable'};} + const account=await this.read(root,'publisher/account-state.md',32_000),accountState=account?.text.match(/^status:\s*(\S+)/m)?.[1],blocked=accountState==='distribution_blocked'; + const items=[]; + for(const file of files){const draft=await this.read(root,'drafts/'+file);if(!draft)continue;const {fields:m,revision}=frontmatter(draft.text),evaluation=await this.read(root,'evals/'+file,64_000); + const verdicts=[...(evaluation?.text||'').matchAll(/\bVerdict:\s*\**\s*(SHIP|HOLD|REVISE|KILL)\b/gi)],verdict=verdicts.at(-1)?.[1].toUpperCase()||null; + const previous=draft.item;const stale=!!(previous&&previous.revision!==revision&&previous.evaluationStamp===evaluation?.stamp)||!!(previous?.stale&&previous.evaluationStamp===evaluation?.stamp); + let stage='evaluate',label='Awaiting evaluation'; + if(!['draft','posted','abandoned'].includes(m.status)){stage='unknown';label='Item state unavailable';} + else if(m.status==='abandoned'||verdict==='KILL'){stage='history';label=m.status==='abandoned'?'Abandoned':'Not proceeding';} + else if(m.status==='posted'){stage='history';label='Recorded as posted';} + else if(stale){stage='evaluate';label='Changed since observed evaluation';} + else if(verdict==='REVISE'){stage='write';label='Needs revision';} + else if(verdict==='HOLD'){stage='evaluate';label='Evaluation on hold';} + else if(verdict==='SHIP'&&m.approved!=='yes'){stage='queue';label='Needs your review';} + else if(verdict==='SHIP'&&m.approved==='yes'){stage='publisher';label=blocked?'Dispatch blocked':Date.parse(m.scheduled_at)>Date.now()?'Waiting for scheduled time':'Approval recorded';} + const item={id:file,file:'drafts/'+file,title:m.title_a||file,subreddit:m.subreddit||'',createdAt:m.created_at||null,revision,verdict,stage,label,scheduledAt:m.scheduled_at||null,approved:m.approved==='yes',stale}; + draft.item={revision,evaluationStamp:evaluation?.stamp,stale};items.push(item); + } + return {available:true,items,blocked,summary:total>500?'Limited view of 500 draft files':items.length[stage,items.filter(i=>i.stage===stage).length]))}; + } +} diff --git a/packages/kitchen/src/connectors/desktop-events.mjs b/packages/kitchen/src/connectors/desktop-events.mjs new file mode 100644 index 0000000..a4cad9e --- /dev/null +++ b/packages/kitchen/src/connectors/desktop-events.mjs @@ -0,0 +1,29 @@ +// Consume structured observations, never evaluate orchestration code or read reasoning. +export function desktopWork(item){ + if(!item||typeof item!=='object')return null; + const type=String(item.type||'').toLowerCase(); + if(type==='commandexecution'){ + const parsed=Array.isArray(item.parsed_cmd)?item.parsed_cmd:[],last=parsed.at(-1); + const failed=item.status==='failed'||(typeof item.exit_code==='number'&&item.exit_code!==0); + if(last&&['read','search','list_files'].includes(last.type))return {category:'research',label:last.type==='read'?(failed?'Tried reading a file':'Read a file'):last.type==='search'?(failed?'Tried searching files':'Searched files'):(failed?'Tried exploring the repo':'Explored the repo'),file:last.type==='read'?last.path:undefined}; + // Only recognize complete, simple check commands; compound scripts remain unknown. + const command=Array.isArray(item.command)?item.command.at(-1):null; + if(typeof command==='string'&&command.length<180&&!/[\n;&|<>`$]/.test(command)&&/^(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|lint|typecheck)(?:\s|$)|node\s+--(?:test|check)(?:\s|$)|(?:pytest|cargo test|go test)(?:\s|$))/.test(command.trim()))return {category:'review',label:item.exit_code===0?'Checks passed':'Ran checks'}; + return {category:'execute',label:failed?'Command failed':'Ran a command'}; + } + if(type==='mcptoolcall'&&item.server==='cua_repl')return {category:'review',label:'Inspected the preview'}; + if(type==='subagentactivity')return {category:'coordinate',label:'Coordinated with an agent'}; + if(type==='imageview')return {category:'review',label:'Inspected an image',file:item.path}; + if(type==='extension'&&/web.?search/i.test(item.kind||''))return {category:'research',label:'Researched references'}; + if(type==='filechange'&&Array.isArray(item.changes)){ + const files=item.changes.map(c=>c?.path).filter(f=>typeof f==='string');return {category:'build',label:'Changed files',file:files.length===1?files[0]:undefined}; + } + return null; +} + +export function directWork(name,namespace){ + if(namespace==='mcp__cua_repl'&&name==='js')return {category:'review',label:'Inspecting the preview',completed:false}; + if(namespace==='collaboration'&&['send_message','followup_task','spawn_agent','list_agents','wait_agent'].includes(name))return {category:'coordinate',label:'Coordinating the work',completed:false}; + if(name==='apply_patch')return {category:'build',label:'Editing files',completed:false}; + return null; +} diff --git a/packages/kitchen/src/connectors/events.mjs b/packages/kitchen/src/connectors/events.mjs new file mode 100644 index 0000000..5bfbc71 --- /dev/null +++ b/packages/kitchen/src/connectors/events.mjs @@ -0,0 +1,93 @@ +import { clean } from '../runtime/crew.mjs'; +import path from 'node:path'; +import {desktopWork,directWork} from './desktop-events.mjs'; + +export const taskList=items=>Array.isArray(items)?items.slice(0,24).filter(t=>t&&typeof(t.content||t.step||t.subject||t.title)==='string'&&['pending','in_progress','completed'].includes(t.status)).map(t=>({...((typeof t.id==='string'||typeof t.id==='number')?{id:clean(String(t.id),100)}:{}),title:clean(t.content||t.step||t.subject||t.title,180),status:t.status})):undefined; + +// Only successful tool results update native tasks. No prompt/description fields survive. +function taskAcknowledgement(tool,input,text){ + if(tool==='TaskCreate'){ + const match=/^Task #(\d+) created successfully: [^\r\n]+$/.exec(text),title=clean(input.subject,180); + return match&&title?{taskChange:{id:match[1],title,status:'pending'}}:{}; + } + if(tool==='TaskUpdate'){ + const match=/^Updated task #(\d+) ((?:owner|status|subject|description|activeForm|metadata|blocks|blockedBy)(?:, (?:owner|status|subject|description|activeForm|metadata|blocks|blockedBy))*)$/.exec(text); + if(match&&match[1]===String(input.taskId)&&match[2].split(', ').includes('status')&&['pending','in_progress','completed','deleted'].includes(input.status))return {taskChange:{id:match[1],status:input.status,...(input.subject&&match[2].split(', ').includes('subject')?{title:clean(input.subject,180)}:{})}}; + } + return {}; +} +function taskResult(tool,input={},output={}){ + if(/^(TodoWrite|todo_write|write_todos)$/.test(tool||''))return {tasks:taskList(input.todos),tasksPartial:input.merge===true}; + if(output?.is_error||output?.isError||output?.error)return {}; + if(Array.isArray(output)||typeof output?.content==='string'||Array.isArray(output?.content)){ + output=Array.isArray(output)?output:output.content; + if(Array.isArray(output))output=output.length===1&&output[0]?.type==='text'?output[0].text:null; + } + if(typeof output==='string'){try{output=JSON.parse(output);}catch{return taskAcknowledgement(tool,input,output.trim());}} + if(!output||typeof output!=='object'||Array.isArray(output)||output.is_error||output.isError||output.error)return {}; + if(tool==='TaskList'&&Array.isArray(output.tasks))return {tasks:taskList(output.tasks)}; + const task=output.task||output,id=task.id||task.taskId||input.taskId; + if(!['TaskCreate','TaskUpdate','TaskGet'].includes(tool)||!['string','number'].includes(typeof id))return {}; + const title=clean(task.subject||input.subject||task.title,180),status=task.status||input.status||(tool==='TaskCreate'?'pending':undefined); + if(!['pending','in_progress','completed','deleted'].includes(status))return {}; + return {taskChange:{id:clean(String(id),100),...(title?{title}:{}),status}}; +} +export function normalizeHook(provider, raw, at=Date.now()) { + const name=raw.hook_event_name || ''; + const parent=provider==='cursor' ? raw.parent_conversation_id || raw.conversation_id || raw.session_id : raw.session_id; + const child=provider==='cursor' ? raw.subagent_id : raw.agent_id; + const sessionId=child || parent; + if(['subagentstart','subagentstop'].includes(name.toLowerCase())&&!child)return null; // Missing child identity must not end its parent. + const cwd=raw.cwd || raw.workspace_roots?.[0]; + if(typeof sessionId!=='string' || typeof cwd!=='string') return null; + const key=name.toLowerCase(); + const kind=({sessionstart:'session-start',sessionend:'session-end',userpromptsubmit:'turn-start',beforesubmitprompt:'turn-start',pretooluse:'tool-start',posttooluse:'tool-end',posttoolusefailure:'tool-end',permissionrequest:'permission',stop:'turn-end',subagentstart:'session-start',subagentstop:'turn-end',beforefileread:'tool-start',beforereadfile:'activity',afterfileedit:'activity',beforeshellexecution:'activity',aftershellexecution:'tool-end',aftermcpexecution:'activity'})[key]; + let notification=key==='notification' ? (raw.notification_type==='permission_prompt' ? 'permission' : raw.notification_type==='idle_prompt' ? 'input' : null) : null; + if(!kind&&!notification) return null; + return {provider,sessionId:clean(sessionId,200),parentId:child?clean(parent,200):null,cwd,at,source:'hook',kind:kind||notification,turnId:raw.generation_id || raw.turn_id,toolId:raw.tool_use_id || raw.tool_call_id,tool:clean(raw.tool_name || (key==='afterfileedit'?'Write':['beforefileread','beforereadfile'].includes(key)?'Read':['beforeshellexecution','aftershellexecution'].includes(key)?'Shell':''),80),file:clean(raw.tool_input?.file_path || raw.tool_input?.path || raw.file_path,500),...(key==='posttooluse'&&!['error','failed'].includes(raw.status)?taskResult(raw.tool_name,raw.tool_input,raw.tool_response??raw.tool_output):{}),error:key==='posttoolusefailure'||raw.status==='error'||raw.status==='failed',id:clean(raw.office_event_id,200)}; +} + +export function codexEvents(row, meta, fileId) { + const p=row.payload || {}, at=Date.parse(row.timestamp), id=`${fileId}:${row.ordinal ?? row.timestamp}:${p.type||row.type}:${p.id||p.call_id||''}`; + if(row.type==='event_msg'&&p.type==='task_started')meta.turnId=p.turn_id; + const base={provider:'codex',sessionId:meta.id,cwd:meta.cwd,parentId:meta.parentId,source:'log',at,id,turnId:p.turn_id||meta.turnId}; + if(row.type==='event_msg') { + if(p.type==='item_completed'){ + if(typeof p.item?.id!=='string'||(p.thread_id&&p.thread_id!==meta.id))return []; + const work=desktopWork(p.item);if(!work)return []; + if(work.file&&typeof p.item.cwd==='string'&&path.isAbsolute(p.item.cwd))work.file=path.resolve(p.item.cwd,work.file); + return [{...base,id:`${fileId}:item:${p.item.id}`,kind:'observation',work:{...work,completed:true},at:Number(p.completed_at_ms)||at}]; + } + const kind=({task_started:'turn-start',task_complete:'turn-end',turn_aborted:'interrupted'})[p.type]; + if(kind) return [{...base,kind}]; + } + if(row.type==='response_item') { + if(['function_call','custom_tool_call'].includes(p.type)) { + let args={}; try {args=JSON.parse(p.arguments || '{}');}catch{} + const patchFiles=typeof p.input==='string'&&/apply_patch$/.test(p.name||'')?[...p.input.matchAll(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/gm)].map(m=>m[1]):[]; + return [{...base,kind:'tool-start',tool:p.name,toolId:p.call_id,work:directWork(p.name,p.namespace),file:args.file_path || args.path || (patchFiles.length===1?patchFiles[0]:undefined),tasks:/update_plan$/.test(p.name||'')?taskList(args.plan):undefined}]; + } + if(['function_call_output','custom_tool_call_output'].includes(p.type)) return [{...base,kind:'tool-end',toolId:p.call_id}]; + } + return []; +} + +export function claudeEvents(row,fileId,context={pending:new Map()}) { + if(row.isSidechain) return []; // child identity is supplied by hooks; never guess it from the parent log. + const base={provider:'claude',sessionId:row.sessionId || row.session_id,cwd:row.cwd,source:'log',at:Date.parse(row.timestamp),id:`${fileId}:${row.uuid}`}; + if(!base.sessionId || !base.cwd || !row.uuid) return []; + if(row.type==='system' && row.subtype==='turn_duration') return [{...base,kind:'turn-end'}]; + const content=row.message?.content; + if(row.type==='user' && typeof content==='string') return [{...base,kind:'turn-start'}]; + if(!Array.isArray(content)) return []; + const events=[]; + content.forEach((c,i)=>{ + const e={...base,id:`${base.id}:${i}`}; + if(c.type==='tool_use') events.push({...e,kind:'tool-start',tool:c.name,toolId:c.id,file:c.input?.file_path || c.input?.path,tasks:c.name==='TodoWrite'?taskList(c.input?.todos):undefined}); + if(c.type==='tool_use'&&['TaskCreate','TaskUpdate','TaskGet','TaskList'].includes(c.name)){context.pending.set(c.id,{name:c.name,input:{taskId:c.input?.taskId,subject:clean(c.input?.subject,180),status:c.input?.status}});if(context.pending.size>64)context.pending.delete(context.pending.keys().next().value);} + if(c.type==='tool_result'){const pending=context.pending.get(c.tool_use_id);context.pending.delete(c.tool_use_id);events.push({...e,kind:'tool-end',toolId:c.tool_use_id,error:!!c.is_error,...(pending&&!c.is_error?taskResult(pending.name,pending.input,c.content):{})});} + }); + if(row.type==='user' && content.some(c=>c.type==='text') && !content.some(c=>c.type==='tool_result')) events.push({...base,kind:'turn-start'}); + if(row.type==='assistant' && row.message?.stop_reason==='end_turn') events.push({...base,id:base.id+':end',kind:'turn-end'}); + return events; +} diff --git a/packages/kitchen/src/connectors/logs.mjs b/packages/kitchen/src/connectors/logs.mjs new file mode 100644 index 0000000..149c7ab --- /dev/null +++ b/packages/kitchen/src/connectors/logs.mjs @@ -0,0 +1,68 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { codexEvents, claudeEvents } from './events.mjs'; + +const LIMIT=2*1024*1024,DAY=86400_000; +async function entries(dir){try{return await fs.readdir(dir,{withFileTypes:true});}catch{return [];}} +export class LogObserver { + constructor(home,store,{codexHome=path.join(home,'.codex'),claudeHome=path.join(home,'.claude')}={}){ + this.home=home;this.codexHome=codexHome;this.claudeHome=claudeHome;this.store=store;this.files=new Map();this.available={codex:false,claude:false};this.lastDiscovery=0;this.recentProjects=[];this.limited=false; + } + async metadata(file,provider){ + let handle;try{ + handle=await fs.open(file,'r');const buffer=Buffer.alloc(65536),{bytesRead}=await handle.read(buffer,0,buffer.length,0); + for(const line of buffer.toString('utf8',0,bytesRead).split('\n')){ + let row;try{row=JSON.parse(line);}catch{continue;} + const m=provider==='codex'&&row.type==='session_meta'?row.payload:provider==='claude'&&typeof row.cwd==='string'?row:null; + if(!m||typeof m.cwd!=='string')continue; + const canonical=await fs.realpath(m.cwd).catch(()=>null);if(!canonical)return null;const cwd=this.store.rootFor(m.cwd)?m.cwd:canonical; + return {id:m.session_id||m.sessionId||m.id,cwd,parentId:m.source?.subagent?.thread_spawn?.parent_thread_id}; + } + }catch{}finally{await handle?.close();}return null; + } + async discover(){ + const candidates=[];this.limited=false; + // Resumed sessions remain in the directory of their original creation date. + const base=path.join(this.codexHome,'sessions'); + for(const y of (await entries(base)).filter(e=>e.isDirectory()&&/^\d{4}$/.test(e.name)))for(const m of (await entries(path.join(base,y.name))).filter(e=>e.isDirectory()&&/^\d{2}$/.test(e.name)))for(const d of (await entries(path.join(base,y.name,m.name))).filter(e=>e.isDirectory()&&/^\d{2}$/.test(e.name))){ + for(const f of await entries(path.join(base,y.name,m.name,d.name)))if(f.isFile()&&f.name.endsWith('.jsonl')){if(candidates.length>=10000){this.limited=true;break;}candidates.push({file:path.join(base,y.name,m.name,d.name,f.name),provider:'codex'});} + } + const claude=path.join(this.claudeHome,'projects'); + for(const dir of (await entries(claude)).filter(e=>e.isDirectory()).slice(0,1000))for(const f of await entries(path.join(claude,dir.name)))if(f.isFile()&&f.name.endsWith('.jsonl')){if(candidates.length>=12000){this.limited=true;break;}candidates.push({file:path.join(claude,dir.name,f.name),provider:'claude'});} + const recent=[];for(const c of candidates){try{const stat=await fs.stat(c.file);this.available[c.provider]=true;if(Date.now()-stat.mtimeMsb.stat.mtimeMs-a.stat.mtimeMs).slice(0,240)){ + let f=this.files.get(c.file);const meta=f?.meta||await this.metadata(c.file,c.provider);if(!meta)continue; + if(!f)f={...c,offset:0,partial:'',meta,watched:false};else f.stat=c.stat; + found.push(f); + } + const projects=new Map();for(const f of found){const prior=projects.get(f.meta.cwd)||{path:f.meta.cwd,name:path.basename(f.meta.cwd),providers:[],lastSeenAt:0};if(!prior.providers.includes(f.provider))prior.providers.push(f.provider);prior.lastSeenAt=Math.max(prior.lastSeenAt,f.stat.mtimeMs);projects.set(prior.path,prior);} + this.recentProjects=[...projects.values()].sort((a,b)=>b.lastSeenAt-a.lastSeenAt).slice(0,24); + // Keep watched streams ahead of discovery-only candidates under the read limit. + found.sort((a,b)=>Number(!!this.store.rootFor(b.meta.cwd))-Number(!!this.store.rootFor(a.meta.cwd))||b.stat.mtimeMs-a.stat.mtimeMs); + if(found.length>120||recent.length>240)this.limited=true; + this.files=new Map(found.slice(0,120).map(f=>[f.file,f])); + } + async poll(){ + if(Date.now()-this.lastDiscovery>5000){this.lastDiscovery=Date.now();await this.discover();} + for(const f of [...this.files.values()].sort((a,b)=>a.stat.mtimeMs-b.stat.mtimeMs)){ + let handle; + try{ + const stat=await fs.stat(f.file);f.stat=stat;const wanted=!!this.store.rootFor(f.meta.cwd); + if(wanted&&!f.watched){f.offset=0;f.partial='';f.eventState=undefined;}f.watched=wanted; + if(!wanted){f.offset=stat.size;continue;} // Only metadata discovery outside selected roots. + if(stat.size===f.offset)continue; + handle=await fs.open(f.file,'r');if(stat.sizeLIMIT;if(skipped){start=stat.size-LIMIT;f.partial='';} + const buf=Buffer.alloc(stat.size-start);await handle.read(buf,0,buf.length,start); + let input=f.partial+buf.toString('utf8');if(skipped)input=input.slice(input.indexOf('\n')+1); + const lines=input.split('\n');f.partial=lines.pop();f.offset=stat.size;if(f.partial.length>LIMIT)f.partial=''; + for(const line of lines){ + let row;try{row=JSON.parse(line);}catch{continue;} + const evs=f.provider==='codex'?codexEvents(row,f.meta,path.basename(f.file)):claudeEvents(row,path.basename(f.file),f.eventState||=( {pending:new Map()} )); + for(const ev of evs){if(ev.cwd===f.meta.cwd||f.provider==='codex')this.store.accept(ev);else{const canonical=await fs.realpath(ev.cwd).catch(()=>null);if(canonical===f.meta.cwd)this.store.accept({...ev,cwd:canonical});}} + } + }catch{this.files.delete(f.file);}finally{await handle?.close();} + } + } +} diff --git a/packages/kitchen/src/connectors/setup.mjs b/packages/kitchen/src/connectors/setup.mjs new file mode 100644 index 0000000..e47874f --- /dev/null +++ b/packages/kitchen/src/connectors/setup.mjs @@ -0,0 +1,30 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export const hookNames={claude:['SessionStart','SessionEnd','UserPromptSubmit','PreToolUse','PostToolUse','PostToolUseFailure','PermissionRequest','Notification','Stop','SubagentStart','SubagentStop'],cursor:['sessionStart','sessionEnd','beforeSubmitPrompt','preToolUse','postToolUse','postToolUseFailure','stop','subagentStart','subagentStop','beforeReadFile','afterFileEdit','beforeShellExecution','afterShellExecution','afterMCPExecution']}; +const quote=s=>"'"+s.replace(/'/g,"'\\''")+"'"; +export function configPath(root,provider){return path.join(root,provider==='claude'?'.claude/settings.local.json':'.cursor/hooks.json');} +export function commandFor(node,relay,provider,stateDir){return `${quote(node)} ${quote(relay)} ${provider} ${quote(stateDir)}`;} +export async function hookConfig(root,provider,command,remove=false){ + if(!hookNames[provider])throw new Error('Choose Claude or Cursor.'); + const file=configPath(root,provider);let config={}; + try{config=JSON.parse(await fs.readFile(file,'utf8'));}catch(e){if(e.code!=='ENOENT')throw new Error('The existing settings file is not valid JSON. Repair it before connecting.');} + if(!config||typeof config!=='object'||Array.isArray(config))throw new Error('The existing settings must be a JSON object.'); + const before=JSON.stringify(config,null,2);config=structuredClone(config); + if(config.hooks && (typeof config.hooks!=='object'||Array.isArray(config.hooks)))throw new Error('The existing hooks configuration is invalid.'); + config.hooks ||= {}; + for(const event of hookNames[provider]){ + const values=config.hooks[event]||[];if(!Array.isArray(values))throw new Error('The existing hook list is invalid.'); + const next=values.map(v=>provider==='claude'&&Array.isArray(v.hooks)?{...v,hooks:v.hooks.filter(h=>h.command!==command)}:v) + .filter(v=>provider==='claude' ? !Array.isArray(v.hooks)||v.hooks.length>0 : v.command!==command); + if(!remove)next.push(provider==='claude'?{hooks:[{type:'command',command,timeout:2}]}:{command,timeout:2}); + if(next.length)config.hooks[event]=next;else delete config.hooks[event]; + } + if(provider==='cursor'&&!remove)config.version ||= 1; + return {file,before,after:JSON.stringify(config,null,2)+'\n',events:hookNames[provider],remove}; +} +export async function installConfig(change){ + await fs.mkdir(path.dirname(change.file),{recursive:true}); + const tmp=change.file+'.office-tmp'; + await fs.writeFile(tmp,change.after,{mode:0o600});await fs.rename(tmp,change.file); +} diff --git a/packages/kitchen/src/runtime/crew.mjs b/packages/kitchen/src/runtime/crew.mjs new file mode 100644 index 0000000..478bc41 --- /dev/null +++ b/packages/kitchen/src/runtime/crew.mjs @@ -0,0 +1,100 @@ +import path from 'node:path'; + +export const clean = (v, max = 180) => typeof v === 'string' ? v.replace(/[\x00-\x1f]/g, ' ').slice(0, max) : ''; +export const within = (file, root) => file === root || file.startsWith(root + path.sep); +export function activityFor(tool = '') { + if(/(?:^|\.)(?:update_plan|TodoWrite|TaskCreate|TaskUpdate|TaskList|TaskGet|todo_write|write_todos)$/i.test(tool))return 'working'; + if(/^(?:functions\.)?exec$/.test(tool))return 'working'; + if (/read|grep|glob|search|list|find|web|browse/i.test(tool)) return 'reading'; + if (/write|edit|patch|replace|notebook/i.test(tool)) return 'writing'; + if (/exec|shell|bash|terminal|command/i.test(tool)) return 'executing'; + return 'working'; +} + +export class CrewStore { + constructor(roots, clock = Date.now) { this.roots = roots; this.clock = clock; this.sessions = new Map(); this.seen = new Set(); } + rootFor(cwd) { return typeof cwd==='string'?this.roots.filter(r => within(cwd, r)).sort((a,b) => b.length-a.length)[0]:undefined; } + accept(event) { + const project = this.rootFor(event.cwd || ''); + const kinds=['session-start','session-end','turn-start','turn-end','tool-start','tool-end','permission','input','interrupted','activity','unknown','role','observation']; + if (!project || !['claude','codex','cursor'].includes(event.provider) || typeof event.sessionId!=='string' || !event.sessionId || typeof event.id!=='string' || !event.id || !kinds.includes(event.kind)) return false; + const dedup = `${event.provider}:${event.id}`; + if (this.seen.has(dedup)) return false; + this.seen.add(dedup); if (this.seen.size > 4000) this.seen.delete(this.seen.values().next().value); + const at = Number(event.at) || this.clock(); + if (at > this.clock()+60_000 || at < this.clock()-24*3600_000) return false; + const id = `${event.provider}:${clean(event.sessionId,200)}`; + let s = this.sessions.get(id); + if(event.kind==='role'&&(!s||s.project!==project||at<(s.roleBindingAt||0)))return false; + if (!s) { + s = { id, sessionId: clean(event.sessionId,200), provider:event.provider, project, cwd:event.cwd, startedAt:at, lastEventAt:0, state:'unknown', source:event.source, parentId:event.parentId ? `${event.provider}:${clean(event.parentId,200)}` : null, recent:[], tools:new Map(), retiredTurns:new Set(), tool:'', file:null, fileAt:null, currentFile:null, turnId:null, ended:false }; + this.sessions.set(id,s); + } + // Events can arrive through a replay or a second observer. Older evidence must not rewind the crew. + if (at < s.lastEventAt&&event.kind!=='observation') return false; + if (s.source === 'hook' && event.source !== 'hook' && at - s.lastEventAt < 30_000) return false; + if(event.turnId&&s.retiredTurns.has(event.turnId))return false; + const recordWork=()=>{ + const w=event.work;if(!w||!['coordinate','research','build','simulation','review','publish','execute'].includes(w.category))return; + const f=typeof w.file==='string'?path.resolve(event.cwd,w.file):null; + const file=f&&within(f,project)?path.relative(project,f):null,label=w.label==='Read a file'&&file?'Read '+path.basename(file):w.label; + s.workContext={category:w.category,label:clean(label,120),file,at,completed:!!w.completed,toolId:event.toolId||null}; + return true; + }; + if(event.kind==='observation'){ + if(s.workContext?.at>at||at<(s.turnStartedAt||s.startedAt))return false; + if(!recordWork())return false; + if(!s.lastEventAt)s.lastEventAt=at; + s.workHistory=[s.workContext,...(s.workHistory||[])].slice(0,24); + s.recent.unshift({id:dedup,at,kind:'observation',tool:s.workContext.label,file:s.workContext.file,source:event.source});s.recent=s.recent.slice(0,12); + this.onChange?.(this.snapshot().find(item=>item.id===id));return true; + } + if(event.kind==='role'){ + if(event.roleId===null)s.roleBinding=null; + else if(typeof event.roleId==='string'&&/^[\w-]{1,100}$/.test(event.roleId))s.roleBinding={roleId:event.roleId,workflowId:clean(event.workflowId,100)||null,runId:clean(event.runId,100)||null,itemId:clean(event.itemId,180)||null,orderId:clean(event.orderId,100)||null,at}; + else return false; + s.roleBindingAt=at; + this.onChange?.(this.snapshot().find(item=>item.id===id)); + return true; + } + s.source = event.source; s.lastEventAt = at; s.project = project; + if (event.turnId && s.turnId !== event.turnId) { if(s.turnId)s.retiredTurns.add(s.turnId);if(s.retiredTurns.size>100)s.retiredTurns.delete(s.retiredTurns.values().next().value);s.tools.clear(); s.turnId = event.turnId; } + if (event.parentId) s.parentId = `${event.provider}:${clean(event.parentId,200)}`; + if(Array.isArray(event.tasks)){if(event.tasksPartial){const incomingIds=new Set(event.tasks.map(t=>t?.id).filter(Boolean));event={...event,tasks:[...(s.sessionTasks||[]).filter(t=>!incomingIds.has(t.id)),...event.tasks.filter(t=>t?.id)]};}if(event.planId&&event.planId!==s.planId){s.outcomeId=null;s.outcomeTitle=null;}s.sessionTasks=event.tasks.slice(0,24).filter(t=>typeof t?.title==='string'&&['pending','in_progress','completed'].includes(t.status)).map(t=>({...((typeof t.id==='string'||typeof t.id==='number')?{id:clean(String(t.id),100)}:{}),title:clean(t.title,180),status:t.status}));s.taskContextAt=at;s.planId=clean(event.planId,100)||s.planId||null;s.outcomeId=clean(event.outcomeId,100)||s.outcomeId||null;s.outcomeTitle=clean(event.outcomeTitle,180)||s.outcomeTitle||null;} + if(event.taskChange&&typeof event.taskChange.id==='string'){ + const change=event.taskChange,id=clean(change.id,100),prior=s.sessionTasks?.find(t=>t.id===id),title=clean(change.title,180)||prior?.title; + if(id&&['pending','in_progress','completed','deleted'].includes(change.status)&&(prior||title)){ + const next={id,title,status:change.status}; + s.sessionTasks=change.status==='deleted'?(s.sessionTasks||[]).filter(t=>t.id!==id):prior?s.sessionTasks.map(t=>t.id===id?next:t):[...(s.sessionTasks||[]),next].slice(-24);s.taskContextAt=at; + } + } + let observedFile=null; + if(event.file){const f=path.resolve(event.cwd,event.file);if(within(f,project)){observedFile=path.relative(project,f);s.file=observedFile;s.fileAt=at;}} + if (event.kind === 'session-start' || event.kind === 'turn-start') { s.state='working'; s.ended=false;s.workContext=null;s.turnStartedAt=at; } + else if (event.kind === 'tool-start') { + s.ended=false; s.tools.set(event.toolId || event.id, {name:clean(event.tool,80),file:observedFile});s.currentFile=observedFile; s.state=activityFor(event.tool); s.tool=clean(event.tool,80); + } else if (event.kind === 'tool-end') { + s.tools.delete(event.toolId); const current=[...s.tools.values()].at(-1); + s.state=event.error ? 'error' : current ? activityFor(current.name) : 'working'; s.tool=current?.name || clean(event.tool,80) || s.tool;s.currentFile=current?.file||null; + } else if (event.kind === 'permission') { s.state='permission'; } + else if (event.kind === 'input') { s.state='input'; } + else if (event.kind === 'turn-end') { s.tools.clear(); s.state=event.error ? 'error' : 'complete'; } + else if (event.kind === 'interrupted') { s.tools.clear(); s.state='interrupted'; } + else if (event.kind === 'session-end') { s.tools.clear(); s.state='offline'; s.ended=true; } + else if (event.kind === 'activity') { s.ended=false;s.state=activityFor(event.tool); s.tool=clean(event.tool,80);s.currentFile=observedFile; } + else if (event.kind === 'unknown') { s.state='unknown'; } + recordWork(); + if(event.kind==='tool-end'&&s.workContext&&s.workContext.toolId===event.toolId&&!s.workContext.completed)s.workContext={...s.workContext,completed:true}; + if(['session-start','turn-start','turn-end','session-end','interrupted','unknown'].includes(event.kind))s.currentFile=null; + s.recent.unshift({id:dedup,at,kind:event.kind,tool:clean(event.tool,80),file:event.file && s.file,source:event.source}); + s.recent=s.recent.slice(0,12); + if(this.sessions.size>160) this.sessions.delete([...this.sessions.values()].sort((a,b)=>a.lastEventAt-b.lastEventAt)[0].id); + this.onChange?.(this.snapshot().find(item=>item.id===id)); + return true; + } + snapshot() { + const now=this.clock(); + for(const [id,s] of this.sessions) if(now-s.lastEventAt>24*3600_000) this.sessions.delete(id); + return [...this.sessions.values()].map(({tools,retiredTurns,...s})=>({...s, activeToolCount:tools.size, freshness:now-s.lastEventAt > 120_000 ? 'quiet' : 'recent'})).sort((a,b)=>b.lastEventAt-a.lastEventAt); + } +} diff --git a/packages/kitchen/src/runtime/orders.mjs b/packages/kitchen/src/runtime/orders.mjs new file mode 100644 index 0000000..dcc3c50 --- /dev/null +++ b/packages/kitchen/src/runtime/orders.mjs @@ -0,0 +1,78 @@ +import crypto from 'node:crypto'; +import {roleForSession} from './workflow-crew.mjs'; + +const key=value=>crypto.createHash('sha256').update(value).digest('hex').slice(0,20); +const active=s=>!/(?:^|\.)(?:update_plan|TodoWrite|TaskCreate|TaskUpdate|TaskList|TaskGet|todo_write|write_todos)$/i.test(s.tool||'')&&!s.ended&&s.freshness!=='quiet'&&['reading','writing','executing','working','permission','input','error'].includes(s.state); +const chefId=(p,s,match)=>match?`role:${p.id}:${p.workflow.id}:${match.role.id}`:s.id; + +// A native todo is a work order. This store observes source plans, never writes one. +export class OrderStore { + constructor(){this.plans=new Map();this.orders=new Map();this.serial=0;} + observe(projects,sessions){ + for(const s of sessions){ + const p=projects.find(p=>p.id===s.project);if(!p)continue; + const scope=JSON.stringify([s.project,s.id,s.planId||'current',s.taskSource||s.source]); + let plan=this.plans.get(scope); + if(s.planAvailable){ + const tasks=s.sessionTasks||[],signature=JSON.stringify(tasks); + if(!plan){plan={scope,project:p.id,sessionId:s.id,signature:null,orderIds:[],revision:0,serial:0};this.plans.set(scope,plan);} + if(plan.signature!==signature){ + const at=s.contextAt||s.taskContextAt||s.lastEventAt; + plan.signature=signature;plan.revision++;plan.at=at; + // Only explicit native IDs or unique unchanged titles can preserve identity. + const titleCounts=new Map(),idCounts=new Map();for(const t of tasks){titleCounts.set(t.title,(titleCounts.get(t.title)||0)+1);if(t.id)idCounts.set(t.id,(idCounts.get(t.id)||0)+1);} + const prior=plan.orderIds.map(id=>this.orders.get(id)).filter(Boolean),next=[]; + tasks.forEach((t,index)=>{ + const nativeId=t.id&&idCounts.get(t.id)===1?t.id:null; + const candidates=(nativeId?[...this.orders.values()].filter(o=>o.scope===scope):prior).filter(o=>nativeId?o.nativeId===nativeId:!o.nativeId&&o.title===t.title&&titleCounts.get(t.title)===1); + let o=candidates.length===1?candidates[0]:null; + if(!o){const id='order-'+key(scope+':'+(++plan.serial));o={id,number:++this.serial,project:p.id,sessionId:s.id,provider:s.provider,scope,nativeId,title:t.title,status:t.status,createdAt:at,completedAt:null,completionVersion:0,contributors:[],history:[],source:s.taskSource||s.source};this.orders.set(id,o);} + const changed=o.title!==t.title||o.status!==t.status||o.withdrawn||!o.history.length; + if(changed){o.history.push({revision:plan.revision,at,title:t.title,status:t.status});o.history=o.history.slice(-24);} + if(t.status==='completed'&&(o.status!=='completed'||!o.completedAt)){o.completedAt=at;o.completionVersion++;} + if(t.status!=='completed')o.completedAt=null; + Object.assign(o,{title:t.title,status:t.status,withdrawn:false,index,revision:plan.revision,updatedAt:at,outcomeId:s.outcomeId||null,outcomeTitle:s.outcomeTitle||null});next.push(o.id); + }); + for(const o of prior)if(!next.includes(o.id)&&!o.withdrawn){o.withdrawn=true;o.updatedAt=at;o.history.push({revision:plan.revision,at,title:o.title,status:'withdrawn'});o.history=o.history.slice(-24);} + plan.orderIds=next; + // An explicitly replaced plan retires its remaining tickets, never serves them. + for(const old of this.plans.values())if(old!==plan&&old.project===p.id&&old.sessionId===s.id){for(const id of old.orderIds){const o=this.orders.get(id);if(o&&!o.withdrawn){o.withdrawn=true;o.history.push({at,status:'withdrawn',title:o.title,revision:o.revision});o.history=o.history.slice(-24);}}old.orderIds=[];} + } + } + if(!active(s))continue; + const bound=s.roleBinding?.orderId; + let order=bound?this.orders.get(bound):null; + if(bound&&(!order||order.project!==p.id))continue; + if(!bound){const current=(plan?.orderIds||[]).map(id=>this.orders.get(id)).filter(o=>o&&!o.withdrawn&&o.status==='in_progress');if(current.length===1)order=current[0];} + if(!order||order.withdrawn||order.status==='completed')continue; + const match=roleForSession(p,s),id=chefId(p,s,match),at=Math.max(s.lastEventAt||0,s.roleBinding?.at||0); + const identity=id+'|'+s.id,prior=order.contributors.find(c=>c.identity===identity); + const entry={identity,chefId:id,roleId:match?.role.id||null,name:match?.role.title||s.provider,sessionId:s.id,provider:s.provider,association:match?.association||{kind:'unknown',reason:'Workflow role not linked'},firstAt:prior?.firstAt||at,lastAt:at}; + if(prior)Object.assign(prior,entry);else order.contributors.push(entry); + order.contributors=order.contributors.slice(-24); + } + while(this.orders.size>600)this.orders.delete(this.orders.keys().next().value); + while(this.plans.size>160)this.plans.delete(this.plans.keys().next().value); + } + snapshot(projects,sessions){ + this.observe(projects,sessions); + const orders=[...this.orders.values()].map(o=>({...o,contributors:o.contributors.map(c=>({...c})),history:[...o.history],activeChefIds:[],activeSessionIds:[]})); + const byId=new Map(orders.map(o=>[o.id,o])),unplanned=[]; + for(const s of sessions){ + const p=projects.find(p=>p.id===s.project);if(!p)continue; + const candidates=orders.filter(o=>o.project===s.project&&o.sessionId===s.id&&!o.withdrawn&&o.status==='in_progress'); + const order=s.roleBinding?.orderId?byId.get(s.roleBinding.orderId):candidates.length===1?candidates[0]:null; + if(active(s)&&order&&order.project===p.id&&!order.withdrawn&&order.status!=='completed'){ + const match=roleForSession(p,s);order.activeChefIds.push(chefId(p,s,match));order.activeSessionIds.push(s.id);if(['permission','input','error'].includes(s.state))order.attention=true; + } + if(!s.ended&&(!s.planAvailable||!(s.sessionTasks||[]).length))unplanned.push({id:'unplanned-'+key(s.id+s.project),project:p.id,sessionId:s.id,title:s.outcomeTitle||'Current work',state:s.state,source:s.taskSource||s.source,progress:null}); + } + const tables=[]; + for(const p of projects){ + const local=orders.filter(o=>o.project===p.id); + const groups=new Map();for(const o of local){const id=o.outcomeId?'table-'+key(p.id+o.outcomeId):o.outcomeTitle?'table-'+key(o.scope+o.outcomeTitle):'table-'+key(p.id);o.tableId=id;if(!groups.has(id))groups.set(id,{id,project:p.id,title:o.outcomeTitle||p.name,reported:!!o.outcomeTitle,orderIds:[],completedIds:[]});const table=groups.get(id);table.orderIds.push(o.id);if(o.status==='completed'&&!o.withdrawn)table.completedIds.push(o.id);} + if(!groups.size)groups.set('table-'+key(p.id),{id:'table-'+key(p.id),project:p.id,title:p.name,reported:false,orderIds:[],completedIds:[]});tables.push(...groups.values()); + } + return {orders,tables,unplanned}; + } +} diff --git a/packages/kitchen/src/runtime/plates.mjs b/packages/kitchen/src/runtime/plates.mjs new file mode 100644 index 0000000..0984b48 --- /dev/null +++ b/packages/kitchen/src/runtime/plates.mjs @@ -0,0 +1,38 @@ +import path from 'node:path'; +import { clean,within } from './crew.mjs'; + +const providers=['claude','codex','cursor']; +const identity=value=>typeof value==='string'&&value.length>0&&value.length<=220&&/^[\w.:-]+$/.test(value); +export class PlateStore { + constructor(crew,clock=Date.now,findOrder=()=>null){this.findOrder=findOrder;this.crew=crew;this.clock=clock;this.artifacts=new Map();this.transfers=new Map();this.seen=new Set();} + accept(e){ + const project=this.crew.rootFor(e.cwd),at=this.clock(); + if(!project||!identity(e.id)||!identity(e.artifactId)||!identity(e.revisionId)||!providers.includes(e.provider)||!identity(e.sessionId)||!['produced','offered','received','failed'].includes(e.kind))return false; + const eventKey=`${project}:${e.provider}:${e.id}`;if(this.seen.has(eventKey))return false; + const producer=`${e.provider}:${e.sessionId}`,key=JSON.stringify([project,e.artifactId,e.revisionId]); + const knownProducer=this.crew.sessions.get(producer);if(knownProducer&&knownProducer.project!==project)return false; + let file=null;if(e.file){if(typeof e.file!=='string')return false;const absolute=path.resolve(e.cwd,e.file);if(!within(absolute,project))return false;file=path.relative(project,absolute);} + const prior=this.artifacts.get(key);if(prior&&prior.producer!==producer)return false; + if(e.orderId&&(!identity(e.orderId)||this.findOrder(e.orderId)?.project!==project||prior&&prior.orderId!==e.orderId))return false; + const orderId=prior?.orderId||e.orderId||null; + let transfer=null; + if(e.kind!=='produced'){ + if(!identity(e.handoffId)||!providers.includes(e.recipientProvider)||!identity(e.recipientSessionId))return false; + const recipient=`${e.recipientProvider}:${e.recipientSessionId}`; + const known=this.crew.sessions.get(recipient);if(known&&known.project!==project)return false; + const transferKey=JSON.stringify([project,e.handoffId]),old=this.transfers.get(transferKey); + if(old&&(old.artifactKey!==key||old.sender!==producer||old.recipient!==recipient))return false; + if(old&&old.state!=='offered')return false; + transfer={id:transferKey,handoffId:e.handoffId,project,artifactKey:key,sender:producer,recipient,state:e.kind,at,source:'explicit relay',eventId:e.id,orderId,senderRoleId:prior?.producerRoleId||null,recipientRoleId:known?.roleBinding?.roleId||null}; + } + this.seen.add(eventKey);if(this.seen.size>4000)this.seen.delete(this.seen.values().next().value); + const kind=['json','image','text','code','table'].includes(e.type)?e.type:'unknown'; + this.artifacts.set(key,{id:key,artifactId:e.artifactId,revisionId:e.revisionId,project,producer,producerRoleId:prior?prior.producerRoleId:e.kind==='produced'?knownProducer?.roleBinding?.roleId||null:null,orderId,kind:prior?.kind||kind,file:prior?.file||file,label:prior?.label||clean(e.label)||file||'Shared artifact',at:prior?.at||at,source:'explicit relay',eventId:prior?.eventId||e.id}); + if(transfer)this.transfers.set(transfer.id,transfer); + while(this.artifacts.size>200)this.artifacts.delete(this.artifacts.keys().next().value); + for(const [id,t] of this.transfers)if(!this.artifacts.has(t.artifactKey))this.transfers.delete(id); + while(this.transfers.size>400)this.transfers.delete(this.transfers.keys().next().value); + return true; + } + snapshot(){return {artifacts:[...this.artifacts.values()],transfers:[...this.transfers.values()]};} +} diff --git a/packages/kitchen/src/runtime/workflow-crew.mjs b/packages/kitchen/src/runtime/workflow-crew.mjs new file mode 100644 index 0000000..8017131 --- /dev/null +++ b/packages/kitchen/src/runtime/workflow-crew.mjs @@ -0,0 +1,55 @@ +const working=s=>s.freshness!=='quiet'&&['reading','writing','executing','working'].includes(s.state); +const urgent=s=>['permission','input','error'].includes(s.state); +const normalized=s=>(s||'').toLowerCase().replace(/[^\p{L}\p{N}]+/gu,' ').trim(); + +export function roleForSession(project,session){ + const roles=project.workflow?.roles||[]; + if(session.roleBinding){ + if(session.roleBinding.workflowId&&session.roleBinding.workflowId!==project.workflow?.id)return null; + const role=roles.find(r=>r.id===session.roleBinding.roleId); + return role?{role,association:{kind:'explicit',source:'role binding',reason:'Workflow explicitly identified this role',componentId:role.components.includes(session.component?.id)?session.component.id:role.components.length===1?role.components[0]:null}}:null; + } + const task=normalized(session.currentTask?.title); + const components=task?project.components.filter(c=>c.tasks.some(t=>normalized(t.title)===task)):[]; + if(components.length===1){const matches=roles.filter(r=>r.components.includes(components[0].id));if(matches.length===1)return {role:matches[0],association:{kind:'inferred',source:'task',reason:'Current session task matches this project responsibility',componentId:components[0].id}};} + if(session.component){const matches=roles.filter(r=>r.components.includes(session.component.id));if(matches.length===1)return {role:matches[0],association:session.association||{kind:'inferred',source:'component',reason:'Based on observed component context'}};} + const work=session.workContext; + if(project.workflow?.origin){ + const infer=(role,reason)=>role?{role,association:{kind:'inferred',source:work?'observed operation':'tool activity',reason,componentId:null}}:null; + const currentFile=session.currentFile,observedFile=currentFile||work?.file; + if(observedFile){const matches=roles.filter(r=>(r.files||[]).some(pattern=>matchesGlob(observedFile,pattern)));if(matches.length===1)return infer(matches[0],`Based on ${currentFile?'the current file':'the last observed file'}: ${observedFile}`);} + const category=work?.category||(/read|search|grep|glob|find|web|list/i.test(session.tool||'')?'research':/write|edit|patch/i.test(session.tool||'')?'build':null); + if(category){const matches=roles.filter(r=>r.category===category);if(matches.length===1)return infer(matches[0],work?`Based on the ${work.completed?'last completed':'observed'} operation: ${work.label}`:'Based on the observed tool');} + const coordinator=roles.find(r=>r.category==='coordinate'); + return infer(coordinator,'The session is active here; its specialist responsibility is not yet known'); + } + return null; +} + +export function workflowCrew(projects,sessions){ + return projects.flatMap(p=>{ + const local=sessions.filter(s=>s.project===p.id);if(!p.workflow?.roles?.length)return local; + const assignments=new Map(p.workflow.roles.map(r=>[r.id,[]])),unlinked=[]; + for(const s of local){if(s.ended)continue;const match=roleForSession(p,s);if(match)assignments.get(match.role.id).push({...s,association:match.association});else unlinked.push({...s,unlinkedRole:true});} + const roles=p.workflow.roles.map(r=>{ + const executors=assignments.get(r.id).sort((a,b)=>Number(urgent(b))-Number(urgent(a))||Number(working(b))-Number(working(a))||b.lastEventAt-a.lastEventAt),primary=executors[0]; + const component=p.components.find(c=>c.id===r.components[0]); + const queued=p.workflow.queue?.items.filter(i=>r.components.includes(i.stage))||[]; + const recentWork=local.flatMap(s=>(s.workHistory||[]).filter(work=>roleForSession(p,{...s,roleBinding:null,component:null,currentTask:null,currentFile:null,workContext:work})?.role.id===r.id).map(work=>({...work,sessionId:s.sessionId,provider:s.provider}))).sort((a,b)=>b.at-a.at)[0]; + return {...primary,id:`role:${p.id}:${p.workflow.id}:${r.id}`,sessionId:primary?.sessionId||'',roleId:r.id,name:r.title,project:p.id,roleComponents:r.components,component:component?{id:component.id,title:component.title}:null, + roleOrigin:r.origin||'configured',roleDescription:r.description||'',roleFiles:r.files||[],recentWork, + provider:primary?.provider||null,state:primary?.state||'idle',source:primary?.source||'workflow',freshness:primary?.freshness||'recent',ended:false, + lastEventAt:primary?.lastEventAt||null,startedAt:0,recent:primary?.recent||[],currentTask:primary?.currentTask||null,executors, + activityComponentId:primary?.association?.componentId||(r.components.includes(primary?.component?.id)?primary.component.id:null), + roleStatus:queued.length?`${queued.length} ${queued.length===1?'item':'items'} ${queued.every(i=>i.label==='Needs revision')?'to revise':'waiting'}`:recentWork?`Last: ${recentWork.label}`:'Ready for the next task',queueCount:queued.length, + association:primary?.association||{kind:'configured',source:'project map',reason:'Persistent project responsibility; no session assigned'}, + workingCount:executors.filter(working).length}; + }); + return [...roles,...unlinked]; + }); +} + +export function workflowPlates(projects){ + return projects.flatMap(p=>(p.workflow?.queue?.items||[]).map(i=>({id:`workflow:${p.id}:${i.id}`,artifactId:i.id,revisionId:i.revision,project:p.id,kind:'text',file:i.file,label:i.title,componentId:i.stage,producer:null,source:'workflow files',workflowItem:i,at:null}))); +} +import {matchesGlob} from '../agenttrail/projects.mjs'; diff --git a/packages/kitchen/src/server.mjs b/packages/kitchen/src/server.mjs new file mode 100644 index 0000000..6a83dc5 --- /dev/null +++ b/packages/kitchen/src/server.mjs @@ -0,0 +1,100 @@ +import http from 'node:http'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { CrewStore } from './runtime/crew.mjs'; +import {OrderStore} from './runtime/orders.mjs'; +import { PlateStore } from './runtime/plates.mjs'; +import { LogObserver } from './connectors/logs.mjs'; +import { Projects } from './agenttrail/projects.mjs'; +import {workflowCrew,workflowPlates} from './runtime/workflow-crew.mjs'; +import { hookConfig,installConfig,commandFor,configPath } from './connectors/setup.mjs'; + +const appRoot=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +const hash=s=>crypto.createHash('sha256').update(s).digest('hex'); +export async function startOffice({roots,home,stateDir,port=4780,observe=true}) { + await fs.mkdir(stateDir,{recursive:true,mode:0o700}); + const csrf=crypto.randomBytes(24).toString('hex'),hookToken=crypto.randomBytes(24).toString('hex'); + const store=new CrewStore(roots),logs=new LogObserver(home,store,home===os.homedir()?{codexHome:process.env.CODEX_HOME||undefined,claudeHome:process.env.CLAUDE_CONFIG_DIR||undefined}:{}),projects=new Projects(roots,home,store); + const orders=new OrderStore(),plates=new PlateStore(store,Date.now,id=>orders.orders.get(id)); + store.onChange=s=>{if(s)orders.observe(projects.snapshot(),projects.enrich([s]));}; + let actualPort=port,closing=false,busy=false,lastProjects=0,lastMessage='';const clients=new Set(); + const setupCommands=Object.fromEntries(['claude','cursor'].map(p=>[p,commandFor(process.execPath,path.join(appRoot,'bin/relay.mjs'),p,stateDir)])); + let installed={}; + async function refreshInstalled(){ + const next={};for(const root of roots){next[root]={};for(const provider of ['claude','cursor']){try{const config=JSON.parse(await fs.readFile(configPath(root,provider),'utf8'));next[root][provider]=Object.values(config.hooks||{}).flat().some(entry=>entry.command===setupCommands[provider]||entry.hooks?.some(h=>h.command===setupCommands[provider]));}catch{next[root][provider]=false;}}}installed=next; + } + const snapshot=()=>{const maps=projects.snapshot(),executors=projects.enrich(store.snapshot()),ledger=plates.snapshot();return {app:'agenttrail-kitchen',version:2,recentProjects:logs.recentProjects,discoveryLimited:logs.limited,projects:maps,crew:workflowCrew(maps,executors),executors,...orders.snapshot(maps,executors),...ledger,artifacts:[...ledger.artifacts,...workflowPlates(maps)],installed,observers:{codex:{available:logs.available.codex,mode:'experimental logs'},claude:{available:logs.available.claude,mode:'hooks or logs'},cursor:{mode:'hooks'}},observing:observe};}; + async function tick(){if(busy||closing)return;busy=true;try{ + if(Date.now()-lastProjects>3000){lastProjects=Date.now();await projects.poll();await refreshInstalled();} + if(observe)await logs.poll(); + const msg=JSON.stringify(snapshot());if(msg!==lastMessage){lastMessage=msg;for(const c of clients){if(c.writableLength>256_000){c.destroy();clients.delete(c);}else c.write(`data: ${msg}\n\n`);}} + }finally{busy=false;}} + async function addProjects(paths){ + if(!Array.isArray(paths)||!paths.length||paths.length>12)throw new Error('Choose one or more project folders.'); + const selected=[]; + for(const value of paths){if(typeof value!=='string'||!path.isAbsolute(value))throw new Error('Use an absolute project folder path.');let root;try{root=await fs.realpath(value);if(!(await fs.stat(root)).isDirectory())throw 0;}catch{throw new Error('That folder could not be found.');}if(!selected.includes(root))selected.push(root);} + const next=[...new Set([...roots,...selected])];if(next.length>12)throw new Error('Up to 12 project folders can be watched.'); + roots.splice(0,roots.length,...next);await fs.writeFile(path.join(stateDir,'projects.json'),JSON.stringify(roots),{mode:0o600}); + // Replay available recent observations immediately for newly selected roots. + lastProjects=0;logs.lastDiscovery=0;await projects.poll();await tick(); + return selected; + } + const json=(res,status,data)=>{res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(data));}; + async function body(req){let raw='';for await(const c of req){raw+=c;if(raw.length>32_000)throw new Error('Request is too large.');}return JSON.parse(raw||'{}');} + const server=http.createServer(async(req,res)=>{ + const origin=`http://127.0.0.1:${actualPort}`; + res.setHeader('x-content-type-options','nosniff');res.setHeader('referrer-policy','no-referrer'); + const hosts=[`127.0.0.1:${actualPort}`,`localhost:${actualPort}`]; + if(!hosts.includes(req.headers.host))return json(res,403,{error:'Local connections only.'}); + if(req.headers.origin&&!hosts.map(h=>'http://'+h).includes(req.headers.origin))return json(res,403,{error:'Origin is not allowed.'}); + let u;try{u=new URL(req.url,origin);}catch{return json(res,400,{error:'Invalid URL.'});} + try{ + if(u.pathname==='/api/attach'){ + if(req.method!=='POST'||req.headers.authorization!==`Bearer ${hookToken}`)return json(res,403,{error:'Invalid connector key.'}); + const data=await body(req),selected=await addProjects(data.projects);return json(res,200,{app:'agenttrail-kitchen',projects:selected}); + } + if(u.pathname==='/api/hook'||u.pathname==='/api/artifact'){ + if(req.method!=='POST'||req.headers.authorization!==`Bearer ${hookToken}`)return json(res,403,{error:'Invalid connector key.'}); + const event=await body(req);event.source='hook';event.at=Date.now(); + const accepted=u.pathname==='/api/artifact'?plates.accept(event):store.accept(event);await tick();return json(res,200,{accepted}); + } + if(req.method==='POST'){ + if(req.headers['x-office-token']!==csrf)return json(res,403,{error:'Reload the office before changing settings.'}); + const data=await body(req); + if(u.pathname==='/api/projects'){ + const [id]=await addProjects([data.path]);return json(res,200,{id}); + } + if(u.pathname==='/api/setup/preview'||u.pathname==='/api/setup/apply'){ + if(!roots.includes(data.project)||!setupCommands[data.provider])return json(res,400,{error:'Choose a watched project and provider.'}); + const change=await hookConfig(data.project,data.provider,setupCommands[data.provider],!!data.remove); + if(u.pathname.endsWith('preview'))return json(res,200,{file:change.file,events:change.events,revision:hash(change.before),remove:change.remove}); + if(data.revision!==hash(change.before))return json(res,409,{error:'Settings changed. Review the connection again.'}); + await installConfig(change);await refreshInstalled();await tick();return json(res,200,{ok:true}); + } + return json(res,404,{error:'Unknown action.'}); + } + if(req.method!=='GET')return json(res,405,{error:'Method not allowed.'}); + if(u.pathname==='/api/bootstrap')return json(res,200,{token:csrf,...snapshot()}); + if(u.pathname==='/api/state')return json(res,200,snapshot()); + if(u.pathname==='/api/events'){ + res.writeHead(200,{'content-type':'text/event-stream','cache-control':'no-cache','connection':'keep-alive'});res.write(`data: ${JSON.stringify(snapshot())}\n\n`);clients.add(res);req.on('close',()=>clients.delete(res));return; + } + const name=u.pathname==='/'?'index.html':decodeURIComponent(u.pathname).slice(1); + const file=path.resolve(appRoot,'public',name); + if(!file.startsWith(path.join(appRoot,'public')+path.sep))return json(res,404,{error:'Not found.'}); + const mime={'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.webp':'image/webp','.woff2':'font/woff2','.glb':'model/gltf-binary'}[path.extname(file)];if(!mime)return json(res,404,{error:'Not found.'}); + const content=await fs.readFile(file); + res.setHeader('content-security-policy',"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"); + res.writeHead(200,{'content-type':mime,'cache-control':'no-cache'});res.end(content); + }catch(e){json(res,e.code==='ENOENT'?404:400,{error:e.code==='ENOENT'?'Not found.':e.message||'Request failed.'});} + }); + await new Promise((resolve,reject)=>{ + let attempts=0;const fail=e=>{if(e.code==='EADDRINUSE'&&++attempts<20){actualPort++;server.listen(actualPort,'127.0.0.1');}else reject(e);};server.on('error',fail);server.once('listening',()=>{server.off('error',fail);actualPort=server.address().port;resolve();});server.listen(actualPort,'127.0.0.1'); + }); + await fs.writeFile(path.join(stateDir,'server.json'),JSON.stringify({port:actualPort,hookToken,pid:process.pid}),{mode:0o600}); + await tick();const timer=setInterval(()=>tick().catch(()=>{}),1000),heartbeat=setInterval(()=>{for(const c of clients)c.write(': heartbeat\n\n');},15000); + return {url:`http://127.0.0.1:${actualPort}`,store,snapshot,async close(){closing=true;clearInterval(timer);clearInterval(heartbeat);projects.close();for(const c of clients)c.end();await new Promise(r=>server.close(r));try{const reg=JSON.parse(await fs.readFile(path.join(stateDir,'server.json'),'utf8'));if(reg.hookToken===hookToken)await fs.unlink(path.join(stateDir,'server.json'));}catch{}}}; +} diff --git a/packages/kitchen/test/activity.test.mjs b/packages/kitchen/test/activity.test.mjs new file mode 100644 index 0000000..0d0aa4b --- /dev/null +++ b/packages/kitchen/test/activity.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {IdentityBook,activityText,goalCards,rankedGoals,kitchenForSession} from '../public/src/activity.js'; +const chef=(id,extra={})=>({id,sessionId:id,provider:'codex',project:'p',state:'writing',freshness:'recent',startedAt:1,...extra}); +const p={id:'p',components:[{id:'old',title:'Completed goal',tasks:[{state:'x'}]},{id:'busy',title:'Current goal',tasks:[{state:'x'}]},{id:'elsewhere',title:'Other kitchen',tasks:[{state:'~'}]}],kitchens:[{id:'one',components:['old','busy']},{id:'two',components:['elsewhere']}]}; +test('a completed goal with fresh work replaces inactive history in the room rail',()=>{ + const s=chef('a',{component:{id:'busy'}}),cards=goalCards(p,[s]); + assert.deepEqual(rankedGoals(cards,{kitchenId:'one'}).map(c=>c.id),['busy']); + assert.equal(cards.find(c=>c.id==='busy').counts.done,1); + assert.deepEqual(rankedGoals(cards,{kitchenId:'one',pinned:['old']}).map(c=>c.id),['old','busy']); +}); +test('two sessions on a goal stay separate and uncertain work remains visible',()=>{ + const crew=[chef('a',{component:{id:'busy'}}),chef('b',{component:{id:'busy'}}),chef('c')]; + const cards=goalCards(p,crew);assert.equal(cards.find(c=>c.id==='busy').chefs.length,2); + assert.equal(cards.find(c=>c.id==='__unlinked').chefs[0].id,'c');assert.equal(kitchenForSession(crew[2],p).id,'one'); +}); +test('attention wins priority and cross-room goals only follow a deliberate pin',()=>{ + const cards=goalCards(p,[chef('a',{component:{id:'busy'}}),chef('b',{component:{id:'old'},state:'permission'}),chef('c',{component:{id:'elsewhere'},state:'input'})]); + assert.deepEqual(rankedGoals(cards,{kitchenId:'one'}).map(c=>c.id),['old','busy']); + assert.equal(rankedGoals(cards,{kitchenId:'one',pinned:['elsewhere']})[0].id,'elsewhere'); +}); +test('identities survive reordering and reload; same-provider chefs have distinct colors and names',()=>{ + const book=new IdentityBook(),crew=Array.from({length:12},(_,i)=>chef('s'+i)); + const first=book.assign(crew),second=new IdentityBook(book.save()).assign([...crew].reverse()); + assert.equal(new Set(first.map(s=>s.visualIndex)).size,12);assert.equal(new Set(first.map(s=>s.displayName)).size,12); + for(const s of first)assert.deepEqual(second.find(x=>x.id===s.id),s); +}); +test('literal activity does not turn an old file or arbitrary command into a current test run',()=>{ + assert.equal(activityText(chef('a',{state:'executing',file:'test/old.test.js'})),'Running a command'); + assert.equal(activityText(chef('a',{currentFile:'public/kitchen.css'})),'Editing kitchen.css'); + assert.match(activityText(chef('a',{freshness:'quiet'})),/^Last seen:/); + assert.equal(activityText(chef('a',{freshness:'quiet',state:'permission'})),'Needs permission'); + assert.equal(activityText(chef('a'),false),'Connection lost'); + assert.match(activityText(chef('a',{activeToolCount:2})),/\+1 actions/); +}); +test('a returning session cannot share a color with a currently visible replacement',()=>{ + const book=new IdentityBook();book.assign([chef('a')]);book.assign([chef('a',{ended:true}),chef('b')]); + const resumed=book.assign([chef('a'),chef('b')]);assert.notEqual(resumed[0].visualIndex,resumed[1].visualIndex);assert.notEqual(resumed[0].badge,resumed[1].badge); +}); diff --git a/packages/kitchen/test/attach.test.mjs b/packages/kitchen/test/attach.test.mjs new file mode 100644 index 0000000..a5dd968 --- /dev/null +++ b/packages/kitchen/test/attach.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {execFile} from 'node:child_process'; +import {promisify} from 'node:util'; +import {startOffice} from '../src/server.mjs'; +import {CrewStore} from '../src/runtime/crew.mjs'; +import {LogObserver} from '../src/connectors/logs.mjs'; +import {normalizeHook,claudeEvents,codexEvents} from '../src/connectors/events.mjs'; +import {parseArgs,liveUrl} from '../bin/office.mjs'; +const run=promisify(execFile),cli=path.resolve('bin/office.mjs'); +async function fixture(t){const home=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'kitchen-attach-')));t.after(()=>fs.rm(home,{recursive:true,force:true}));const a=path.join(home,'first'),b=path.join(home,"repo with spaces ' and $(literal)"),dir=path.join(home,'.codex/sessions/2024/01/02');await Promise.all([a,b,dir].map(d=>fs.mkdir(d,{recursive:true})));return {home,a,b,dir};} +const line=(payload,at,type='response_item')=>JSON.stringify({type,timestamp:new Date(at).toISOString(),payload})+'\n'; +async function log(dir,root){const now=Date.now(),file=path.join(dir,'resumed.jsonl');await fs.writeFile(file,JSON.stringify({type:'session_meta',payload:{id:'resumed',cwd:root}})+'\n'+line({type:'task_started',turn_id:'one'},now-4,'event_msg')+line({type:'function_call',name:'update_plan',call_id:'plan',arguments:JSON.stringify({plan:[{step:'Check the current change',status:'in_progress'}]})},now-3)+line({type:'function_call_output',call_id:'plan',output:'Plan updated'},now-2)+line({type:'function_call',name:'Read',call_id:'read',arguments:JSON.stringify({file_path:'app.js',private:'do-not-show'})},now-1));return file;} + +test('discovers an old resumed session and replays it only after its repo is selected',async t=>{ + const {home,a,b,dir}=await fixture(t);await log(dir,b);const roots=[a],store=new CrewStore(roots),observer=new LogObserver(home,store);await observer.poll(); + assert.equal(store.snapshot().length,0);assert.equal(observer.recentProjects[0].path,b);assert.ok(!JSON.stringify(observer.recentProjects).includes('do-not-show')); + roots.push(b);await observer.poll();assert.equal(store.snapshot().length,1);assert.equal(store.snapshot()[0].state,'reading');assert.equal(store.snapshot()[0].sessionTasks[0].title,'Check the current change'); +}); +test('the CLI attaches a planless repo to the existing service and replays real log events',async t=>{ + const {home,a,b,dir}=await fixture(t);await log(dir,b);const stateDir=path.join(home,'state'),office=await startOffice({roots:[a],home,stateDir,port:0});t.after(()=>office.close()); + const before=JSON.parse(await fs.readFile(path.join(stateDir,'server.json'),'utf8')); + const {stdout}=await run(process.execPath,[cli,b,'--state-dir',stateDir,'--no-open'],{timeout:10000});assert.match(stdout,/Kitchen updated:/); + const printed=new URL(stdout.split('\n')[0].replace('Kitchen updated: ',''));assert.equal(printed.searchParams.get('project'),b);assert.equal(printed.searchParams.get('mode'),'live'); + const after=JSON.parse(await fs.readFile(path.join(stateDir,'server.json'),'utf8'));assert.equal(after.pid,before.pid);assert.equal(after.hookToken,before.hookToken); + const state=await fetch(office.url+'/api/state').then(r=>r.json()),project=state.projects.find(p=>p.id===b);assert.equal(project.components.length,0);assert.equal(state.crew.filter(c=>c.project===b).length,4);assert.equal(state.executors.filter(c=>c.project===b).length,1);assert.equal(state.crew.find(c=>c.project===b&&c.workingCount).state,'reading');assert.equal(state.orders.filter(o=>o.project===b).length,1);assert.deepEqual(await fs.readdir(b),[]); + await run(process.execPath,[cli,'--state-dir',stateDir,'--no-open'],{cwd:b,timeout:10000});assert.equal(office.snapshot().projects.length,2); + const denied=await fetch(office.url+'/api/attach',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({projects:[b]})});assert.equal(denied.status,403); +}); +test('CLI arguments accept current/relative repos without shell interpretation and reject missing values',()=>{ + assert.deepEqual(parseArgs(['.','../other'], '/projects/one').roots,['/projects/one','/projects/other']);assert.throws(()=>parseArgs(['--project']),/Provide a value/);assert.throws(()=>parseArgs(['--port','no']),/Choose a port/); + assert.equal(new URL(liveUrl('http://127.0.0.1:4780',"/repo/'literal' $(safe)")).searchParams.get('project'),"/repo/'literal' $(safe)"); +}); +test('a Codex call and result in the same millisecond cannot leave a phantom active tool',()=>{ + const store=new CrewStore(['/repo']),meta={id:'one',cwd:'/repo'},timestamp=new Date().toISOString(); + for(const payload of [{type:'function_call',call_id:'fast',name:'Read'},{type:'function_call_output',call_id:'fast'}])for(const event of codexEvents({type:'response_item',timestamp,payload},meta,'log'))store.accept(event); + assert.equal(store.snapshot()[0].activeToolCount,0); +}); +test('modern Claude tasks change only on confirmed tool results and keep native ordering',()=>{ + const store=new CrewStore(['/repo']);let count=0; + const send=(tool,input,output,hook_event_name='PostToolUse')=>{const event=normalizeHook('claude',{session_id:'one',cwd:'/repo',hook_event_name,tool_name:tool,tool_input:input,tool_response:output,office_event_id:String(++count)});if(event)store.accept(event);}; + send('TaskCreate',{subject:'First',description:'private details'},{task:{id:'1',subject:'First',status:'pending'}});send('TaskCreate',{subject:'Second'},{task:{id:'2',subject:'Second',status:'pending'}}); + send('TaskUpdate',{taskId:'1',status:'completed'},{},'PostToolUseFailure');assert.equal(store.snapshot()[0].sessionTasks[0].status,'pending'); + send('TaskUpdate',{taskId:'1',status:'in_progress'},{});assert.deepEqual(store.snapshot()[0].sessionTasks.map(t=>t.id),['1','2']);assert.equal(store.snapshot()[0].sessionTasks[0].status,'in_progress'); + send('TaskUpdate',{taskId:'1',status:'completed'},{});assert.equal(store.snapshot()[0].sessionTasks[0].status,'completed');assert.ok(!JSON.stringify(store.snapshot()).includes('private details')); + send('TaskUpdate',{taskId:'2',status:'deleted'},{});assert.equal(store.snapshot()[0].sessionTasks.length,1); +}); +test('Claude log TaskUpdate waits for successful result and Cursor partial todos retain other items',()=>{ + const context={pending:new Map()},base={sessionId:'one',cwd:'/repo',timestamp:new Date().toISOString()},start=claudeEvents({...base,uuid:'start',type:'assistant',message:{content:[{type:'tool_use',id:'call',name:'TaskUpdate',input:{taskId:'1',status:'completed'}}]}},'log',context);assert.equal(start[0].taskChange,undefined); + const end=claudeEvents({...base,uuid:'end',type:'user',message:{content:[{type:'tool_result',tool_use_id:'call',content:'{}'}]}},'log',context);assert.equal(end[0].taskChange.status,'completed'); + const store=new CrewStore(['/repo']);for(const [id,todos,merge] of [['one',[{id:'1',content:'First',status:'pending'},{id:'2',content:'Second',status:'pending'}],false],['two',[{id:'1',content:'First',status:'in_progress'}],true]])store.accept(normalizeHook('cursor',{hook_event_name:'postToolUse',conversation_id:'cursor-session',cwd:'/repo',office_event_id:id,tool_name:'TodoWrite',tool_input:{todos,merge}})); + assert.equal(store.snapshot()[0].sessionTasks.length,2);assert.equal(store.snapshot()[0].sessionTasks.find(t=>t.id==='1').status,'in_progress'); + assert.equal(normalizeHook('cursor',{hook_event_name:'subagentStop',conversation_id:'parent',cwd:'/repo'}),null); +}); diff --git a/packages/kitchen/test/claude-native-acks.test.mjs b/packages/kitchen/test/claude-native-acks.test.mjs new file mode 100644 index 0000000..3272818 --- /dev/null +++ b/packages/kitchen/test/claude-native-acks.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {normalizeHook,claudeEvents} from '../src/connectors/events.mjs'; +import {CrewStore} from '../src/runtime/crew.mjs'; + +const subject='Write implementable game-rules brief in research/RULES.md'; +const created=`Task #1 created successfully: ${subject}`; +const hook=(tool,input,output,extra={})=>normalizeHook('claude',{session_id:'one',cwd:'/repo',hook_event_name:'PostToolUse',tool_name:tool,tool_input:input,tool_response:output,...extra}); +function fromLog(tool,input,output,is_error=false){ + const context={pending:new Map()},base={sessionId:'one',cwd:'/repo',timestamp:new Date().toISOString()}; + const start=claudeEvents({...base,uuid:'start',type:'assistant',message:{content:[{type:'tool_use',id:'call',name:tool,input}]}},'log',context); + const retained=JSON.stringify([...context.pending.values()]); + const end=claudeEvents({...base,uuid:'end',type:'user',message:{content:[{type:'tool_result',tool_use_id:'call',content:output,is_error}]}},'log',context); + return {start,event:end[0],retained,context}; +} + +test('Claude native text receipts create and update a dish through hooks without private fields',()=>{ + const input={subject,description:'PRIVATE DESCRIPTION',activeForm:'PRIVATE ACTIVE FORM',status:'completed'}; + const create=hook('TaskCreate',input,created,{office_event_id:'create'}); + assert.deepEqual(create.taskChange,{id:'1',title:subject,status:'pending'}); + const update=hook('TaskUpdate',{taskId:'1',status:'in_progress',owner:'PRIVATE OWNER'},'Updated task #1 owner, status',{office_event_id:'update'}); + assert.deepEqual(update.taskChange,{id:'1',status:'in_progress'}); + const store=new CrewStore(['/repo']);store.accept(create);store.accept(update); + assert.equal(store.snapshot()[0].sessionTasks[0].status,'in_progress'); + assert.ok(!JSON.stringify({create,update,snapshot:store.snapshot()}).includes('PRIVATE')); +}); + +test('Claude logs match successful native acknowledgements to their pending calls',()=>{ + const create=fromLog('TaskCreate',{subject,description:'PRIVATE DESCRIPTION',activeForm:'PRIVATE ACTIVE FORM'},[{type:'text',text:created}]); + assert.equal(create.start[0].taskChange,undefined); + assert.deepEqual(create.event.taskChange,{id:'1',title:subject,status:'pending'}); + assert.equal(create.context.pending.size,0);assert.ok(!JSON.stringify(create).includes('PRIVATE')); + const update=fromLog('TaskUpdate',{taskId:'1',status:'completed'},[{type:'text',text:'Updated task #1 owner, status'}]); + assert.deepEqual(update.event.taskChange,{id:'1',status:'completed'}); + assert.deepEqual(hook('TaskCreate',{subject},{content:[{type:'text',text:created}]}).taskChange,create.event.taskChange); +}); + +test('plain acknowledgements reject mismatches, failure text, and unconfirmed status changes',()=>{ + const invalid=[ + ['TaskCreate',{subject},'Task #1 failed to create: '+subject], + ['TaskCreate',{subject},'I think '+created], + ['TaskCreate',{subject},created+'\nBut it failed'], + ['TaskUpdate',{taskId:'1',status:'completed'},'Updated task #2 owner, status'], + ['TaskUpdate',{taskId:'1',status:'completed'},'Failed to update task #1 status'], + ['TaskUpdate',{taskId:'1',status:'completed'},'Updated task #1 owner'], + ['TaskUpdate',{taskId:'1',status:'completed'},'Updated task #1 status failed'], + ['TaskUpdate',{taskId:'1',status:'completed'},'Updated task #1 status\nReasoning: done'], + ['TaskUpdate',{taskId:'1',status:'completed'},'"failed"'], + ['TaskUpdate',{taskId:'1',status:'completed'},[{type:'text',text:'Updated task #1 status'},{type:'text',text:'Actually failed'}]], + ]; + for(const [tool,input,output] of invalid){ + assert.equal(hook(tool,input,output).taskChange,undefined,JSON.stringify(output)); + assert.equal(fromLog(tool,input,output).event.taskChange,undefined,JSON.stringify(output)); + } +}); + +test('failure flags override acknowledgements and existing structured results remain supported',()=>{ + assert.equal(hook('TaskCreate',{subject},created,{hook_event_name:'PostToolUseFailure'}).taskChange,undefined); + assert.equal(hook('TaskCreate',{subject},{content:created,is_error:true}).taskChange,undefined); + assert.equal(fromLog('TaskCreate',{subject},created,true).event.taskChange,undefined); + assert.deepEqual(hook('TaskCreate',{subject,description:'PRIVATE'},{task:{id:'1',subject,status:'pending'}}).taskChange,{id:'1',title:subject,status:'pending'}); + assert.deepEqual(fromLog('TaskUpdate',{taskId:'1',status:'completed'},'{}').event.taskChange,{id:'1',status:'completed'}); +}); diff --git a/packages/kitchen/test/crew.test.mjs b/packages/kitchen/test/crew.test.mjs new file mode 100644 index 0000000..16bdb39 --- /dev/null +++ b/packages/kitchen/test/crew.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CrewStore } from '../src/runtime/crew.mjs'; +import { normalizeHook,codexEvents } from '../src/connectors/events.mjs'; +import { matchesGlob,parsePlan } from '../src/agenttrail/projects.mjs'; + +const now=Date.now(); +const event=(overrides={})=>({id:'event-1',provider:'claude',sessionId:'parent',cwd:'/work/project',at:now,source:'hook',kind:'turn-start',...overrides}); +test('duplicates, replay and unrelated projects cannot create or rewind crew',()=>{ + const store=new CrewStore(['/work/project'],()=>now+1000); + assert.equal(store.accept(event()),true);assert.equal(store.accept(event()),false); + store.accept(event({id:'write',at:now+100,kind:'tool-start',tool:'Edit',toolId:'a'})); + store.accept(event({id:'old-stop',at:now-1,kind:'turn-end'})); + assert.equal(store.snapshot()[0].state,'writing'); + assert.equal(store.accept(event({id:'outside',cwd:'/work/project-other'})),false); + assert.equal(store.snapshot().length,1); +}); +test('concurrent tools and out-of-order child completions preserve exact identities',()=>{ + const store=new CrewStore(['/work/project'],()=>now+1000); + for(const child of ['a','b'])store.accept(event({id:child,sessionId:child,parentId:'parent',kind:'session-start'})); + store.accept(event({id:'b-end',sessionId:'b',kind:'turn-end',at:now+2})); + assert.equal(store.sessions.get('claude:a').state,'working');assert.equal(store.sessions.get('claude:b').state,'complete'); + store.accept(event({id:'a-tool',sessionId:'a',kind:'tool-start',tool:'Read',toolId:'r',at:now+3})); + store.accept(event({id:'a-tool2',sessionId:'a',kind:'tool-start',tool:'Shell',toolId:'s',at:now+4})); + store.accept(event({id:'a-toolend',sessionId:'a',kind:'tool-end',toolId:'r',at:now+5})); + assert.equal(store.sessions.get('claude:a').state,'executing'); +}); +test('permission persists through quiet observation; a completed turn retains the session',()=>{ + let clock=now;const store=new CrewStore(['/work/project'],()=>clock); + store.accept(event({kind:'permission'}));clock+=180000; + assert.equal(store.snapshot()[0].state,'permission');assert.equal(store.snapshot()[0].freshness,'quiet'); + store.accept(event({id:'turn-end',kind:'turn-end',at:clock})); + assert.equal(store.snapshot()[0].state,'complete');assert.equal(store.snapshot()[0].ended,false); + store.accept(event({id:'end',kind:'session-end',at:clock+1}));assert.equal(store.snapshot()[0].ended,true); +}); +test('native hook shapes preserve children and discard raw user content',()=>{ + const cursor=normalizeHook('cursor',{hook_event_name:'subagentStop',conversation_id:'parent',parent_conversation_id:'parent',subagent_id:'b',workspace_roots:['/work/project'],office_event_id:'x',task:'private prompt',user_email:'private@example.test'},now); + assert.equal(cursor.sessionId,'b');assert.equal(cursor.parentId,'parent');assert.equal(cursor.kind,'turn-end'); + assert.ok(!JSON.stringify(cursor).includes('private')); + const claude=normalizeHook('claude',{session_id:'p',agent_id:'c',cwd:'/work/project',hook_event_name:'PreToolUse',office_event_id:'x',tool_name:'Bash',tool_input:{command:'secret command'}},now); + assert.equal(claude.sessionId,'c');assert.equal(claude.parentId,'p');assert.ok(!JSON.stringify(claude).includes('secret')); +}); +test('a delayed completion from an earlier turn cannot finish the current turn',()=>{ + const store=new CrewStore(['/work/project'],()=>now+1000); + store.accept(event({id:'first',turnId:'one'})); + store.accept(event({id:'second',turnId:'two',at:now+1})); + store.accept(event({id:'late-first',turnId:'one',kind:'turn-end',at:now+2})); + assert.equal(store.snapshot()[0].turnId,'two');assert.equal(store.snapshot()[0].state,'working'); + assert.equal(store.accept(event({id:'bad',sessionId:{malformed:true}})),false); +}); +test('Codex uses thread identity and lifecycle evidence without exposing arguments',()=>{ + const meta={id:'thread',cwd:'/work/project'}; + const start=codexEvents({type:'event_msg',timestamp:new Date(now).toISOString(),payload:{type:'task_started',turn_id:'turn'}},meta,'log')[0]; + assert.equal(start.kind,'turn-start');assert.equal(start.sessionId,'thread'); + const tool=codexEvents({type:'response_item',timestamp:new Date(now).toISOString(),payload:{type:'function_call',call_id:'1',name:'exec_command',arguments:'{"cmd":"secret"}'}},meta,'log')[0]; + assert.equal(tool.tool,'exec_command');assert.ok(!JSON.stringify(tool).includes('secret')); +}); +test('component matching handles direct, nested and literal filenames',()=>{ + assert.ok(matchesGlob('public/app.js','public/**'));assert.ok(matchesGlob('src/app.js','src/**/*.js'));assert.ok(matchesGlob('src/a/app.js','src/**/*.js')); + assert.ok(!matchesGlob('src/a/app.js','src/*.js'));assert.ok(matchesGlob('file.test.js','file.test.js'));assert.ok(!matchesGlob('fileXtestXjs','file.test.js')); + const parsed=parsePlan('## Show work {#work}\nfiles: [public/**]\n- [~] Draw the crew {#crew}\n by: codex\n## decisions\n- anything'); + assert.equal(parsed[0].tasks[0].title,'Draw the crew');assert.equal(parsed[0].files[0],'public/**'); +}); + +test('current files follow active parallel tools while the last touched file remains context',()=>{ + const store=new CrewStore(['/work/project'],()=>now+1000); + store.accept(event({id:'read',kind:'tool-start',tool:'Read',toolId:'r',file:'src/a.js'})); + store.accept(event({id:'shell',kind:'tool-start',tool:'Bash',toolId:'b',at:now+1})); + assert.equal(store.snapshot()[0].currentFile,null);assert.equal(store.snapshot()[0].file,'src/a.js');assert.equal(store.snapshot()[0].activeToolCount,2); + store.accept(event({id:'shell-end',kind:'tool-end',toolId:'b',at:now+2}));assert.equal(store.snapshot()[0].currentFile,'src/a.js'); + store.accept(event({id:'read-end',kind:'tool-end',toolId:'r',at:now+3}));assert.equal(store.snapshot()[0].currentFile,null);assert.equal(store.snapshot()[0].activeToolCount,0); +}); +test('Codex structured plan steps and patch headers expose useful metadata without patch bodies',()=>{ + const meta={id:'thread',cwd:'/work/project'},base={type:'response_item',timestamp:new Date(now).toISOString()}; + const plan=codexEvents({...base,payload:{type:'function_call',call_id:'p',name:'update_plan',arguments:JSON.stringify({explanation:'PRIVATE EXPLANATION',plan:[{step:'Connect goal cards',status:'in_progress'}]})}},meta,'log')[0]; + assert.equal(plan.tasks[0].title,'Connect goal cards');assert.ok(!JSON.stringify(plan).includes('PRIVATE')); + const patch=codexEvents({...base,payload:{type:'custom_tool_call',call_id:'patch',name:'apply_patch',input:'*** Begin Patch\n*** Update File: public/app.js\n@@\n+PRIVATE BODY\n*** End Patch'}},meta,'log')[0]; + assert.equal(patch.file,'public/app.js');assert.ok(!JSON.stringify(patch).includes('PRIVATE')); +}); diff --git a/packages/kitchen/test/inferred-crew.test.mjs b/packages/kitchen/test/inferred-crew.test.mjs new file mode 100644 index 0000000..95f5362 --- /dev/null +++ b/packages/kitchen/test/inferred-crew.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {inferCrew} from '../src/agenttrail/crew-profile.mjs'; +import {workflowConfig} from '../src/agenttrail/workflows.mjs'; +import {workflowCrew,roleForSession} from '../src/runtime/workflow-crew.mjs'; +import {CrewStore} from '../src/runtime/crew.mjs'; +import {OrderStore} from '../src/runtime/orders.mjs'; +import {codexEvents} from '../src/connectors/events.mjs'; +import {desktopWork} from '../src/connectors/desktop-events.mjs'; +import {IdentityBook} from '../public/src/activity.js'; + +const project={id:'/repo',name:'Simulation',components:[],kitchens:[{id:'shared',components:[]}],workflow:inferCrew(['src/world.js','sim/engine.js','research/notes.md','tests/engine.test.js'],'Simulation')}; +const now=Date.now(),base={provider:'codex',sessionId:'one',cwd:'/repo',source:'log',turnId:'turn'}; +const enriched=store=>store.snapshot().map(s=>({...s,planAvailable:!!s.sessionTasks,taskSource:'native plan',currentTask:s.sessionTasks?.find(t=>t.status==='in_progress')})); + +test('ordinary repos get stable crews adapted to their actual responsibilities',()=>{ + assert.deepEqual(project.workflow.roles.map(r=>r.title),['Head chef','Researcher','World builder','Simulation engineer','Reviewer']); + assert.deepEqual(inferCrew(['drafts/','publisher/'],'Posts').roles.map(r=>r.title),['Head chef','Researcher','Writer','Evaluator','Publisher']); + const empty=inferCrew([],'Empty');assert.equal(empty.roles.length,4);assert.ok(empty.roles.every(r=>r.origin==='inferred'&&!r.components.length)); + const config=workflowConfig([],{version:1,kitchens:[],deliverables:[],workflow:{roles:[{id:'writer',title:'Copy editor',files:['copy/**'],category:'build'}]}},'Custom'); + assert.equal(config.workflow.roles[0].title,'Copy editor');assert.equal(config.workflow.origin,'configured'); + assert.equal(workflowConfig([],{workflow:false},'Opt out').workflow,false); +}); +test('one session moves across five persistent chefs with only one current executor',()=>{ + const store=new CrewStore(['/repo'],()=>now+100),book=new IdentityBook(); + store.accept({...base,id:'start',at:now,kind:'turn-start'}); + const first=book.assign(workflowCrew([project],enriched(store))); + store.accept({...base,id:'read',at:now+1,kind:'tool-start',tool:'Read',toolId:'read',file:'sim/engine.js'}); + const reading=book.assign(workflowCrew([project],enriched(store)));assert.equal(reading.find(r=>r.workingCount).roleId,'simulation-engineer'); + store.accept({...base,id:'read-end',at:now+2,kind:'tool-end',toolId:'read'}); + store.accept({...base,id:'read-meta',at:now+2,kind:'observation',work:{category:'research',label:'Read a file',file:'sim/engine.js',completed:true}}); + store.accept({...base,id:'browser',at:now+3,kind:'tool-start',tool:'js',toolId:'browser',work:{category:'review',label:'Inspecting the preview'}}); + const reviewing=book.assign(workflowCrew([project],enriched(store)));assert.equal(reviewing.find(r=>r.workingCount).roleId,'reviewer'); + assert.equal(reviewing.filter(r=>r.workingCount).length,1);assert.equal(reviewing.reduce((n,r)=>n+r.executors.length,0),1); + assert.deepEqual(first.map(r=>[r.id,r.badge,r.visualIndex]),reviewing.map(r=>[r.id,r.badge,r.visualIndex])); + assert.equal(reviewing.find(r=>r.roleId==='simulation-engineer').recentWork.file,'sim/engine.js'); + store.accept({...base,id:'end',at:now+4,kind:'turn-end'});assert.equal(workflowCrew([project],enriched(store)).filter(r=>r.workingCount).length,0); + assert.equal(workflowCrew([project],enriched(store)).length,5); +}); +test('completed desktop metadata enriches context without reviving a finished session or creating todos',()=>{ + const store=new CrewStore(['/repo'],()=>now+100); + store.accept({...base,id:'start',at:now,kind:'turn-start'});store.accept({...base,id:'end',at:now+8,kind:'turn-end'}); + const row={type:'event_msg',timestamp:new Date(now+10).toISOString(),payload:{type:'item_completed',thread_id:'one',turn_id:'turn',completed_at_ms:now+7,item:{type:'CommandExecution',id:'read-result',status:'completed',parsed_cmd:[{type:'read',path:'sim/engine.js',cmd:'PRIVATE COMMAND'}],stdout:'PRIVATE OUTPUT'}}}; + for(const e of codexEvents(row,{id:'one',cwd:'/repo'},'file'))assert.equal(store.accept(e),true); + const s=store.snapshot()[0];assert.equal(s.state,'complete');assert.equal(s.lastEventAt,now+8);assert.equal(s.workContext.file,'sim/engine.js');assert.equal(s.sessionTasks,undefined);assert.ok(!JSON.stringify(s).includes('PRIVATE')); + row.payload.thread_id='other';assert.deepEqual(codexEvents(row,{id:'one',cwd:'/repo'},'file'),[]); + store.accept({...base,turnId:'new',id:'new-turn',at:now+9,kind:'turn-start'});assert.equal(store.snapshot()[0].workContext,null); +}); +test('desktop work accepts parsed operations and only gives compound commands a generic label',()=>{ + assert.equal(desktopWork({type:'CommandExecution',command:['/bin/zsh','-lc','npm test'],exit_code:0}).category,'review'); + assert.deepEqual(desktopWork({type:'CommandExecution',command:['sh','-c','echo "npm test"; PRIVATE BODY']}),{category:'execute',label:'Ran a command'}); + assert.equal(desktopWork({type:'CommandExecution',status:'failed',parsed_cmd:[{type:'read',path:'missing.js'}]}).label,'Tried reading a file'); + assert.equal(desktopWork({type:'Reasoning',summary_text:'I am reviewing'}),null); + assert.equal(desktopWork({type:'AgentMessage',content:'Task complete'}),null); + assert.equal(desktopWork({type:'McpToolCall',server:'cua_repl',arguments:{code:'PRIVATE'}}).label,'Inspected the preview'); +}); +test('a fresh generic command replaces stale preview context without inventing its intent',()=>{ + const store=new CrewStore(['/repo'],()=>now+100); + store.accept({...base,id:'start',at:now,kind:'turn-start'}); + store.accept({...base,id:'preview',at:now+1,kind:'observation',work:{category:'review',label:'Inspected the preview',completed:true}}); + assert.equal(workflowCrew([project],enriched(store)).find(r=>r.workingCount).roleId,'reviewer'); + store.accept({...base,id:'command',at:now+2,kind:'observation',work:{...desktopWork({type:'CommandExecution',command:['sh','-c','PRIVATE BODY']}),completed:true}}); + const roles=workflowCrew([project],enriched(store)),active=roles.find(r=>r.workingCount); + assert.equal(active.workContext.label,'Ran a command');assert.equal(active.roleId,'coordinator'); + assert.equal(roles.find(r=>r.roleId==='reviewer').workingCount,0); + assert.ok(!JSON.stringify(roles).includes('PRIVATE'));assert.equal(active.sessionTasks,undefined); +}); +test('inferred chefs contribute to the same native dish without copying its task or completing it',()=>{ + const store=new CrewStore(['/repo'],()=>now+100),orders=new OrderStore();store.onChange=()=>orders.observe([project],enriched(store)); + store.accept({...base,id:'start',at:now,kind:'turn-start'}); + store.accept({...base,id:'plan',at:now+1,kind:'tool-start',tool:'update_plan',toolId:'plan',tasks:[{id:'dish',title:'Make the crew visible',status:'in_progress'}]}); + store.accept({...base,id:'plan-end',at:now+2,kind:'tool-end',toolId:'plan'}); + for(const [index,file] of ['research/notes.md','src/world.js','tests/engine.test.js'].entries()){ + store.accept({...base,id:'r'+index,at:now+3+index*2,kind:'tool-start',tool:'Read',toolId:'r'+index,file}); + store.accept({...base,id:'e'+index,at:now+4+index*2,kind:'tool-end',toolId:'r'+index}); + } + const state=orders.snapshot([project],enriched(store));assert.equal(state.orders.length,1);assert.equal(state.orders[0].status,'in_progress'); + assert.ok(['researcher','world-builder','reviewer'].every(id=>state.orders[0].contributors.some(c=>c.roleId===id))); + assert.ok(state.orders[0].activeChefIds.length<=1);assert.equal(state.tables[0].completedIds.length,0); +}); +test('explicit bindings win and an unrecognized binding never silently changes roles',()=>{ + const s={id:'codex:one',sessionId:'one',state:'working',project:'/repo',workContext:{category:'review'},roleBinding:{roleId:'world-builder'}}; + assert.equal(roleForSession(project,s).role.id,'world-builder'); + assert.equal(roleForSession(project,{...s,roleBinding:{roleId:'missing'}}),null); +}); diff --git a/packages/kitchen/test/motion.test.mjs b/packages/kitchen/test/motion.test.mjs new file mode 100644 index 0000000..f279a82 --- /dev/null +++ b/packages/kitchen/test/motion.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as T from 'three'; +import {animateChef} from '../public/src/chefs.js'; +import {KitchenWorld} from '../public/src/world.js'; +import {workPose,nextWorkBeat,approachPoint} from '../public/src/motion.js'; +import {stationLayout} from '../public/src/layout.js'; +import {routeBetween,walkable} from '../public/src/routes.js'; +import {batchMeshes} from '../public/src/batch.js'; + +// Real Three transforms with no renderer or canvas dependency. +function rig(id='chef'){ + const root=new T.Group(),body=new T.Group(),head=new T.Group();root.add(body);body.add(head); + const arms=[new T.Group(),new T.Group()];arms.forEach((a,i)=>{a.position.set(i? .295:-.295,.66,0);body.add(a);}); + return {id,root,body,head,arms,hat:new T.Group(),legs:[new T.Group(),new T.Group()],ring:new T.Group(),carried:new T.Group(),utensil:new T.Group(),spoon:new T.Group(),attention:new T.Group(),color:0xdf5949,phase:0,state:'working',pose:'working',target:new T.Vector3(),home:new T.Vector3(),facing:0,atWorktop:true,walkRoute:[]}; +} +function transforms(c){return [c.root,c.body,c.head,...c.arms,...c.legs,c.hat,c.ring,c.utensil,c.spoon].flatMap(o=>[...o.position,...o.quaternion,...o.scale]);} +const working={state:'working',freshness:'recent',lastEventAt:100_000}; + +test('generic live work makes readable hand movements; idle chefs do not cook',()=>{ + const c=rig();animateChef(c,0,.016);const a=c.utensil.position.clone();animateChef(c,.6,.016); + assert.ok(a.distanceTo(c.utensil.position)>.2);assert.equal(c.utensil.visible,false); + c.state='idle';c.pose='idle';c.atWorktop=false;animateChef(c,1,.016);const q=c.arms[1].quaternion.clone();animateChef(c,2,.016); + assert.ok(q.equals(c.arms[1].quaternion));assert.equal(c.spoon.visible,false);assert.equal(c.carried.visible,false); +}); +test('observed command gestures stir visibly without changing the reported source state',()=>{ + const c=rig();c.pose=workPose({...working,workContext:{at:99_000,category:'execute'}}); + animateChef(c,0,.016);const p=c.spoon.position.clone();animateChef(c,.5,.016); + assert.equal(c.state,'working');assert.equal(c.spoon.visible,true);assert.ok(p.distanceTo(c.spoon.position)>.2); + assert.equal(workPose({...working,workContext:{at:1,category:'execute'}}),'working'); + assert.equal(workPose({...working,state:'idle',workContext:{at:99_000,category:'execute'}}),'idle'); +}); +test('pause freezes the current joints and walking position; reduced motion gives a static pose',()=>{ + const c=rig();c.target.set(2,0,0);animateChef(c,.5,.016);const frame=transforms(c); + animateChef(c,20,.5,false,true);assert.deepEqual(transforms(c),frame); + animateChef(c,21,.016,true);assert.ok(c.root.position.equals(c.target));const still=transforms(c); + animateChef(c,22,.016,true);assert.deepEqual(transforms(c),still); +}); +test('walking beats require fresh new work, respect pause and do not replay reconnect history',()=>{ + const memory={};assert.equal(nextWorkBeat(memory,working,0,100_100,{newRoom:true}),false); + assert.equal(nextWorkBeat(memory,working,10,100_100),false); + assert.equal(nextWorkBeat(memory,{...working,lastEventAt:110_000},10,110_100),true); + assert.equal(nextWorkBeat(memory,{...working,lastEventAt:111_000},11,111_100),false); + assert.equal(nextWorkBeat(memory,{...working,lastEventAt:130_000},30,130_100,{paused:true}),false); + assert.equal(nextWorkBeat(memory,{...working,lastEventAt:130_000},31,130_100),false); + assert.equal(nextWorkBeat(memory,{...working,lastEventAt:150_000},50,150_100,{reduced:true}),false); + assert.equal(nextWorkBeat(memory,{...working,lastEventAt:160_000},60,180_100),false); + assert.equal(nextWorkBeat(memory,{...working,state:'idle',lastEventAt:181_000},81,181_100),false); +}); +test('an active chef keeps a worktop even when idle chefs were listed there first',()=>{ + const components=Array.from({length:6},(_,i)=>({id:String(i),title:'Station '+i})),crew=Array.from({length:5},(_,i)=>({id:String(i),visualIndex:i,state:i===4?'working':'idle',freshness:'recent',lastEventAt:Date.now()})); + const rigs=new Map(crew.map(s=>[s.id,Object.assign(rig(s.id),{visualIndex:s.visualIndex})])); + const layout=stationLayout(6),world=Object.assign(Object.create(KitchenWorld.prototype),{layout,crew:rigs,characters:new T.Group(),plates:new Map(),roomKey:JSON.stringify(components.map(c=>[c.id,c.title])),time:0,paused:false,reduced:false,setOrders(){},workSlots:layout.positions.flatMap(([x,z],station)=>Array.from({length:3},(_,seat)=>({station,seat,x:x+(seat-1)*1.3,z,kits:{reading:{},writing:{},executing:{}}})))}); + world.setData({components,crew,artifacts:[],transfers:[],demo:false,kitchenId:'shared',projectId:'/repo'}); + const active=rigs.get('4');assert.equal(active.atWorktop,true);assert.equal(active.home.z,2.42);assert.ok(active.walkRoute.length); + for(const [id,idle] of rigs)if(id!=='4'){ + assert.equal(idle.atWorktop,false);assert.ok(idle.home.distanceTo(active.home)>=1.06); + assert.ok(Math.hypot(idle.home.x-approachPoint(active.home)[0],idle.home.z-approachPoint(active.home)[1])>=1.06); + } +}); +test('short work trips stay in the aisle for both kitchen sizes',()=>{ + for(const count of [4,6]){ + const {positions,walkWidth}=stationLayout(count); + for(const [x,z] of positions){ + const home={x,z:z<0?-2.32:2.42},start=[home.x,home.z],approach=approachPoint(home),route=[...routeBetween(start,approach,walkWidth),...routeBetween(approach,start,walkWidth)]; + assert.ok(route.length>=4); + for(let i=1;i{ + const root=new T.Group();root.position.set(2,1,-3);root.rotation.y=.5; + const joint=new T.Group(),mat=new T.MeshStandardMaterial();joint.position.set(0,2,0);root.add(joint); + for(const parent of [root,joint])for(const x of [-1,1]){const m=new T.Mesh(new T.BoxGeometry(.5,.7,.9),mat);m.position.set(x,.3,.5);parent.add(m);} + const before=new T.Box3().setFromObject(root);batchMeshes(root,false); + assert.equal(root.children.length,2);assert.equal(joint.children.length,2); + const after=new T.Box3().setFromObject(root);assert.ok(before.min.distanceTo(after.min)<1e-6);assert.ok(before.max.distanceTo(after.max)<1e-6); + joint.rotation.z=.8;assert.ok(new T.Box3().setFromObject(root).max.distanceTo(after.max)>.1); +}); diff --git a/packages/kitchen/test/orders.test.mjs b/packages/kitchen/test/orders.test.mjs new file mode 100644 index 0000000..946002a --- /dev/null +++ b/packages/kitchen/test/orders.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {OrderStore} from '../src/runtime/orders.mjs'; +import {CrewStore} from '../src/runtime/crew.mjs'; +import {codexEvents,normalizeHook} from '../src/connectors/events.mjs'; + +const p={id:'/repo',name:'Weekly posts',components:[{id:'research',tasks:[]},{id:'write',tasks:[]}],workflow:{id:'project',roles:[{id:'researcher',title:'Researcher',components:['research']},{id:'writer',title:'Writer',components:['write']}]}}; +const task=(title,status='in_progress',id)=>({...id?{id}:{},title,status}); +const session=(extra={})=>({id:'codex:a',sessionId:'a',provider:'codex',project:p.id,planAvailable:true,taskSource:'native plan',sessionTasks:[task('Prepare weekly post')],contextAt:100,lastEventAt:100,state:'reading',freshness:'recent',component:{id:'research'},...extra}); + +test('a native todo keeps the same dish through sequential roles; only the current chef is active',()=>{ + const store=new OrderStore();let state=store.snapshot([p],[session()]),id=state.orders[0].id; + state=store.snapshot([p],[session({component:{id:'write'},state:'writing',lastEventAt:200})]); + assert.equal(state.orders[0].id,id);assert.deepEqual(state.orders[0].contributors.map(c=>c.roleId),['researcher','writer']);assert.deepEqual(state.orders[0].activeChefIds,['role:/repo:project:writer']); + state=store.snapshot([p],[session({state:'complete',lastEventAt:300})]);assert.equal(state.orders[0].status,'in_progress');assert.equal(state.tables[0].completedIds.length,0);assert.equal(state.orders[0].activeChefIds.length,0); +}); +test('completion, reopening and withdrawal are distinct from turn completion',()=>{ + const store=new OrderStore();store.snapshot([p],[session()]); + let state=store.snapshot([p],[session({sessionTasks:[task('Prepare weekly post','completed')],contextAt:200})]); + assert.equal(state.tables[0].completedIds.length,1);assert.equal(state.orders[0].completionVersion,1); + state=store.snapshot([p],[session({sessionTasks:[task('Prepare weekly post','in_progress')],contextAt:300})]);assert.equal(state.tables[0].completedIds.length,0);assert.equal(state.orders[0].completedAt,null); + state=store.snapshot([p],[session({sessionTasks:[],contextAt:400})]);assert.equal(state.orders[0].withdrawn,true);assert.equal(state.tables[0].completedIds.length,0); +}); +test('native IDs preserve renamed and reordered tasks; ambiguous title edits never merge work',()=>{ + const store=new OrderStore();let state=store.snapshot([p],[session({sessionTasks:[task('First','pending','a'),task('Second','pending','b')]})]);const ids=Object.fromEntries(state.orders.map(o=>[o.nativeId,o.id])); + state=store.snapshot([p],[session({sessionTasks:[task('Renamed second','pending','b'),task('First','pending','a')],contextAt:200})]);assert.equal(state.orders.find(o=>o.nativeId==='b').id,ids.b);assert.equal(state.orders.find(o=>o.nativeId==='b').index,0); + const ambiguous=new OrderStore();state=ambiguous.snapshot([p],[session({sessionTasks:[task('Read'),task('Read')]})]);const old=state.orders.map(o=>o.id); + state=ambiguous.snapshot([p],[session({sessionTasks:[task('Read')],contextAt:200})]);assert.equal(state.orders.filter(o=>!o.withdrawn).length,1);assert.ok(!old.includes(state.orders.find(o=>!o.withdrawn).id)); +}); +test('same text in different sessions stays separate unless another executor explicitly binds to the dish',()=>{ + const store=new OrderStore(),a=session(),b=session({id:'claude:b',provider:'claude',sessionId:'b',component:{id:'write'}}); + let state=store.snapshot([p],[a,b]);assert.equal(state.orders.length,2); + const id=state.orders.find(o=>o.sessionId===a.id).id; + state=store.snapshot([p],[a,{...b,roleBinding:{roleId:'writer',orderId:id,at:200}}]);const shared=state.orders.find(o=>o.id===id);assert.equal(shared.activeChefIds.length,2);assert.equal(shared.contributors.length,2); + const foreign={...p,id:'/foreign'};state=store.snapshot([p,foreign],[a,{...b,project:foreign.id,roleBinding:{roleId:'writer',orderId:id}}]);assert.equal(state.orders.find(o=>o.id===id).activeChefIds.length,1); +}); +test('no native plan yields unknown progress without turning project components into orders',()=>{ + const store=new OrderStore();const state=store.snapshot([p],[session({planAvailable:false,sessionTasks:[]})]);assert.equal(state.orders.length,0);assert.equal(state.unplanned[0].progress,null);assert.equal(state.tables[0].reported,false); +}); +test('event observation retains intermediate role contributions even before the next browser snapshot',()=>{ + let now=1000;const crew=new CrewStore([p.id],()=>now),orders=new OrderStore(); + crew.onChange=s=>orders.observe([p],[{...s,planAvailable:!!s.sessionTasks,contextAt:s.taskContextAt,taskSource:'native plan'}]); + const send=e=>crew.accept({provider:'codex',sessionId:'a',cwd:p.id,id:String(++now),at:now,source:'hook',...e}); + send({kind:'turn-start'});send({kind:'role',roleId:'researcher'});send({kind:'activity',tool:'Read',tasks:[task('Shared order')]});send({kind:'role',roleId:'writer'});send({kind:'activity',tool:'Edit'});send({kind:'turn-end'}); + const state=orders.snapshot([p],crew.snapshot().map(s=>({...s,planAvailable:!!s.sessionTasks,contextAt:s.taskContextAt,taskSource:'native plan'})));assert.deepEqual(state.orders[0].contributors.map(c=>c.roleId),['researcher','writer']);assert.equal(state.orders[0].activeChefIds.length,0); +}); +test('native adapters retain task IDs and do not expose unrelated fields',()=>{ + const input={todos:[{id:'native-1',content:'Read notes',status:'in_progress',private:'secret'}]}; + const event=normalizeHook('claude',{hook_event_name:'PostToolUse',session_id:'s',cwd:p.id,tool_name:'TodoWrite',tool_input:input});assert.deepEqual(event.tasks,[task('Read notes','in_progress','native-1')]); + const [e]=codexEvents({timestamp:new Date().toISOString(),type:'response_item',payload:{type:'function_call',name:'update_plan',arguments:JSON.stringify({plan:[{id:'x',step:'Cook',status:'pending'}]})}},{id:'s',cwd:p.id},'file');assert.deepEqual(e.tasks,[task('Cook','pending','x')]); +}); + +test('plan bookkeeping does not invent cooking on the newly marked in-progress task',()=>{ + const store=new OrderStore();const state=store.snapshot([p],[session({tool:'TodoWrite',state:'working'})]);assert.equal(state.orders[0].contributors.length,0);assert.equal(state.orders[0].activeChefIds.length,0); +}); +test('a returning native task ID retains history and changing plan scope retires earlier tickets',()=>{ + const store=new OrderStore();let state=store.snapshot([p],[session({planId:'one',sessionTasks:[task('First','in_progress','a')]})]);const id=state.orders[0].id; + store.snapshot([p],[session({planId:'one',sessionTasks:[],contextAt:200})]);state=store.snapshot([p],[session({planId:'one',sessionTasks:[task('First again','in_progress','a')],contextAt:300})]);assert.equal(state.orders.filter(o=>!o.withdrawn)[0].id,id); + state=store.snapshot([p],[session({planId:'two',sessionTasks:[task('Other work','in_progress','a')],contextAt:400})]);assert.equal(state.orders.filter(o=>!o.withdrawn).length,1);assert.notEqual(state.orders.find(o=>!o.withdrawn).id,id); +}); diff --git a/packages/kitchen/test/plates.test.mjs b/packages/kitchen/test/plates.test.mjs new file mode 100644 index 0000000..db2e55a --- /dev/null +++ b/packages/kitchen/test/plates.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CrewStore} from '../src/runtime/crew.mjs'; +import {PlateStore} from '../src/runtime/plates.mjs'; +const event=(extra={})=>({id:'e1',artifactId:'image',revisionId:'sha256-a',cwd:'/repo',provider:'codex',sessionId:'one',kind:'produced',type:'image',file:'public/image.png',...extra}); +test('plates require exact artifact identity and discard private payloads',()=>{ + const p=new PlateStore(new CrewStore(['/repo']));assert.equal(p.accept(event({revisionId:undefined})),false);assert.equal(p.accept(event({cwd:'/outside'})),false);assert.equal(p.accept(event({file:'../secret'})),false); + assert.equal(p.accept(event({body:'secret bytes',prompt:'secret prompt'})),true);assert.equal(p.accept(event()),false);assert.ok(!JSON.stringify(p.snapshot()).includes('secret')); +}); +test('receipts survive delayed offers; identities and project boundaries cannot change',()=>{ + const crew=new CrewStore(['/repo','/other']),p=new PlateStore(crew); + const received=event({kind:'received',handoffId:'h1',recipientProvider:'claude',recipientSessionId:'two'}); + assert.equal(p.accept(received),true);assert.equal(p.accept({...received,id:'late-offer',kind:'offered'}),false);assert.equal(p.snapshot().transfers[0].state,'received'); + assert.equal(p.accept({...received,id:'changed',revisionId:'other'}),false); + crew.accept({id:'start',provider:'cursor',sessionId:'foreign',cwd:'/other',kind:'session-start',at:Date.now()}); + assert.equal(p.accept({...received,id:'cross',handoffId:'h2',recipientProvider:'cursor',recipientSessionId:'foreign'}),false); +}); + +test('explicit dish links and historical roles survive a handoff within one session',()=>{ + const crew=new CrewStore(['/repo']),orders=new Map([['order-one',{project:'/repo'}],['order-other',{project:'/other'}]]),store=new PlateStore(crew,Date.now,id=>orders.get(id)); + crew.accept({id:'start-role-order',provider:'codex',sessionId:'one',cwd:'/repo',kind:'turn-start',source:'hook'}); + crew.accept({id:'research-role-order',provider:'codex',sessionId:'one',cwd:'/repo',kind:'role',roleId:'researcher',source:'hook'}); + const base={id:'produce-order',cwd:'/repo',provider:'codex',sessionId:'one',artifactId:'notes',revisionId:'r1',kind:'produced',orderId:'order-one',type:'text'}; + assert.equal(store.accept({...base,id:'bad-scope',orderId:'order-other'}),false);assert.equal(store.accept(base),true); + crew.accept({id:'writer-role-order',provider:'codex',sessionId:'one',cwd:'/repo',kind:'role',roleId:'writer',source:'hook'}); + assert.equal(store.accept({...base,id:'receive-order',kind:'received',handoffId:'within-session',recipientProvider:'codex',recipientSessionId:'one'}),true); + const state=store.snapshot();assert.equal(state.artifacts[0].producerRoleId,'researcher');assert.equal(state.transfers[0].senderRoleId,'researcher');assert.equal(state.transfers[0].recipientRoleId,'writer');assert.equal(state.transfers[0].orderId,'order-one'); +}); diff --git a/packages/kitchen/test/projects.test.mjs b/packages/kitchen/test/projects.test.mjs new file mode 100644 index 0000000..528aca6 --- /dev/null +++ b/packages/kitchen/test/projects.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parsePlan } from '../src/agenttrail/projects.mjs'; +import { kitchenMap } from '../src/agenttrail/kitchens.mjs'; + +const components=parsePlan('## Read input {#input}\nneeds: [api]\nlinks: [view]\nfiles: [src/input/**]\n- [x] Read files {#read}\n by: codex\n from: agent\n## Build API {#api}\n- [~] Return a response {#response}\n## Draw view {#view}\n- [!] Show the result {#result}'); +test('plan relationships and task identity survive parsing',()=>{ + assert.deepEqual(components[0].needs,['api']);assert.deepEqual(components[0].links,['view']); + assert.equal(components[0].tasks[0].id,'read');assert.equal(components[0].tasks[0].by,'codex');assert.equal(components[0].tasks[0].from,'agent'); +}); +test('deliverables span kitchens without duplicating task progress',()=>{ + const m=kitchenMap(components,{version:1,kitchens:[{id:'back',title:'Back kitchen',components:['input','api']},{id:'front',title:'Front kitchen',components:['view']}],deliverables:[{id:'ship',title:'Ship the viewer',tasks:['read','read','response','result']}]}); + assert.deepEqual(m.deliverables[0].counts,{total:3,done:1,active:1,blocked:1});assert.deepEqual(m.deliverables[0].kitchenIds,['back','front']);assert.equal(m.deliverables[0].status,'blocked'); +}); +test('configuration cannot hide components or claim missing work complete',()=>{ + const m=kitchenMap(components,{version:1,kitchens:[{id:'one',components:['input','missing']},{id:'two',components:['input']}],deliverables:[{id:'ship',tasks:['read','unknown']}]}); + assert.deepEqual(m.kitchens.flatMap(k=>k.components).sort(),['api','input','view']);assert.equal(m.deliverables[0].status,'unknown');assert.ok(m.warnings.length>=3); + assert.equal(kitchenMap(components,{version:9}).deliverables.length,3); +}); +test('default kitchens stay stable across activity and handle a planless project',()=>{ + const a=kitchenMap(components),b=kitchenMap(components.map(c=>({...c,tasks:c.tasks.map(t=>({...t,state:'x'}))}))); + assert.deepEqual(a.kitchens.map(k=>[k.id,k.components]),b.kitchens.map(k=>[k.id,k.components]));assert.equal(kitchenMap([]).kitchens.length,1);assert.equal(kitchenMap([]).deliverables.length,0); +}); +test('large configured kitchens split into readable rooms without losing components',()=>{ + const many=Array.from({length:9},(_,i)=>({id:'c'+i,title:'Component '+i,files:[],tasks:[]})); + const result=kitchenMap(many,{version:1,kitchens:[{id:'large',title:'Large kitchen',components:many.map(c=>c.id)}],deliverables:[]}); + assert.deepEqual(result.kitchens.map(k=>k.components.length),[4,4,1]);assert.equal(new Set(result.kitchens.flatMap(k=>k.components)).size,9); +}); + +import {Projects} from '../src/agenttrail/projects.mjs'; +import {CrewStore} from '../src/runtime/crew.mjs'; +function bridgeFixture(){const root='/work/project',store=new CrewStore([root]),projects=new Projects([root],'/home',store);projects.data.set(root,{components:[{id:'ui',title:'Draw UI',files:['public/**'],tasks:[]},{id:'api',title:'Build API',files:['src/**'],tasks:[]},{id:'shared',title:'Shared file',files:['shared.js'],tasks:[]},{id:'overlap',title:'Another owner',files:['shared.js'],tasks:[]}]});return {root,store,projects};} +test('board todos augment a native session without replacing its newer action or copying tool payloads',()=>{ + const {root,store,projects}=bridgeFixture(),at=Date.now(); + store.accept({id:'native',provider:'codex',sessionId:'one',cwd:root,at,source:'log',kind:'tool-start',tool:'Read',file:'public/app.js',toolId:'read'}); + projects.applyBoard(root,{runs:[{id:'one',agent:'codex',lastEventAt:at-10,componentId:'ui',ended:true,currentTool:{name:'Bash',detail:'PRIVATE COMMAND'},todos:[{content:'Keep labels readable',status:'in_progress'}],prompt:'PRIVATE PROMPT'}]}); + const s=projects.enrich(store.snapshot())[0];assert.equal(s.state,'reading');assert.equal(s.ended,false);assert.equal(s.currentTask.title,'Keep labels readable');assert.equal(s.component.id,'ui');assert.equal(s.association.kind,'inferred');assert.ok(!JSON.stringify(s).includes('PRIVATE')); +}); +test('board lifecycle ends imported runs, and overlapping file ownership stays uncertain',()=>{ + const {root,store,projects}=bridgeFixture(),at=Date.now(); + projects.applyBoard(root,{runs:[{id:'board',agent:'claude',lastEventAt:at-1,componentId:'ui',currentTool:{name:'Edit'}}]}); + assert.equal(projects.enrich(store.snapshot())[0].component.id,'ui'); + projects.applyBoard(root,{runs:[{id:'board',agent:'claude',lastEventAt:at,ended:true}]});assert.equal(store.snapshot()[0].ended,true); + store.accept({id:'shared',provider:'codex',sessionId:'two',cwd:root,at,source:'log',kind:'tool-start',tool:'Edit',file:'shared.js'}); + const s=projects.enrich(store.snapshot()).find(s=>s.sessionId==='two');assert.equal(s.component,null);assert.equal(s.association.kind,'unknown');assert.deepEqual(s.componentCandidates,['shared','overlap']); +}); +test('conflicting associations are not silently converted into a confirmed assignment',()=>{ + const {root,store,projects}=bridgeFixture(),at=Date.now(); + store.accept({id:'native',provider:'codex',sessionId:'one',cwd:root,at:at-100,source:'log',kind:'tool-start',tool:'Read',file:'public/app.js'}); + projects.applyBoard(root,{runs:[{id:'one',agent:'codex',lastEventAt:at-50,componentId:'api',todos:[]}]}); + assert.equal(projects.enrich(store.snapshot())[0].component,null); + store.accept({id:'new',provider:'codex',sessionId:'one',cwd:root,at,source:'log',kind:'tool-start',tool:'Edit',file:'public/new.js'}); + assert.equal(projects.enrich(store.snapshot())[0].component.id,'ui'); +}); diff --git a/packages/kitchen/test/routes.test.mjs b/packages/kitchen/test/routes.test.mjs new file mode 100644 index 0000000..29b10cc --- /dev/null +++ b/packages/kitchen/test/routes.test.mjs @@ -0,0 +1,8 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {routeBetween,walkable} from '../public/src/routes.js'; +test('chef routes go around the pass, never through counters',()=>{ + const route=routeBetween([-4.15,1.95],[1.85,-1.8]);assert.ok(route.length>2); + for(let i=1;i{ + const root=await fs.mkdtemp(path.join(os.tmpdir(),'orbit-setup-'));t.after(()=>fs.rm(root,{recursive:true,force:true})); + await fs.mkdir(path.join(root,'.claude')); + const original={permissions:{allow:['Read']},hooks:{Stop:[{hooks:[{type:'command',command:'my-hook'}]}]}}; + const file=path.join(root,'.claude/settings.local.json');await fs.writeFile(file,JSON.stringify(original)); + await installConfig(await hookConfig(root,'claude','office-hook'));await installConfig(await hookConfig(root,'claude','office-hook')); + const installed=JSON.parse(await fs.readFile(file,'utf8'));assert.equal(installed.hooks.Stop.length,2);assert.deepEqual(installed.permissions,original.permissions); + await installConfig(await hookConfig(root,'claude','office-hook',true));assert.deepEqual(JSON.parse(await fs.readFile(file,'utf8')),original); + await fs.writeFile(file,'{broken');await assert.rejects(()=>hookConfig(root,'claude','office-hook'),/not valid JSON/); +}); +test('log reader resumes partial records and merges multiple Codex files for one thread',async t=>{ + const home=await fs.mkdtemp(path.join(os.tmpdir(),'orbit-logs-'));t.after(()=>fs.rm(home,{recursive:true,force:true})); + const root=path.join(home,'project'),dir=path.join(home,'.codex/sessions',...new Date().toISOString().slice(0,10).split('-'));await fs.mkdir(dir,{recursive:true});await fs.mkdir(root,{recursive:true}); + const file=path.join(dir,'one.jsonl'),meta={type:'session_meta',payload:{id:'thread',cwd:root}}; + const row=(type,at)=>JSON.stringify({type:'event_msg',timestamp:new Date(at).toISOString(),payload:{type,turn_id:'turn'}}); + const now=Date.now();await fs.writeFile(file,JSON.stringify(meta)+'\n'+row('task_started',now)+'\n'); + const store=new CrewStore([root]),reader=new LogObserver(home,store);await reader.poll();assert.equal(store.snapshot()[0].state,'working'); + const finish=row('task_complete',now+1);await fs.appendFile(file,finish.slice(0,30));await reader.poll();assert.equal(store.snapshot()[0].state,'working'); + await fs.appendFile(file,finish.slice(30)+'\n');await reader.poll();assert.equal(store.snapshot()[0].state,'complete'); + await fs.writeFile(path.join(dir,'two.jsonl'),JSON.stringify(meta)+'\n'+row('task_started',now+2)+'\n');reader.lastDiscovery=0;await reader.poll();assert.equal(store.snapshot().length,1);assert.equal(store.snapshot()[0].state,'working'); +}); +test('local server authenticates writes, limits scope, streams events and keeps private data out',async t=>{ + const home=await fs.mkdtemp(path.join(os.tmpdir(),'orbit-http-')),root=path.join(home,'project'),stateDir=path.join(home,'state');await fs.mkdir(root); + const office=await startOffice({roots:[root],home,stateDir,port:0,observe:false});t.after(async()=>{await office.close();await fs.rm(home,{recursive:true,force:true});}); + const fetcher=(p,opt)=>fetch(office.url+p,opt); + assert.equal((await fetcher('/')).status,200); + assert.equal((await fetcher('/api/bootstrap',{headers:{Origin:'https://evil.example'}})).status,403); + assert.equal((await fetcher('/api/setup/preview',{method:'POST',body:'{}'})).status,403); + const boot=await fetcher('/api/bootstrap').then(r=>r.json()); + const hook=JSON.parse(await fs.readFile(path.join(stateDir,'server.json'),'utf8')); + assert.ok(!JSON.stringify(boot).includes(hook.hookToken)); + const event={provider:'cursor',id:'fixture',sessionId:'live-session',cwd:root,kind:'tool-start',tool:'Write',toolId:'tool',file:path.join(root,'main.js'),arguments:'secret'}; + assert.equal((await fetcher('/api/hook',{method:'POST',headers:{authorization:'Bearer wrong'},body:JSON.stringify(event)})).status,403); + const headers={authorization:`Bearer ${hook.hookToken}`,'content-type':'application/json'}; + await fetcher('/api/hook',{method:'POST',headers,body:JSON.stringify(event)}); + await fetcher('/api/hook',{method:'POST',headers,body:JSON.stringify(event)}); + const state=await fetcher('/api/state').then(r=>r.json());assert.equal(state.crew.length,4);assert.equal(state.executors.length,1);assert.equal(state.crew.find(c=>c.workingCount).state,'writing');assert.equal(state.executors[0].file,'main.js');assert.ok(!JSON.stringify(state).includes('secret')); + const artifact={id:'output',artifactId:'result',revisionId:'revision-a',kind:'produced',provider:'cursor',sessionId:'live-session',cwd:root,type:'json',file:'result.json',body:'private-artifact-content'}; + assert.equal((await fetcher('/api/artifact',{method:'POST',body:JSON.stringify(artifact)})).status,403); + assert.equal((await fetcher('/api/artifact',{method:'POST',headers,body:JSON.stringify(artifact)}).then(r=>r.json())).accepted,true); + assert.equal((await fetcher('/api/artifact',{method:'POST',headers,body:JSON.stringify({...artifact,id:'receipt',kind:'received',handoffId:'delivery',recipientProvider:'claude',recipientSessionId:'consumer'})}).then(r=>r.json())).accepted,true); + const plates=await fetcher('/api/state').then(r=>r.json());assert.equal(plates.artifacts[0].revisionId,'revision-a');assert.equal(plates.transfers[0].state,'received');assert.ok(!JSON.stringify(plates).includes('private-artifact-content')); + const outside=await fetcher('/api/hook',{method:'POST',headers,body:JSON.stringify({...event,id:'outside',cwd:home})}).then(r=>r.json());assert.equal(outside.accepted,false); + const abort=new AbortController(),stream=await fetcher('/api/events',{signal:abort.signal});const reader=stream.body.getReader();const message=await reader.read();assert.match(new TextDecoder().decode(message.value),/live-session/);abort.abort(); + const setupHeaders={'x-office-token':boot.token,'content-type':'application/json'}; + const preview=await fetcher('/api/setup/preview',{method:'POST',headers:setupHeaders,body:JSON.stringify({project:root,provider:'cursor'})}).then(r=>r.json()); + assert.equal((await fetcher('/api/setup/apply',{method:'POST',headers:setupHeaders,body:JSON.stringify({project:root,provider:'cursor',revision:'outdated'})})).status,409); + assert.equal((await fetcher('/api/setup/apply',{method:'POST',headers:setupHeaders,body:JSON.stringify({project:root,provider:'cursor',revision:preview.revision})})).status,200); +}); diff --git a/packages/kitchen/test/workflow.test.mjs b/packages/kitchen/test/workflow.test.mjs new file mode 100644 index 0000000..4398fa3 --- /dev/null +++ b/packages/kitchen/test/workflow.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {workflowCrew,workflowPlates,roleForSession} from '../src/runtime/workflow-crew.mjs'; +import {workflowConfig,workflowStations,WorkflowQueues} from '../src/agenttrail/workflows.mjs'; +import {parsePlan} from '../src/agenttrail/projects.mjs'; +import {kitchenMap} from '../src/agenttrail/kitchens.mjs'; +import {CrewStore} from '../src/runtime/crew.mjs'; +import {startOffice} from '../src/server.mjs'; +import {activityText,goalCards,rankedGoals,IdentityBook} from '../public/src/activity.js'; +import {stationLayout} from '../public/src/layout.js'; +import {routeBetween,walkable} from '../public/src/routes.js'; + +const components=['research','radar','write','evaluate','queue','publisher','distill','manager'].map(id=>({id,title:id,files:[],tasks:[{id:id+'-task',title:'Work on '+id,state:'x'}],kind:id==='queue'?'human':id==='distill'?'knowledge':undefined})); +const config=workflowConfig(components,null,'Reddit loop'),p={id:'/project',components,...kitchenMap(components,config),workflow:config.workflow}; +const session=(extra={})=>({id:'codex:one',sessionId:'one',provider:'codex',project:p.id,state:'writing',freshness:'recent',lastEventAt:Date.now(),recent:[],...extra}); + +test('a coherent Reddit workflow gets five persistent roles and distinct human and knowledge stations',()=>{ + assert.deepEqual(p.workflow.roles.map(r=>r.id),['researcher','writer','evaluator','publisher','head-chef']); + assert.equal(p.kitchens.length,1);assert.equal(p.kitchens[0].components.length,8); + assert.equal(workflowStations(components,p.workflow.roles).length,7); + const crew=workflowCrew([p],[]);assert.equal(crew.length,5);assert.ok(crew.every(c=>c.state==='idle'&&c.executors.length===0)); + const cards=goalCards(p,crew);assert.equal(rankedGoals(cards,{kitchenId:p.kitchens[0].id}).length,8); + assert.equal(cards.find(c=>c.id==='queue').chefs.length,0);assert.equal(cards.find(c=>c.id==='distill').chefs[0].roleId,'head-chef'); + const activeCards=goalCards(p,workflowCrew([p],[session({component:{id:'research'}})])); + assert.equal(activeCards.find(c=>c.id==='research').chefs[0].state,'writing'); + assert.equal(activeCards.find(c=>c.id==='radar').chefs[0].state,'idle'); + assert.match(activeCards.find(c=>c.id==='radar').chefs[0].roleStatus,/Working on research/); +}); +test('generic projects adapt roles to components and configuration remains optional and compatible',()=>{ + const nodes=parsePlan('## Draw things {#draw}\n- [x] Paint {#paint}\n## Review {#review}\nkind: human\nurl: http://localhost:5350\n## Store context {#context}\nkind: knowledge'); + assert.equal(nodes[1].kind,'human');assert.equal(nodes[1].url,'http://localhost:5350'); + const generated=workflowConfig(nodes,null,'Canvas');assert.deepEqual(generated.workflow.roles.map(r=>r.id),['draw']);assert.equal(generated.workflow.id,'project'); + assert.equal(workflowConfig(nodes,{version:1,kitchens:[],deliverables:[],workflow:false},'Canvas').workflow,false); + assert.equal(workflowConfig([],null,'Empty'),null); +}); +test('one session changes roles without changing the lineup or its visual identities',()=>{ + const book=new IdentityBook(),first=book.assign(workflowCrew([p],[session({component:{id:'write'}})])); + const second=book.assign(workflowCrew([p],[session({component:{id:'evaluate'},state:'reading'})])); + assert.equal(first.length,5);assert.equal(second.length,5); + assert.deepEqual(first.map(c=>[c.id,c.visualIndex,c.badge]),second.map(c=>[c.id,c.visualIndex,c.badge])); + assert.equal(second.find(c=>c.roleId==='writer').state,'idle');assert.equal(second.find(c=>c.roleId==='evaluator').state,'reading'); + assert.match(activityText(second.find(c=>c.roleId==='evaluator')),/Codex/); +}); +test('explicit role wins over reading another role input, with concurrency and unknown links kept visible',()=>{ + const writer=session({roleBinding:{roleId:'writer',workflowId:'project'},component:{id:'research'},currentTask:{title:'Work on research'}}); + assert.equal(roleForSession(p,writer).role.id,'writer'); + const crew=workflowCrew([p],[writer,session({id:'claude:two',sessionId:'two',provider:'claude',component:{id:'write'}}),session({id:'cursor:unknown',roleBinding:{roleId:'missing'},component:{id:'write'}})]); + const chef=crew.find(c=>c.roleId==='writer');assert.equal(chef.executors.length,2);assert.equal(chef.workingCount,2);assert.equal(crew.filter(c=>c.unlinkedRole).length,1); + assert.equal(roleForSession(p,session({currentTask:{title:'Work on evaluate'}})).role.id,'evaluator'); + const quiet=workflowCrew([p],[session({component:{id:'write'},freshness:'quiet'})]).find(c=>c.roleId==='writer');assert.equal(quiet.workingCount,0);assert.match(activityText(quiet),/Last seen/); +}); +test('binding events cannot invent work, revive stale observations, cross projects or rewind bindings',()=>{ + const now=Date.now(),store=new CrewStore([p.id,'/another'],()=>now); + const base={provider:'codex',sessionId:'one',cwd:p.id,source:'hook',at:now-200_000}; + assert.equal(store.accept({...base,id:'no-session',kind:'role',roleId:'writer'}),false); + store.accept({...base,id:'start',kind:'turn-start'}); + store.accept({...base,id:'binding',at:now-10,kind:'role',roleId:'writer'}); + assert.equal(store.snapshot()[0].freshness,'quiet'); + assert.equal(store.accept({...base,id:'old-binding',at:now-20,kind:'role',roleId:'evaluator'}),false); + assert.equal(store.accept({...base,id:'cross-project',at:now,kind:'role',cwd:'/another',roleId:'evaluator'}),false); + store.accept({...base,id:'clear',at:now,kind:'role',roleId:null});assert.equal(store.snapshot()[0].roleBinding,null); +}); +test('expanded station positions retain reachable counter routes without crossing the pass',()=>{ + const layout=stationLayout(7);assert.equal(layout.columns,4);assert.equal(layout.positions.length,8); + const route=routeBetween([-8.1,-2.32],[8.1,2.42],layout.walkWidth);assert.ok(route.length>2); + for(let i=1;i{ + const root=await fs.mkdtemp(path.join(os.tmpdir(),'kitchen-queue-'));t.after(()=>fs.rm(root,{recursive:true,force:true})); + await Promise.all(['drafts','evals','publisher'].map(d=>fs.mkdir(path.join(root,d)))); + const draft=(approved,body)=>`---\ntitle_a: Test item\nstatus: draft\napproved: ${approved}\ncreated_at: 2026-09-08T12:00:00Z\n---\n${body}`; + await fs.writeFile(path.join(root,'drafts/one.md'),draft('no','PRIVATE BODY'));await fs.writeFile(path.join(root,'evals/one.md'),'Verdict: SHIP'); + const adapter=new WorkflowQueues();let queue=await adapter.snapshot(root);assert.equal(queue.counts.queue,1);assert.ok(!JSON.stringify(queue).includes('PRIVATE BODY')); + await fs.writeFile(path.join(root,'drafts/one.md'),draft('yes','PRIVATE BODY'));queue=await adapter.snapshot(root);assert.equal(queue.counts.publisher,1);assert.equal(queue.items[0].stale,false); + await fs.writeFile(path.join(root,'drafts/one.md'),draft('yes','CHANGED PRIVATE BODY'));queue=await adapter.snapshot(root);assert.equal(queue.counts.evaluate,1);assert.equal(queue.items[0].stale,true); + await fs.writeFile(path.join(root,'evals/one.md'),'New review\nVerdict: REVISE');queue=await adapter.snapshot(root);assert.equal(queue.counts.write,1); + await fs.writeFile(path.join(root,'drafts/closed.md'),'---\nstatus: abandoned\n---\nPRIVATE');queue=await adapter.snapshot(root);assert.equal(queue.counts.history,1); + assert.equal(workflowPlates([{...p,workflow:{...p.workflow,queue}}]).length,2); + await fs.writeFile(path.join(root,'drafts/unknown.md'),'incomplete draft');queue=await adapter.snapshot(root);assert.equal(queue.counts.unknown,1); +}); +test('the server exposes several role chefs with one real execution stream',async t=>{ + const home=await fs.mkdtemp(path.join(os.tmpdir(),'kitchen-workflow-http-')),root=path.join(home,'project'),stateDir=path.join(home,'state');await fs.mkdir(root); + await fs.writeFile(path.join(root,'PLAN.md'),'## Research {#research}\nfiles: [research/**]\n## Write {#write}\nfiles: [drafts/**]\n## Evaluate {#evaluate}\nfiles: [evals/**]'); + const office=await startOffice({roots:[root],home,stateDir,port:0,observe:false});t.after(async()=>{await office.close();await fs.rm(home,{recursive:true,force:true});}); + const registration=JSON.parse(await fs.readFile(path.join(stateDir,'server.json'),'utf8')),headers={'content-type':'application/json',authorization:`Bearer ${registration.hookToken}`}; + const send=async e=>fetch(office.url+'/api/hook',{method:'POST',headers,body:JSON.stringify({provider:'codex',sessionId:'one',cwd:root,...e})}).then(r=>r.json()); + assert.equal(office.snapshot().crew.length,3); + await send({id:'read',kind:'tool-start',tool:'Read',file:'research/notes.md'});assert.equal(office.snapshot().crew.find(c=>c.roleId==='research').state,'reading'); + await send({id:'role',kind:'role',roleId:'write'});assert.equal(office.snapshot().crew.find(c=>c.roleId==='write').state,'reading');assert.equal(office.snapshot().crew.find(c=>c.roleId==='research').state,'idle'); + await send({id:'finish',kind:'turn-end'});assert.equal(office.snapshot().crew.length,3);assert.equal(office.snapshot().executors.length,1); +});