From 1ea84305e4c95b703e354ea3601cd59d8bf8419e Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Thu, 7 May 2026 11:09:51 -0500 Subject: [PATCH 01/33] v2 --- orchestrator-prd-v1.md | 1247 +++++++++++++++++++++++++++++++ orchestrator-prd-v2.md | 1181 +++++++++++++++++++++++++++++ prd-lvl3-agent-definition-v4.md | 707 ++++++++++++++++++ 3 files changed, 3135 insertions(+) create mode 100644 orchestrator-prd-v1.md create mode 100644 orchestrator-prd-v2.md create mode 100644 prd-lvl3-agent-definition-v4.md diff --git a/orchestrator-prd-v1.md b/orchestrator-prd-v1.md new file mode 100644 index 0000000..bf273e4 --- /dev/null +++ b/orchestrator-prd-v1.md @@ -0,0 +1,1247 @@ +# Unified Orchestrator PRD v1 + +**Status:** Draft +**Authors:** Eric Siebeneich, Matthew Wright, Alexander Reeves +**Date:** 2026-05-07 +**Version:** 1.0 + +--- + +## Product Description, Problem, and Goal + +### Product Description + +The **Bifrost Orchestrator** is a Python-based distributed task execution system that coordinates **AI agents** to perform work across multiple projects. It provides a plugin architecture with **Task Sources** (providers of work) and **Engines** (executors of work), connected by a core orchestration layer that manages task lifecycle, state transitions, and telemetry. + +The system implements a **multi-level AI factory model**: + +- **Level 2 (skill):** Language- or tool-specific knowledge capsule. Contains a prompt describing how to write code in a specific language, a **toolClass** registry mapping roles (formatter, linter, testFramework, build) to named tools, and file-level hooks that validate output. A skill is agnostic to any task — it only answers: "how do you write X?" +- **Level 3 (task agent):** A **parameterized, task-focused agent** that accepts a **unit of work (UoW)** with pre-populated `taskState` and executes one discrete workflow (e.g., BDD Red phase). It is language-agnostic by design — language, framework, and style arrive via `taskState` at dispatch time. +- **Level 4 (orchestrator program):** Built using the orchestrator framework. Validates that the incoming UoW `taskState` satisfies the target agent's parameter schema, then dispatches the agent. The orchestrator does NOT derive or populate `taskState` values — that is the responsibility of whoever produced the UoW (a human, a CI system, another UoW agent, etc.). +- **Level 5 (meta-agent):** Can augment or modify skill and agent definitions (e.g., add timing hooks, update prompts after evals). + +**Key Terms:** + +- **Rune**: A unit of work (task) to be executed by an agent, containing title, description, tags, and metadata +- **Agent**: An AI worker with specific capabilities (model, tools, prompt) that executes runes +- **AGENT.md**: Markdown file with YAML frontmatter that fully describes a Level 3 agent's contract +- **taskState**: Free-form object containing all context for a single task execution, including language, framework, and cross-hook state +- **Task Source**: Plugin that discovers, claims, and fulfills runes from an external system (e.g., Bifrost API) +- **Engine**: Plugin that executes runes using a specific mechanism (e.g., Claude Code CLI) +- **Task State Store**: Plugin that persists and retrieves taskState across hook executions +- **Hook**: Shell command or Node.js script executed before (RuneStart) or after (RuneStop) agent execution +- **repo script**: Hook script that belongs to a specific working repository, installed to `.ai//hooks/` +- **repoConfig**: YAML file committed to working repository declaring languages and tools +- **toolClass**: Role identifier (formatter, linter, testFramework, build) for tool resolution +- **Claimant**: Identifier for the agent instance currently working on a rune +- **projectDir**: Git root of the working repository, resolved automatically +- **Orchestration**: The process of coordinating hooks, engine execution, and follow-up loops +- **Follow-up**: Additional agent execution triggered by RuneStop hooks to address issues +- **Handlebars**: Template syntax used in AGENT.md prompt bodies for taskState substitution + +### Problem + +Sarah is a platform engineer managing a monorepo with 50+ services. Her team has implemented AI agents to help with routine maintenance tasks (dependency updates, code refactors, security patches). Each agent works great in isolation, but Sarah has five critical problems: + +1. **No coordination**: Multiple agent instances compete for the same tasks, causing duplicate work +2. **No observability**: When an agent fails, there's no telemetry—did it timeout? run out of tokens? hit a bug? +3. **No integration**: Agents can't validate work with project-specific checks (running tests, linters) before marking tasks complete +4. **Tight coupling**: Agents hard-code language and framework details. When the team adds a C# service, Sarah must maintain separate C# agents +5. **No state sharing**: Hooks can't pass data to each other—snapshot-tests captures file hashes but check-new-tests can't read them + +Marcus, a senior engineer on Sarah's team, wants to automate the BDD Red phase across the polyglot monorepo. He writes a BDD-Red agent prompt that hard-codes Python and pytest — but three weeks later the team adds a C# service. Marcus must now maintain two nearly identical agents. When a teammate writes a BDD-Green agent, they hard-code it for TypeScript/Vitest. The agents are not composable: changing the test framework means editing every agent. Hooks that enforce correctness are written as ad-hoc shell scripts with no documented contract, so no one knows which exit code means what. + +Sarah spends hours manually deconflicting agents, digging through logs to understand failures, and manually validating work before merging. She can't scale her automation beyond a few agents without creating more work than she saves. + +### Goal + +With Bifrost Orchestrator, Sarah deploys a single orchestrator instance per project. She configures a **Task Source** that connects to her Bifrost API server, and an **Engine** that uses Claude Code CLI. When a rune is ready, the orchestrator: + +1. Claims the rune (preventing duplicate work) +2. Runs **RuneStart hooks** (project-specific validation, prompt augmentation) +3. Executes the rune with the appropriate agent (model, tools, prompt from agent catalog) +4. Runs **RuneStop hooks** (test suites, linters, custom checks) +5. If hooks report issues, triggers a **follow-up** loop to address them +6. Marks the rune complete with full telemetry (tokens used, duration, cost) + +Marcus defines one BDD-Red agent. Its AGENT.md declares the `taskState` fields it requires: `language`, `testFramework`, and `testStyle`. Whatever process creates the UoW — a human, a CI trigger, or another agent — fills those fields before handing it to the orchestrator. The orchestrator validates the schema and dispatches. The same BDD-Red agent handles the C# service when a UoW arrives with C# / XUnit / Gherkin in taskState. Hooks are declared in the AGENT.md with a defined stdin schema and exit code contract. Repo scripts are automatically installed to the working repository's `.ai/` directory on first run. Hooks communicate via taskState, so snapshot-tests writes file hashes that check-new-tests reads. + +Sarah now has reliable, scalable, composable automation. She can deploy multiple agents knowing the orchestrator will coordinate them. She has full visibility into execution. She trusts the automation because hooks validate work before completion. Marcus's team ships BDD Red across every language without touching the agent definition. + +--- + +## User Stories / Use Cases + +### US-1: Define a task agent with AGENT.md + +**As an** agent operator +**I want** to write an AGENT.md with a documented parameter schema, allowed tools, hook lifecycle specs, and a Handlebars prompt body +**So that** task agents are composable and language-agnostic + +**Acceptance Criteria:** + +``` +Given an AGENT.md file with valid YAML frontmatter + And a prompt body containing Handlebars tokens matching declared template parameters +When an orchestrator program reads the file +Then the agent name, description, tools, toolClasses, template parameter schema, and prompt body are all accessible as structured data +``` + +``` +Given an AGENT.md missing a required frontmatter field (name, description, or tools) +When an orchestrator program reads the file +Then parsing fails with a descriptive error naming the missing field + And the agent is not dispatched +``` + +``` +Given an AGENT.md template.parameters section where a field name ends with ? +When the parameter schema is parsed +Then that field is marked optional + And the Handlebars renderer does not error if that field is absent from taskState + And an absent optional field renders as empty string +``` + +``` +Given a template parameter declared as optional (name ends with ?) + And that parameter is an object with one or more sub-fields whose names do not end with ? +When taskState provides that optional parameter +Then all non-? sub-fields of that object must be present and non-empty + And validation fails naming any absent required sub-field by its dot-notation path +When taskState omits the optional parameter entirely +Then no validation error is raised for that parameter or any of its sub-fields +``` + +``` +Given a prompt body referencing a Handlebars token not declared in template.parameters +When the AGENT.md is parsed +Then parsing fails identifying the undeclared token by name +``` + +### US-2: Agent Operator - Run Agent on Claimed Rune + +**As an** agent operator +**I want** to claim a rune and execute it with the appropriate agent +**So that** work is completed without duplicate execution + +**Acceptance Criteria:** + +``` +Given a rune is available with status "open" + And the rune has a "worker:reviewer" tag + And the reviewer agent is configured in the agent catalog + And no other claimant is set on the rune +When the orchestrator polls the task source +Then the rune is claimed with the current claimant identifier + And RuneStart hooks are executed in sequence + And the reviewer agent is invoked with the rune context + And RuneStop hooks are executed in sequence + And the rune is marked complete + And execution telemetry is recorded +``` + +``` +Given a rune is already claimed by another process +When the orchestrator polls the task source +Then the rune is skipped + And no execution occurs +``` + +``` +Given a rune has no worker tag (no "worker:*" in tags) +When the dispatcher receives the rune +Then an empty command is emitted + And the rune is not claimed +``` + +### US-3: Project Maintainer - Extend Agent with Hooks + +**As a** project maintainer +**I want** to attach shell commands to agent lifecycle events +**So that** agents can validate work and augment prompts + +**Acceptance Criteria:** + +``` +Given an agent has RuneStart hooks configured +And a hook command is configured as "npm run check-deps" +When the orchestrator executes the agent +Then the hook is executed with rune context as JSON stdin +And the hook environment includes CLAUDE_PROJECT_DIR +And hook exit code 0 results in continuation +And hook exit code -2 skips agent execution +And hook exit code > 0 aborts with failure +And hook stdout is appended to the system prompt +``` + +``` +Given an agent has RuneStop hooks configured +And a hook command is configured as "pytest" +When the orchestrator completes agent execution +Then the hook is executed with rune context and last agent message +And hook exit code 0 results in SUCCESS outcome +And hook exit code -2 results in SKIP_FULFILL outcome +And hook exit code 1 results in FOLLOW_UP outcome with hook output as message +And hook exit code 2 results in BLOCKING_FAILURE outcome +``` + +``` +Given multiple RuneStop hooks are configured +And the first hook succeeds (exit 0) +And the second hook returns FOLLOW_UP (exit 1) +When the orchestrator evaluates hook results +Then execution continues to the follow-up loop +And subsequent hooks are not executed +``` + +### US-4: First run installs repo scripts into the working repository + +**As a** developer running `bf orchestrate` against a working repository for the first time +**I want** repo scripts for all built-in agents to be automatically hard-copied into the working repository and staged for commit +**So that** working repositories are ready for agent dispatch without manual file management + +**Acceptance Criteria:** + +``` +Given an orchestrator program with one or more built-in agents that have repo scripts + And a working repository that has not previously been initialized by this orchestrator +When bf orchestrate runs against the working repository for the first time +Then each repo script is hard-copied to .ai//hooks/.d/.mjs + And no symlinks are created + And the orchestrator logs each installed path +``` + +``` +Given a working repository that has already been initialized + And a repo script already exists at the expected path +When bf orchestrate runs again +Then the existing script is not overwritten + And the orchestrator logs that the script is already present +``` + +``` +Given repo scripts installed by bf orchestrate +When a developer runs git status in the working repository +Then the installed .mjs files appear as new untracked files + And no other files in the working repository are modified by the install step +``` + +### US-5: Dispatch an agent with a pre-populated unit of work + +**As a** Level 4 orchestrator program +**I want** to validate an incoming UoW's `taskState` against the target agent's parameter schema and dispatch only when all required fields are satisfied +**So that** the orchestrator remains a thin validation and dispatch layer + +**Acceptance Criteria:** + +``` +Given a Level 3 agent with a declared template.parameters schema + And a UoW whose taskState satisfies all required parameters recursively +When the orchestrator program dispatches +Then the rendered prompt is injected into the agent's context + And the agent begins work +``` + +``` +Given a UoW whose taskState is missing a required parameter +When the orchestrator program attempts dispatch +Then the Start hook validate-args exits with code 2 + And the agent does not execute any prompt + And the error identifies the missing field by its dot-notation path +``` + +``` +Given a template parameter that is an optional object (name ends with ?) + And the UoW taskState provides that object + And the object is missing a required sub-field (sub-field name does not end with ?) +When validate-args runs +Then validation fails identifying the missing sub-field by its dot-notation path +``` + +``` +Given a UoW taskState where a required field is present but set to empty string +When validate-args runs +Then validation fails as if the field were absent +``` + +``` +Given a UoW with an incomplete taskState +When the orchestrator program receives it +Then the orchestrator does not read repoConfig, inspect the workspace, or call any external service to fill in missing values + And it fails validation and returns the error to the caller +``` + +### US-6: Platform Engineer - Observe Agent Execution + +**As a** platform engineer +**I want** to collect telemetry from agent executions +**So that** I can debug failures and optimize costs + +**Acceptance Criteria:** + +``` +Given an agent executes successfully +When the orchestration completes +Then execution telemetry includes: duration_ms, input_tokens, output_tokens +And telemetry includes: cache_read_tokens, cache_creation_tokens +And telemetry includes: total_cost_usd, num_turns +And telemetry is appended as a completion note on the rune +``` + +``` +Given an agent executes with follow-up +When multiple engine executions occur +Then telemetry is accumulated across all executions +And cumulative telemetry is reported on completion +``` + +### US-7: System Administrator - Configure Multiple Sources and Engines + +**As a** system administrator +**I want** to configure task sources, engines, and task state stores via YAML +**So that** the orchestrator works with different backends + +**Acceptance Criteria:** + +``` +Given a .bifrost.yaml configuration file +And orchestrate.task_source.type is "bifrost" +And orchestrate.task_source.settings.base_url is "https://api.example.com" +And orchestrate.task_source.settings.poll_interval is 30 +When the orchestrator loads configuration +Then a BifrostTaskSource is created with the specified base_url +And the task source polls every 30 seconds +``` + +``` +Given a .bifrost.yaml configuration file +And orchestrate.engine.type is "claude-code" +And orchestrate.engine.settings.claude_dir is "/custom/.claude" +When the orchestrator loads configuration +Then a ClaudeCodeEngine is created with the specified claude_dir +``` + +``` +Given a .bifrost.yaml configuration file +And orchestrate.task_state_store.type is "redis" +And orchestrate.task_state_store.settings.url is "redis://localhost:6379" +When the orchestrator loads configuration +Then a RedisTaskStateStore is created with the specified URL +``` + +``` +Given an unknown task source type is configured +When the orchestrator attempts to create the task source +Then a ValueError is raised with message "Unknown task source type: {type}" +``` + +### US-8: Developer - List Available Agents + +**As a** developer +**I want** to list all configured agents and their capabilities +**So that** I can choose the right agent for a task + +**Acceptance Criteria:** + +``` +Given the agent catalog contains agents +And agent "reviewer" has description, model, tools, and hooks +When the dispatcher is invoked with --list-agents +Then each agent name is printed +And agent description is printed if present +And agent model is printed if present +And agent tools are printed as comma-separated list if present +And rune_start_hooks are printed as comma-separated list if present +And rune_stop_hooks are printed as comma-separated list if present +``` + +``` +Given the agent catalog is empty +When the dispatcher is invoked with --list-agents +Then "No agents found." is printed to stderr +``` + +### US-9: projectDir resolved from git root of CWD + +**As a** developer running `bf orchestrate` from within a working repository +**I want** the orchestrator program to automatically resolve the working repository root by walking up from the current working directory +**So that** running the orchestrator requires no path arguments and works from any subdirectory + +**Acceptance Criteria:** + +``` +Given a developer runs bf orchestrate from a directory inside a git repository +When the orchestrator program starts +Then projectDir is set to the git root of the directory bf orchestrate was invoked from + And no --projectDir argument is required +``` + +``` +Given a developer runs bf orchestrate from a directory that is not inside any git repository +When the orchestrator program starts +Then it exits with a descriptive error stating that no git root could be found + And no agent dispatch occurs +``` + +``` +Given a git repository rooted at /home/user/myrepo + And a developer runs bf orchestrate from /home/user/myrepo/src/lib +When the orchestrator program starts +Then projectDir is /home/user/myrepo +``` + +### US-10: Task State persistence across hook executions + +**As a** hook author +**I want** hooks to read and write taskState that persists across hook executions +**So that** hooks can coordinate their behavior without side effects + +**Acceptance Criteria:** + +``` +Given a Start hook that writes taskState.snapshotTests = { "test.js": "hash123" } +When the Start hook completes +Then the Task State Store persists the updated taskState +And the Stop hook can read taskState.snapshotTests in a subsequent execution +``` + +``` +Given a taskState that was previously persisted +When a new hook execution begins +Then the Task State Store loads the persisted taskState +And the hook receives the updated taskState via stdin +``` + +``` +Given the Task State Store is unavailable +When a hook attempts to read or write taskState +Then the hook execution fails with a descriptive error +And the orchestrator logs the failure +And the rune is marked as failed +``` + +--- + +## Functional Requirements + +### FR-1: Task Source Interface + +The system MUST implement the `TaskSource` interface with the following methods: + +- `async watch_tasks() -> AsyncIterator[Task]`: Continuously poll for available tasks +- `async get_task_detail(task_id: str) -> TaskDetail`: Retrieve full task details +- `async claim_task(task_id: str, claimant: str) -> bool`: Claim exclusive ownership +- `async unclaim_task(task_id: str) -> bool`: Release ownership +- `async complete_task(task_id: str) -> bool`: Mark task as fulfilled + +Task Status enum values: +- `OPEN`: Task is available for claiming +- `IN_PROGRESS`: Task is claimed and being executed +- `COMPLETED`: Task is fulfilled +- `FAILED`: Task execution failed +- `CANCELLED`: Task was cancelled + +### FR-2: Engine Interface + +The system MUST implement the `Engine` interface with the following methods: + +- `async execute(context: EngineContext, task_data: dict) -> EngineResult`: Execute a task +- `async send_follow_up(message: str) -> EngineResult`: Optional method for follow-up execution + +EngineContext MUST contain: +- `task_id: str`: Unique task identifier +- `working_dir: str`: Project directory for execution +- `agent_name: str`: Name of the agent to use +- `verbose: bool`: Enable verbose logging + +EngineResult MUST contain: +- `success: bool`: Whether execution succeeded +- `skip_fulfill: bool`: Whether to skip marking the task complete +- `last_message: str | None`: Final message from the agent +- `stats: ExecutionStats | None`: Telemetry data + +ExecutionStats MUST contain: +- `duration_ms: int`: Execution duration in milliseconds +- `input_tokens: int`: Input tokens consumed +- `output_tokens: int`: Output tokens consumed +- `cache_read_tokens: int`: Cache read tokens +- `cache_creation_tokens: int`: Cache creation tokens +- `total_cost_usd: float`: Total cost in USD +- `num_turns: int`: Number of conversation turns + +### FR-3: Task State Store Interface + +The system MUST implement the `TaskStateStore` interface with the following methods: + +- `async load_task_state(task_id: str) -> dict | None`: Load taskState for a task +- `async save_task_state(task_id: str, task_state: dict) -> bool`: Persist taskState for a task +- `async delete_task_state(task_id: str) -> bool`: Delete taskState for a task +- `async initialize_task_state(task_id: str, initial_state: dict) -> bool`: Initialize taskState for a new task + +TaskStateStore implementations MUST be thread-safe and support concurrent access. + +### FR-4: Agent Definition File (AGENT.md) + +Format: Markdown file with YAML frontmatter delimited by `---`. The prompt body follows the closing `---`. + +Required frontmatter fields: + +| Field | Type | Description | +|---|---|---| +| `name` | string | Unique agent identifier (kebab-case) | +| `description` | string | One-line description used for orchestrator routing | +| `tools` | string[] | Explicit allowlist of Claude Code tools this agent may use | +| `toolClasses` | string[] | Optional. Tool role types this agent requires. Informational — documents what must be available. | +| `template.parameters` | object | Free-form YAML shape declaring the taskState structure this agent expects | + +The `tools` list is a strict allowlist enforced by the harness. Bash is not implicitly permitted. Any tool not listed is denied regardless of what the prompt requests. + +### FR-5: template.parameters Schema Rules + +`template.parameters` is a free-form YAML object of any shape. The following conventions govern optionality: + +- A field whose key ends with `?` is **optional**. It may be absent from `taskState` without causing a validation error. When absent, its Handlebars token renders as empty string. +- A field whose key does not end with `?` is **required**. It must be present and non-empty in `taskState`. +- Rules apply recursively: if an optional object is provided, all of its non-`?`-suffixed sub-fields are required. If an optional object is absent, none of its sub-fields are evaluated. +- Scalar values (e.g., `string`) are type hints only and are not enforced at runtime. +- Prompt authors use `{{#if paramName}}...{{/if}}` to guard sections that depend on optional parameters. + +Example: +```yaml +template: + parameters: + language: + name: string + prompt: string + testFramework: + name: string + prompt: string + testStyle: + name: string + prompt: string + userPrompt: string + context?: + prDescription: string + additionalNotes?: string +``` + +In this example: `language`, `testFramework`, `testStyle`, and `userPrompt` are required. `context` is optional; if provided, `context.prDescription` is required and `context.additionalNotes` is optional. + +Handlebars tokens in the prompt body must match declared parameter paths exactly. Unknown tokens are a parse-time error. + +### FR-6: projectDir Resolution + +`projectDir` is not passed as a CLI argument. When `bf orchestrate` is invoked, the orchestrator program walks up from the current working directory to find the nearest ancestor directory containing a `.git` folder. That directory becomes `projectDir` for the duration of the run. If no git root is found, the program exits with a fatal error. + +### FR-7: Orchestrator Framework and Orchestrator Program + +The **orchestrator framework** is a Python/Node.js hybrid monorepo supporting both runtime environments: + +``` +orchestrator-framework/ + package.json # Python: pyproject.toml with workspaces + node_modules/ # shared, hoisted + packages/ + bdd-red -> /path/to/bdd-red # symlink + bdd-green -> /path/to/bdd-green +``` + +An **orchestrator program** is built on this framework and embeds a chosen set of agents. When `bf orchestrate` runs against a working repository for the first time, it performs a one-time install of all built-in repo scripts before any dispatch occurs. + +### FR-8: Working Repository Layout (after install) + +``` +/ + repoConfig.yaml + .ai/ + / + hooks/ + Start.d/ + .mjs # hard-copied, committed + Stop.d/ + .mjs +``` + +Repo scripts are hard-copied (never symlinked) and committed. The orchestrator program imports them via dynamic `import()` using the absolute path resolved from `projectDir + "/.ai//hooks/..."`. Their dependencies are declared in the orchestrator program's `package.json` and resolved from its `node_modules`. + +### FR-9: Agent Package Layout + +``` +/ + AGENT.md + package.json + hooks/ + Start.d/ + .mjs # provided script + .md # Gherkin acceptance spec + Stop.d/ + .mjs + .md +``` + +Hook execution order within each `.d/` directory: alphabetical by filename. + +### FR-10: Hook Contract + +**stdin (JSON):** +```json +{ + "projectDir": "string", + "params": {}, + "taskState": {} +} +``` + +The rendered agent prompt is NOT included. Cross-hook state is communicated via `taskState` mutations passed through to subsequent hooks. + +**Exit codes:** + +| Code | Meaning | Effect | +|---|---|---| +| 0 | Success | Proceed | +| 1 | Recoverable error | Hook stdout passed to agent as context; execution continues | +| 2 | Fatal error | Agent halts; error surfaced to orchestrator caller | + +**Script format:** `.mjs` (ES module) or executable shell script. Executed in the orchestrator program's runtime context via dynamic `import()` or subprocess execution. + +### FR-11: Built-in Hook Specs + +| Hook | Lifecycle | Type | Purpose | +|---|---|---|---| +| `validate-args` | Start | framework | Assert all required taskState fields are non-empty per declared schema | +| `snapshot-tests` | Start | framework | Hash existing test files into taskState | +| `check-new-tests` | Stop | framework | Assert at least one new test was added since snapshot | +| `lint` | Stop | repo script | Run project linter (resolved from repoConfig `linter` toolClass) | +| `format` | Stop | repo script | Run project formatter (resolved from repoConfig `formatter` toolClass) | + +Framework hooks run from the orchestrator's `packages/` context. Repo scripts are loaded from the working repository's `.ai/` directory via dynamic `import()`. + +### FR-12: repoConfig.yaml + +```yaml +languages: + - name: string # e.g., "csharp", "node" + version: string # semver range + tools: + - toolClass: string # "formatter" | "linter" | "testFramework" | "build" + name: string + version: string # optional +``` + +When multiple tools share the same `toolClass` within one language entry, the first entry wins and a warning is emitted identifying the language name, the conflicting toolClass, and the line numbers of both entries. + +### FR-13: Configuration + +Configuration file `.bifrost.yaml` MUST support: + +```yaml +orchestrate: + task_source: + type: bifrost + settings: + base_url: string + timeout: int (seconds) + poll_interval: int (seconds) + + engine: + type: claude-code + settings: + claude_dir: path + verbose: bool + + task_state_store: + type: redis | memory | file + settings: + # type-specific settings + + concurrency: int + claimant: string | null + dispatcher: path + logging: normal | verbose +``` + +### FR-14: Dispatcher Protocol + +Dispatcher MUST read `DispatchInput` from stdin: +```json +{ + "rune": { "id": "...", "title": "...", "tags": ["worker:..."], ... }, + "cwd": "/path/to/project" +} +``` + +Dispatcher MUST write `DispatchResult` to stdout: +```json +{ + "command": "uv", + "args": ["run", "--directory", "...", "agent.py", "agent-name"], + "stdin": "{...}", + "env": {} +} +``` + +Empty command string indicates skip (unclaim). + +### FR-15: Orchestration Lifecycle + +The orchestrator MUST execute the following sequence: + +1. Load agent definition from AGENT.md based on `worker:*` tag +2. Load/initialize taskState from Task State Store +3. Execute Start hooks with taskState +4. If hook error: abort with failure +5. If hook skip: exit success without engine execution +6. Validate taskState against template.parameters +7. Render Handlebars prompt with taskState values +8. Build EngineContext and task_data +9. Execute engine +10. Execute Stop hooks +11. If blocking failure: abort with failure +12. If follow-up: loop back to step 9 with follow-up message +13. Save final taskState to Task State Store +14. Append completion note with telemetry +15. Return OrchestrationResult + +### FR-16: Level Hierarchy Constraints + +- Level 2 skills do not know about Level 3 task workflows. +- Level 3 task agents do not know about Level 4 orchestrators and do not read repoConfig directly. +- Level 3 agents MUST NOT embed language names, framework names, or version numbers directly in their prompt bodies. +- Level 4 orchestrator programs validate UoW `taskState` but do NOT derive or populate parameter values. +- The `tools` allowlist is enforced by the runtime harness, not the prompt. +- Task State Store provides the interface for persisting taskState without coupling to any specific backend. + +--- + +## Non-Functional Requirements + +### NFR-1: Performance + +- Task source polling interval MUST be configurable (default 10 seconds) +- API request timeout MUST be configurable (default 30 seconds) +- Hook execution MUST complete within 5 minutes or be terminated +- Engine execution has no hardcoded timeout (managed by engine) +- Task State Store operations MUST complete within 100ms for local stores, 500ms for remote stores +- AGENT.md parsing completes in under 100ms for files under 10KB + +### NFR-2: Reliability + +- The orchestrator MUST gracefully handle task source unavailability +- The orchestrator MUST log all hook failures without crashing +- The orchestrator MUST remove claimed runes from seen set on unclaim +- The orchestrator MUST survive process restart and resume polling +- Task State Store MUST be thread-safe and support concurrent access +- Task State persistence failures MUST be logged and cause task failure + +### NFR-3: Monitoring and Observability + +- All task source operations MUST be logged with task ID +- All hook executions MUST be logged with command and exit code +- Engine execution MUST log telemetry on completion +- Task State Store operations MUST be logged with task ID +- Log levels MUST be configurable (normal, verbose) +- Structured JSON log entries for: git root resolution, agent load, taskState validation result, hook start, hook exit (with exit code and duration), agent dispatch + +### NFR-4: Concurrency + +- The orchestrator MUST support configurable worker concurrency +- Each worker MUST maintain independent seen sets +- Claim operations MUST be atomic (handled by task source) +- Task State Store MUST support concurrent reads and writes to the same task_id + +### NFR-5: Error Handling + +- Invalid JSON on stdin MUST result in exit code 1 +- Unknown agent name MUST result in exit code 1 +- Agent without model MUST result in error and exit code 1 +- Task source unavailability MUST be logged and retried +- Hook execution exceptions MUST be caught, logged, and skipped +- Task State Store unavailability MUST cause task failure with descriptive error +- Every validation failure, hook exit-2, and parse error must name the specific field or file path that caused the failure, using dot-notation for nested fields + +### NFR-6: Install Idempotency + +- Running `bf orchestrate` multiple times against the same working repository produces the same result +- Already-present repo scripts are not overwritten +- No errors are raised for already-installed scripts +- Task State Store initialization is idempotent + +### NFR-7: Reproducibility + +- Given the same AGENT.md, the same `taskState`, and the same `projectDir`, two dispatches produce identical rendered prompts +- Handlebars rendering is deterministic and side-effect free + +### NFR-8: Security + +- The `tools` allowlist is enforced by the runtime harness +- A prompt that instructs the agent to use an unlisted tool is rejected before execution +- Repo scripts are executed with the same permissions as the orchestrator +- Task State Store does not execute code from stored taskState values + +--- + +## Data & Storage + +### Commands + +**ClaimRune** +- `rune_id: str` +- `claimant: str` +- Occurs when: Worker claims a task for execution + +**UnclaimRune** +- `rune_id: str` +- Occurs when: Worker releases a task (error, skip, or manual) + +**FulfillRune** +- `rune_id: str` +- Occurs when: Worker completes a task successfully + +**AppendCompletionNote** +- `rune_id: str` +- `note: str` (JSON serialized ExecutionStats) +- Occurs when: Worker completes execution with telemetry + +**InitializeTaskState** +- `task_id: str` +- `initial_state: dict` +- Occurs when: Task is claimed for the first time + +**UpdateTaskState** +- `task_id: str` +- `updates: dict` +- Occurs when: Hook modifies taskState + +**LoadTaskState** +- `task_id: str` +- Occurs when: Hook needs to read current taskState + +**DispatchAgent** +- `agentName: str`, `projectDir: str`, `uow: UnitOfWork`, `dispatchedAt: ISO8601` +- Occurs when: Orchestrator dispatches an agent + +**RunHook** +- `agentName: str`, `hookName: str`, `lifecycle: "Start" | "Stop"`, `projectDir: str` +- Occurs when: Hook is executed + +**InstallRepoScripts** +- `orchestratorName: str`, `projectDir: str`, `installedAt: ISO8601` +- Occurs when: Repo scripts are installed to working repository + +### Events + +**RuneClaimed** +- `rune_id: str` +- `claimant: str` +- `timestamp: datetime` + +**RuneUnclaimed** +- `rune_id: str` +- `previous_claimant: str` +- `reason: str` (error, skip, manual) +- `timestamp: datetime` + +**RuneFulfilled** +- `rune_id: str` +- `claimant: str` +- `telemetry: ExecutionStats` +- `timestamp: datetime` + +**RuneExecutionFailed** +- `rune_id: str` +- `claimant: str` +- `error: str` +- `timestamp: datetime` + +**AgentDispatched** +- `agentName: str`, `taskStateSnapshot: Record`, `renderedPromptHash: str`, `dispatchedAt: ISO8601` + +**HookExecuted** +- `agentName: str`, `hookName: str`, `lifecycle: "Start" | "Stop"`, `exitCode: 0 | 1 | 2`, `stdout: str`, `durationMs: number`, `executedAt: ISO8601` + +**AgentHalted** +- `agentName: str`, `reason: str`, `haltedAt: ISO8601` + +**TaskStateValidationFailed** +- `agentName: str`, `missingFields: string[]`, `failedAt: ISO8601` + +**RepoScriptInstalled** +- `agentName: str`, `hookName: str`, `targetPath: str`, `installedAt: ISO8601` + +**RepoScriptAlreadyPresent** +- `agentName: str`, `hookName: str`, `targetPath: str`, `checkedAt: ISO8601` + +**TaskStateUpdated** +- `task_id: str`, `updated_fields: string[]`, `updated_at: datetime` + +**TaskStateInitialized** +- `task_id: str`, `initial_state: dict`, `initialized_at: datetime` + +### Aggregates + +**Rune** +- `id: str` +- `title: str` +- `description: str | None` +- `status: TaskStatus` +- `tags: list[str]` +- `claimant: str | None` +- `created_at: datetime | None` +- `updated_at: datetime | None` +- `priority: int` + +**RuneDetail** +- Extends Rune +- `dependencies: list[DependencyRef]` +- `notes: list[NoteEntry]` +- `acceptance_criteria: list[ACEntry]` +- `retro: list[RetroEntry]` + +**AgentExecution** +- `rune_id: str` +- `agent_name: str` +- `claimant: str` +- `started_at: datetime` +- `completed_at: datetime | None` +- `telemetry: ExecutionStats | None` +- `verdict: OrchestrationResult` + +**AgentDefinition** +```typescript +type AgentDefinition = { + name: string + description: string + tools: string[] + toolClasses: string[] + template: { + parameters: Record // free-form; validated recursively at dispatch + } + hooks: { + Start: HookSpec[] + Stop: HookSpec[] + } + promptBody: string +} +``` + +**HookSpec** +```typescript +type HookSpec = { + name: string + scriptPath: string // relative to agent dir + specPath: string // path to .md Gherkin spec + isRepoScript: boolean // true = installed to working repo; false = runs from framework packages +} +``` + +**RepoConfig** +```typescript +type RepoConfig = { + languages: Array<{ + name: string + version: string + tools: Array<{ + toolClass: "formatter" | "linter" | "testFramework" | "build" + name: string + version?: string + }> + }> +} +``` + +**UnitOfWork** +```typescript +type UnitOfWork = { + id: string + agentName: string + projectDir: string + taskState: Record + createdAt: string + dispatchedAt: string | null +} +``` + +**DispatchRecord** +```typescript +type DispatchRecord = { + uowId: string + agentName: string + projectDir: string + taskStateSnapshot: Record + renderedPromptHash: string + hookResults: Array<{ + hookName: string + lifecycle: "Start" | "Stop" + exitCode: number + durationMs: number + }> + outcome: "completed" | "halted" + dispatchedAt: string + completedAt: string | null +} +``` + +### Query Projections + +**ReadyRunesQuery** +- Question: Which runes are available for claiming? +- Projection: `Rune` where `status == OPEN` AND `claimant == None` +- Used by: Task source polling + +**RuneDetailQuery** +- Question: What is the full context for a specific rune? +- Projection: `RuneDetail` by `rune_id` +- Used by: Agent execution + +**AgentExecutionHistoryQuery** +- Question: What executions have occurred for a rune? +- Projection: List of `AgentExecution` by `rune_id` ordered by `started_at` +- Used by: Debugging and audit + +**ClaimantActiveTasksQuery** +- Question: What tasks is a claimant currently working on? +- Projection: List of `Rune` where `claimant == X` AND `status == IN_PROGRESS` +- Used by: Status monitoring + +**AgentSchemaView** +- Question: What taskState shape, tools, and hook contracts does a named agent declare? +- Projection: `AgentDefinition` by `agentName` +- Used by: Dispatcher, validation + +**UoWReadinessView** +- Question: Does a given UoW's taskState satisfy the target agent's parameter schema? +- Projection: Boolean result of schema validation +- Used by: Pre-dispatch validation + +**HookHealthView** +- Question: Which hooks have failed (exit code ≥ 1) for a given agent in the last N dispatches? +- Projection: List of `HookExecuted` events filtered by failure +- Used by: Health monitoring + +**RepoScriptInstallStatusView** +- Question: Which repo scripts have been installed to a given working repository, and which are missing? +- Projection: Set of installed script paths vs required script paths +- Used by: Install validation + +**TaskStateView** +- Question: What is the current taskState for a given task? +- Projection: `taskState` dict by `task_id` +- Used by: Hook execution, follow-up loops + +### Data Retention + +- Rune events MUST be retained indefinitely (event sourcing) +- Rune aggregate MUST be rebuildable from event stream +- Completed runes MUST NOT be deleted from task source +- Agent execution history MUST be appended to rune as notes +- `DispatchRecord` and associated events: 90 days, then archived or deleted +- `AgentDefinition` snapshot captured at dispatch time: retained with its `DispatchRecord` for the same 90-day window +- `package.json` hash records: retained indefinitely (required for install-skip optimization) +- TaskState MUST be retained until task is completed and archived, then purged according to retention policy + +--- + +## Out of Scope + +- Multi-machine orchestration (single host only in v1) +- Dynamic agent registration (agents must be pre-configured in catalog) +- Real-time task streaming (polling only, no websockets) +- Task prioritization within the orchestrator (priority is metadata only) +- Automatic retry on failure (manual retry only) +- Task dependencies (dependencies are metadata only) +- Distributed locking across multiple orchestrator instances +- Custom scheduling algorithms (FIFO polling only) +- Agent sandboxing (agents run with same permissions as orchestrator) +- Web UI or API for orchestration management +- Level 5 meta-agent behavior: Automated prompt improvement, token-usage instrumentation, and eval-driven skill updates are out of scope. The schema must not prevent these being added later. +- Non-Node/Python hook runtimes in v1: Python, Bash, and Rust hooks are out of scope. The exit-code contract is language-agnostic and could support other runtimes in a future version. +- Agent-to-agent calls within a Level 3 task: Level 3 agents execute a single task. Inter-agent calls within a prompt belong to Level 4. +- GUI or web interface for agent authoring: Authoring is file-based (markdown + YAML). +- Agent definition versioning: Semantic versioning of AGENT.md files and compatibility guarantees between versions are out of scope for v1. +- Multi-language dispatch in a single agent invocation: Each dispatch targets one language at a time. Polyglot support comes from separate dispatches. +- Automated Gherkin test execution for hook specs: The `.md` Gherkin spec files are documentation and acceptance criteria only. They are not automatically parsed and run. Provided hook scripts ship with unit tests; teams writing replacement implementations test against the spec on their own. +- Scalar type enforcement in template.parameters: The type hints (e.g., `string`) in `template.parameters` values document intent but are not enforced at runtime in v1. Validation only checks presence and non-emptiness of required fields. +- Orchestrator responsibility for taskState derivation: The orchestrator validates; it does not populate. Whatever produces the UoW — human, CI, or another agent — is responsible for all `taskState` values. +- Dependency conflict isolation between agents via separate node_modules trees: npm workspace hoisting is the conflict resolution mechanism. Per-agent isolation via Docker or separate processes is out of scope for v1. +- Repo script upgrade path: When a newer version of the orchestrator program ships an updated repo script, there is no automated mechanism to update already-installed copies in working repositories. Teams update repo scripts manually. A future version may introduce a hash-comparison check with an explicit overwrite command. +- Malicious agent package supply chain: A compromised agent package could declare arbitrary dependencies installed into the shared orchestrator `node_modules`. The v1 mitigation is developer discipline — always review agent definitions and `package.json` before installing. + +--- + +## Dependencies and Assumptions + +### Dependencies + +| Dependency | Purpose | +|---|---| +| `repoConfig.yaml` (working repo) | Toolchain declarations; read by processes that produce UoW taskState | +| Claude Code harness | Runtime enforcement of the `tools` allowlist | +| Python 3.12+ | Python runtime for orchestrator core | +| Node.js ≥24 | Node.js runtime for hook script execution | +| git | Working repository root resolution (`projectDir` detection) | +| npm workspaces | Agent dependency resolution and workspace test orchestration | +| uv | Python package manager and script executor | +| vitest (agent-level devDependency) | Running provided hook unit tests | +| Handlebars (or equivalent) | Prompt template rendering at dispatch time | +| Task State Store backend (Redis, filesystem, in-memory) | taskState persistence across hook executions | + +### Assumptions + +1. The Claude Code harness enforces the `tools` allowlist at the runtime level. An agent cannot bypass it via prompt instructions. +2. `repoConfig.yaml` is committed to the working repository and readable by any process that needs it. +3. Node.js ≥24 is available in the orchestrator program's execution environment for .mjs hooks. +4. Python 3.12+ is available in the orchestrator program's execution environment for core orchestration. +5. All hook scripts (framework and repo) are ES modules (`.mjs`) or executable shell scripts. CommonJS is not supported. +6. The orchestrator framework is an npm workspace monorepo. `npm install` at the root resolves all agent hook dependencies. +7. `taskState` fields of type `prompt` carry the full text of a skill prompt section as a string value. The UoW producer is responsible for loading skill content and writing it as a string — not a file path. +8. A Level 3 agent is stateless between dispatches. Per-dispatch state lives in `taskState` and is threaded through hook stdin. +9. `validate-args` is the first Start hook by convention. Its absence is an authoring warning, not a parse-time fatal error. +10. When two tools share the same `toolClass` in a `repoConfig.yaml` language entry, the first listed entry is used and a warning with line numbers is emitted. +11. Repo script installation is a one-time operation performed by `bf orchestrate` on first run against a working repository. Subsequent runs are safe and idempotent. +12. `bf orchestrate` is always invoked from inside a valid git repository. The git root is the working repository; no other mechanism for specifying `projectDir` exists. +13. Absent optional Handlebars tokens render as empty string. Prompt authors guard optional sections with `{{#if}}` blocks. +14. Task State Store is available and reachable during hook execution. Unavailability causes task failure. +15. Task State Store operations are atomic for single task_id writes. +16. Agent catalog exists: `.claude/agents/` directory contains agent definitions for legacy agents, or AGENT.md files for Level 3 agents. +17. Configuration file exists: `.bifrost.yaml` in project root or home directory. +18. Network connectivity: Task source API is reachable from orchestrator. +19. File system permissions: Orchestrator has read/write access to project directory. +20. Shell availability: `/bin/sh` or compatible shell is available for hook execution. +21. Claimant uniqueness: Each orchestrator instance has a unique claimant identifier. +22. Idempotent hooks: Hooks are safe to run multiple times (follow-up loops). +23. Hook timeouts: Hooks complete within reasonable time or are killed. +24. Agent model support: Specified model (sonnet/opus/haiku) is available in engine. + +### External System Assumptions + +- **Bifrost API**: Supports `/runes/ready`, `/runes/{id}`, `/runes/{id}/claim`, `/runes/{id}/unclaim`, `/runes/{id}/fulfill` endpoints +- **Claude Code CLI**: Supports executing agents with context and returning structured results +- **Agent catalog format**: YAML files follow the specified schema for legacy agents, AGENT.md for Level 3 agents +- **Task State Store backend**: Supports get/set/delete operations with appropriate performance characteristics + +--- + +## Open Questions + +### OQ-1: Multi-Instance Coordination + +**Ambiguity**: How should multiple orchestrator instances coordinate to prevent duplicate work? + +**Assumption**: The Bifrost API handles atomic claim operations, preventing race conditions between instances. + +**Ideal Solution**: Implement distributed locking via the task source API. Each orchestrator instance has a unique claimant ID. Claim operations are atomic and return false if already claimed. + +**Alternatives**: +1. **Central coordinator**: Single coordinator assigns tasks to workers (adds complexity) +2. **Database-backed locking**: Use Redis/Postgres for distributed locks (adds dependency) +3. **Single instance**: Run only one orchestrator instance (limits scalability) + +**Comparison**: The atomic claim approach (ideal) balances simplicity with correctness. It leverages the existing task source API without additional infrastructure. The central coordinator alternative adds a single point of failure. Database locking adds operational overhead. Single instance limits horizontal scaling. + +### OQ-2: Hook Failure Strategy + +**Ambiguity**: Should hook failures always abort the entire orchestration? + +**Assumption**: Hook failures in RuneStart abort immediately. Hook failures in RuneStop are logged but don't block completion unless exit code 2. + +**Ideal Solution**: Configurable hook failure policy per hook: `continue_on_error`, `abort_on_error`, `warn_on_error`. + +**Alternatives**: +1. **Strict mode**: Any hook failure aborts (current behavior for RuneStart) +2. **Best effort**: Log and continue regardless of hook outcome +3. **Conditional**: Different behavior for RuneStart vs RuneStop (current hybrid) + +**Comparison**: Strict mode ensures correctness but may fail on non-critical hooks. Best effort may hide important failures. The current conditional approach treats RuneStart as validation (strict) and RuneStop as optional checks (permissive). Adding configurable policies would provide flexibility without sacrificing safety. + +### OQ-3: Follow-Up Loop Limits + +**Ambiguity**: Should there be a limit on follow-up iterations to prevent infinite loops? + +**Assumption**: No limit currently exists. A poorly behaved hook could cause infinite follow-up loops. + +**Ideal Solution**: Configurable maximum follow-up iterations (default 3) with exponential backoff between iterations. + +**Alternatives**: +1. **No limit**: Trust hooks to eventually succeed (current behavior, risky) +2. **Hard limit**: Fixed maximum iterations (e.g., 5) with failure on exceed +3. **Timeout**: Maximum total time in follow-up loop regardless of iterations + +**Comparison**: No limit is simplest but risks infinite loops. A hard limit provides safety but may cut off legitimate retries. A timeout-based approach balances safety with flexibility. The configurable limit with backoff offers the best balance of safety and configurability. + +### OQ-4: Agent Selection Without Worker Tag + +**Ambiguity**: What should happen when a rune has no `worker:*` tag? + +**Assumption**: Rune is skipped (unclaimed) and not executed. + +**Ideal Solution**: Configurable default agent that executes when no worker tag is present. If no default configured, skip the rune. + +**Alternatives**: +1. **Skip always**: Current behavior, safe but may miss work +2. **Default agent**: Use a configured default agent (requires config) +3. **Round-robin**: Distribute untagged runes across available agents (complex) +4. **Reject**: Mark rune as failed for missing worker tag (strict) + +**Comparison**: Skip always is safe but may require manual tagging. Default agent provides automation but requires configuration. Round-robin is complex and may not match agent capabilities. Reject is strict but may create noise. The default agent approach with skip fallback offers flexibility with safe defaults. + +--- + +## Design Decisions and Feedback + +This section records decisions made after the initial PRD creation and feedback received during review. + +### Decision 1: Multi-Instance Coordination Responsibility + +**Status**: Resolved — Out of scope for orchestrator + +**Decision**: If a task is ready (emitted by the task source's async iterator), the orchestrator is free to work on it. The task source plugin (e.g., Bifrost API, database, queue) is responsible for ensuring two orchestrators don't work on the same task simultaneously. + +**Rationale**: This is handled via atomic claim operations, distributed locks, or queue semantics at the task source level. The orchestrator is a thin dispatch layer — it trusts that if the task source emits a task, it's safe to work on. + +### Decision 2: Hook Exit Code Semantics + +**Status**: Resolved — Exit codes control continuation + +**Decision**: Hooks control continuation via exit codes: +- `0`: Continue (success) +- `1`: Continue with warning (recoverable error, stdout passed as context) +- `2`: Abort agent execution (fatal error) + +Exit code 2 aborts the agent execution for that unit of work, moving it to failed state. The orchestrator continues processing other tasks. The task source is responsible for not re-emitting failed tasks. + +**Rationale**: It is not the orchestrator's job to inspect whether a task "can be worked on" — if the plugin emits it, the orchestrator dispatches it. Hooks provide the validation gate via exit codes. + +### Decision 3: Follow-Up Loop Limits + +**Status**: Resolved — Orchestrator-developer defined + +**Decision**: The orchestrator framework does not enforce a hard-coded follow-up loop limit. An orchestrator-developer can implement this themselves if needed, or an agent-developer can enforce limits via custom hooks that track iteration count in taskState and abort after exceeding a threshold. + +**Rationale**: This provides maximum flexibility. Different use cases have different requirements — some need unlimited retries, others need strict limits. Let the developer decide. + +### Decision 4: Undispatchable Work Handling + +**Status**: Resolved — Mark as failed and abort + +**Decision**: Work that is emitted from the async iterator but cannot be dispatched for any reason (no worker tag, taskState fails agent schema validation, agent not found, etc.) MUST be marked as failed and aborted. The task source plugin is responsible for handling the failed state (not re-emitting, moving to dead-letter queue, etc.). + +**Rationale**: The orchestrator should not silently skip work. If it can't dispatch, it should fail visibly so the task source can handle it appropriately. + +### Feedback 1: Language Choice — TypeScript over Python + +**Received**: The orchestrator PRD should use TypeScript as the primary language, not Python. The agent definition PRD already specifies TypeScript due to ease of loading .mjs hooks via dynamic import. + +**Action for v2**: Rewrite orchestrator as TypeScript-only. Remove Python references. + +### Feedback 2: Decouple from Bifrost and Claude + +**Received**: The orchestrator PRD should not include "Bifrost" or "Claude" language. We want this to be 100% decoupled — Bifrost will merely be a plugin to the framework. For instance: +- "Rune" is a Bifrost term — the orchestrator should talk about "tasks" +- "Agents should be stored in `.claude/agents/`" is Claude-specific — the orchestrator framework would install agents into the monorepo per the agent definition PRD + +**Action for v2**: +- Remove all Bifrost-specific terminology (use "task" not "rune", generic task source terms) +- Remove all Claude-specific terminology (use generic agent catalog locations) +- Use generic "AI runtime" or "engine" terms instead of "Claude Code CLI" +- Configuration file should be `.orchestrator.yaml` not `.bifrost.yaml` +- CLI command should be generic, not `bf orchestrate` diff --git a/orchestrator-prd-v2.md b/orchestrator-prd-v2.md new file mode 100644 index 0000000..2fd70ce --- /dev/null +++ b/orchestrator-prd-v2.md @@ -0,0 +1,1181 @@ +# Unified Orchestrator PRD v2 + +**Status:** Draft +**Authors:** Eric Siebeneich, Matthew Wright, Alexander Reeves +**Date:** 2026-05-07 +**Version:** 2.0 + +--- + +## Product Description, Problem, and Goal + +### Product Description + +The **Orchestrator Framework** is a TypeScript-based distributed task execution system that coordinates **AI agents** to perform work across multiple projects. It provides a plugin architecture with **Task Sources** (providers of work), **Engines** (executors of work), and **Task State Stores** (task state persistence), connected by a core orchestration layer that manages task lifecycle, state transitions, and telemetry. + +The system implements a **multi-level AI factory model**: + +- **Level 2 (skill):** Language- or tool-specific knowledge capsule. Contains a prompt describing how to write code in a specific language, a **toolClass** registry mapping roles (formatter, linter, testFramework, build) to named tools, and file-level hooks that validate output. A skill is agnostic to any task — it only answers: "how do you write X?" +- **Level 3 (task agent):** A **parameterized, task-focused agent** that accepts a **unit of work (UoW)** with pre-populated `taskState` and executes one discrete workflow (e.g., BDD Red phase). It is language-agnostic by design — language, framework, and style arrive via `taskState` at dispatch time. +- **Level 4 (orchestrator program):** Built using the orchestrator framework. Validates that the incoming UoW `taskState` satisfies the target agent's parameter schema, then dispatches the agent. The orchestrator does NOT derive or populate `taskState` values — that is the responsibility of whoever produced the UoW (a human, a CI system, another UoW agent, etc.). +- **Level 5 (meta-agent):** Can augment or modify skill and agent definitions (e.g., add timing hooks, update prompts after evals). + +**Key Terms:** + +- **Task**: A unit of work to be executed by an agent, containing title, description, tags, and metadata +- **Agent**: An AI worker with specific capabilities (model, tools, prompt) that executes tasks +- **AGENT.md**: Markdown file with YAML frontmatter that fully describes a Level 3 agent's contract +- **taskState**: Free-form object containing all context for a single task execution, including language, framework, and cross-hook state +- **Task Source**: Plugin that discovers, claims, and fulfills tasks from an external system (e.g., API, database, queue) +- **Engine**: Plugin that executes tasks using a specific mechanism (e.g., AI runtime, CLI tool) +- **Task State Store**: Plugin that persists and retrieves taskState across hook executions +- **Hook**: Shell command or Node.js script executed before (Start) or after (Stop) agent execution +- **repo script**: Hook script that belongs to a specific working repository, installed to `.ai//hooks/` +- **repoConfig**: YAML file committed to working repository declaring languages and tools +- **toolClass**: Role identifier (formatter, linter, testFramework, build) for tool resolution +- **Claimant**: Identifier for the agent instance currently working on a task +- **projectDir**: Git root of the working repository, resolved automatically +- **Orchestration**: The process of coordinating hooks, engine execution, and follow-up loops +- **Follow-up**: Additional agent execution triggered by Stop hooks to address issues +- **Handlebars**: Template syntax used in AGENT.md prompt bodies for taskState substitution + +### Problem + +Sarah is a platform engineer managing a monorepo with 50+ services. Her team has implemented AI agents to help with routine maintenance tasks (dependency updates, code refactors, security patches). Each agent works great in isolation, but Sarah has five critical problems: + +1. **No coordination**: Multiple agent instances compete for the same tasks, causing duplicate work +2. **No observability**: When an agent fails, there's no telemetry—did it timeout? run out of tokens? hit a bug? +3. **No integration**: Agents can't validate work with project-specific checks (running tests, linters) before marking tasks complete +4. **Tight coupling**: Agents hard-code language and framework details. When the team adds a C# service, Sarah must maintain separate C# agents +5. **No state sharing**: Hooks can't pass data to each other—snapshot-tests captures file hashes but check-new-tests can't read them + +Marcus, a senior engineer on Sarah's team, wants to automate the BDD Red phase across the polyglot monorepo. He writes a BDD-Red agent prompt that hard-codes Python and pytest — but three weeks later the team adds a C# service. Marcus must now maintain two nearly identical agents. When a teammate writes a BDD-Green agent, they hard-code it for TypeScript/Vitest. The agents are not composable: changing the test framework means editing every agent. Hooks that enforce correctness are written as ad-hoc shell scripts with no documented contract, so no one knows which exit code means what. + +Sarah spends hours manually deconflicting agents, digging through logs to understand failures, and manually validating work before merging. She can't scale her automation beyond a few agents without creating more work than she saves. + +### Goal + +With the Orchestrator Framework, Sarah builds orchestrator instances tailored to her infrastructure. She configures a **Task Source** that connects to her task management system, an **Engine** that uses her preferred AI runtime, and a **Task State Store** that persists taskState in Redis. When a task is ready, the orchestrator: + +1. Receives the task from the Task Source's async iterator +2. Loads agent definition from AGENT.md (template parameters, hooks, prompt) +3. Validates taskState against agent's parameter schema +4. Runs **Start hooks** (validate-args, snapshot-tests, project-specific validation) +5. Renders Handlebars prompt with taskState values +6. Executes the task with the appropriate agent (model, tools from agent catalog) +7. Runs **Stop hooks** (check-new-tests, lint, format, custom checks) +8. If hooks report issues (exit code 1), triggers a **follow-up** loop to address them +9. If hooks report fatal errors (exit code 2), marks the UoW as failed +10. Persists updated taskState (including cross-hook state) via Task State Store +11. Marks the task complete with full telemetry (tokens used, duration, cost) + +Marcus defines one BDD-Red agent. Its AGENT.md declares the `taskState` fields it requires: `language`, `testFramework`, and `testStyle`. Whatever process creates the UoW — a human, a CI trigger, or another agent — fills those fields before handing it to the orchestrator. The orchestrator validates the schema and dispatches. The same BDD-Red agent handles the C# service when a UoW arrives with C# / XUnit / Gherkin in taskState. Hooks are declared in the AGENT.md with a defined stdin schema and exit code contract. Repo scripts are automatically installed to the working repository's `.ai/` directory on first run. Hooks communicate via taskState, so snapshot-tests writes file hashes that check-new-tests reads. + +Sarah now has reliable, scalable, composable automation. She can deploy multiple agents knowing the Task Source provides coordination (claiming, locking, or queue semantics). She has full visibility into execution. She trusts the automation because hooks validate work before completion. Marcus's team ships BDD Red across every language without touching the agent definition. + +--- + +## User Stories / Use Cases + +### US-1: Define a task agent with AGENT.md + +**As an** agent author +**I want** to write an AGENT.md with a documented parameter schema, allowed tools, hook lifecycle specs, and a Handlebars prompt body +**So that** task agents are composable and language-agnostic + +**Acceptance Criteria:** + +``` +Given an AGENT.md file with valid YAML frontmatter + And a prompt body containing Handlebars tokens matching declared template parameters +When the orchestrator reads the file +Then the agent name, description, tools, toolClasses, template parameter schema, and prompt body are all accessible as structured data +``` + +``` +Given an AGENT.md missing a required frontmatter field (name, description, or tools) +When the orchestrator reads the file +Then parsing fails with a descriptive error naming the missing field + And the agent is not dispatched +``` + +``` +Given an AGENT.md template.parameters section where a field name ends with ? +When the parameter schema is parsed +Then that field is marked optional + And the Handlebars renderer does not error if that field is absent from taskState + And an absent optional field renders as empty string +``` + +``` +Given a template parameter declared as optional (name ends with ?) + And that parameter is an object with one or more sub-fields whose names do not end with ? +When taskState provides that optional parameter +Then all non-? sub-fields of that object must be present and non-empty + And validation fails naming any absent required sub-field by its dot-notation path +When taskState omits the optional parameter entirely +Then no validation error is raised for that parameter or any of its sub-fields +``` + +``` +Given a prompt body referencing a Handlebars token not declared in template.parameters +When the AGENT.md is parsed +Then parsing fails identifying the undeclared token by name +``` + +### US-2: Task Source emits available tasks + +**As a** task source plugin author +**I want** to emit available tasks via an async iterator +**So that** the orchestrator can dispatch them + +**Acceptance Criteria:** + +``` +Given a task is available for processing + And the task source has exclusive ownership (via claim, lock, or queue dequeue) +When the orchestrator polls the task source +Then the task source yields the task via its async iterator +``` + +``` +Given a task source supports concurrent polling + And two orchestrator instances poll simultaneously +When both poll the task source +Then each task is yielded to at most one orchestrator + And the task source handles coordination (atomic claims, distributed locks, or queue semantics) +``` + +``` +Given a task source yields a task +When the orchestrator receives the task +Then the orchestrator dispatches the task without checking ownership + And ownership is the task source's responsibility +``` + +### US-3: Agent Operator - Dispatch agent on task + +**As an** agent operator +**I want** to dispatch a task with the appropriate agent +**So that** work is completed + +**Acceptance Criteria:** + +``` +Given a task is yielded from the task source + And the task has a "worker:reviewer" tag + And the reviewer agent is configured in the agent catalog +When the orchestrator receives the task +Then Start hooks are executed in sequence + And the reviewer agent is invoked with the task context + And Stop hooks are executed in sequence + And the task is marked complete + And execution telemetry is recorded +``` + +``` +Given a task has no worker tag (no "worker:*" in tags) +When the orchestrator receives the task +Then the task is marked as failed + And the orchestrator logs the reason + And the task source is notified of the failure +``` + +``` +Given a task's taskState fails agent schema validation +When the orchestrator receives the task +Then the task is marked as failed + And the orchestrator logs the validation error + And the task source is notified of the failure +``` + +### US-4: Project Maintainer - Extend Agent with Hooks + +**As a** project maintainer +**I want** to attach shell commands to agent lifecycle events +**So that** agents can validate work and augment prompts + +**Acceptance Criteria:** + +``` +Given an AGENT.md with a hooks.Start section containing one or more hook specs +When the agent is dispatched with a valid UoW +Then each Start hook executes in declaration order before the agent receives its prompt + And exit code 0 allows the agent to proceed + And exit code 1 passes hook stdout to the agent as a warning and continues + And exit code 2 halts the agent, marks UoW as failed, and surfaces the error to the task source +``` + +``` +Given an AGENT.md with a hooks.Stop section +When the agent's prompt execution finishes +Then each Stop hook executes in declaration order + And exit code 1 returns stdout to the agent for remediation (follow-up loop) + And exit code 2 halts, marks UoW as failed, and reports the error to the task source +``` + +``` +Given a running hook +When stdin is read +Then the hook receives a JSON object containing: projectDir (string), params (the resolved taskState values), taskState (full UoW taskState object) + And the rendered prompt is NOT present in stdin +``` + +``` +Given a Start hook that writes data into taskState (e.g., snapshot-tests writes file hashes) + And a Stop hook that reads that data (e.g., check-new-tests reads file hashes) +When both hooks run within the same dispatch +Then the Stop hook receives taskState as modified by all preceding hooks +``` + +### US-5: First run installs repo scripts into the working repository + +**As a** developer running the orchestrator against a working repository for the first time +**I want** repo scripts for all built-in agents to be automatically hard-copied into the working repository and staged for commit +**So that** working repositories are ready for agent dispatch without manual file management + +**Acceptance Criteria:** + +``` +Given an orchestrator program with one or more built-in agents that have repo scripts + And a working repository that has not previously been initialized by this orchestrator +When the orchestrator runs against the working repository for the first time +Then each repo script is hard-copied to .ai//hooks/.d/.mjs + And no symlinks are created + And the orchestrator logs each installed path +``` + +``` +Given a working repository that has already been initialized + And a repo script already exists at the expected path +When the orchestrator runs again +Then the existing script is not overwritten + And the orchestrator logs that the script is already present +``` + +``` +Given repo scripts installed by the orchestrator +When a developer runs git status in the working repository +Then the installed .mjs files appear as new untracked files + And no other files in the working repository are modified by the install step +``` + +### US-6: Dispatch an agent with a pre-populated unit of work + +**As a** Level 4 orchestrator program +**I want** to validate an incoming UoW's `taskState` against the target agent's parameter schema and dispatch only when all required fields are satisfied +**So that** the orchestrator remains a thin validation and dispatch layer + +**Acceptance Criteria:** + +``` +Given a Level 3 agent with a declared template.parameters schema + And a UoW whose taskState satisfies all required parameters recursively +When the orchestrator dispatches +Then the rendered prompt is injected into the agent's context + And the agent begins work +``` + +``` +Given a UoW whose taskState is missing a required parameter +When the orchestrator attempts dispatch +Then the Start hook validate-args exits with code 2 + And the agent does not execute any prompt + And the error identifies the missing field by its dot-notation path + And the UoW is marked as failed +``` + +``` +Given a template parameter that is an optional object (name ends with ?) + And the UoW taskState provides that object + And the object is missing a required sub-field (sub-field name does not end with ?) +When validate-args runs +Then validation fails identifying the missing sub-field by its dot-notation path +``` + +``` +Given a UoW taskState where a required field is present but set to empty string +When validate-args runs +Then validation fails as if the field were absent +``` + +``` +Given a UoW with an incomplete taskState +When the orchestrator receives it +Then the orchestrator does not read repoConfig, inspect the workspace, or call any external service to fill in missing values + And it fails validation, marks the UoW as failed, and returns the error to the task source +``` + +### US-7: Platform Engineer - Observe Agent Execution + +**As a** platform engineer +**I want** to collect telemetry from agent executions +**So that** I can debug failures and optimize costs + +**Acceptance Criteria:** + +``` +Given an agent executes successfully +When the orchestration completes +Then execution telemetry includes: duration_ms, input_tokens, output_tokens +And telemetry includes: cache_read_tokens, cache_creation_tokens +And telemetry includes: total_cost_usd, num_turns +And telemetry is appended as a completion note on the task +``` + +``` +Given an agent executes with follow-up +When multiple engine executions occur +Then telemetry is accumulated across all executions +And cumulative telemetry is reported on completion +``` + +### US-8: System Administrator - Configure Multiple Sources and Engines + +**As a** system administrator +**I want** to configure task sources, engines, and task state stores via YAML +**So that** the orchestrator works with different backends + +**Acceptance Criteria:** + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_source.type is "api" +And orchestrate.task_source.settings.base_url is "https://api.example.com" +And orchestrate.task_source.settings.poll_interval is 30 +When the orchestrator loads configuration +Then an APITaskSource is created with the specified base_url +And the task source polls every 30 seconds +``` + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.engine.type is "ai-runtime" +And orchestrate.engine.settings.endpoint is "https://ai.example.com" +When the orchestrator loads configuration +Then an AIRuntimeEngine is created with the specified endpoint +``` + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_state_store.type is "redis" +And orchestrate.task_state_store.settings.url is "redis://localhost:6379" +When the orchestrator loads configuration +Then a RedisTaskStateStore is created with the specified URL +``` + +``` +Given an unknown task source type is configured +When the orchestrator attempts to create the task source +Then an error is raised with message "Unknown task source type: {type}" +``` + +### US-9: Developer - List Available Agents + +**As a** developer +**I want** to list all configured agents and their capabilities +**So that** I can choose the right agent for a task + +**Acceptance Criteria:** + +``` +Given the agent catalog contains agents +And agent "reviewer" has description, model, tools, and hooks +When the orchestrator CLI is invoked with --list-agents +Then each agent name is printed +And agent description is printed if present +And agent model is printed if present +And agent tools are printed as comma-separated list if present +And start_hooks are printed as comma-separated list if present +And stop_hooks are printed as comma-separated list if present +``` + +``` +Given the agent catalog is empty +When the orchestrator CLI is invoked with --list-agents +Then "No agents found." is printed to stderr +``` + +### US-10: projectDir resolved from git root of CWD + +**As a** developer running the orchestrator from within a working repository +**I want** the orchestrator program to automatically resolve the working repository root by walking up from the current working directory +**So that** running the orchestrator requires no path arguments and works from any subdirectory + +**Acceptance Criteria:** + +``` +Given a developer runs the orchestrator from a directory inside a git repository +When the orchestrator program starts +Then projectDir is set to the git root of the directory the orchestrator was invoked from + And no --projectDir argument is required +``` + +``` +Given a developer runs the orchestrator from a directory that is not inside any git repository +When the orchestrator program starts +Then it exits with a descriptive error stating that no git root could be found + And no agent dispatch occurs +``` + +``` +Given a git repository rooted at /home/user/myrepo + And a developer runs the orchestrator from /home/user/myrepo/src/lib +When the orchestrator program starts +Then projectDir is /home/user/myrepo +``` + +### US-11: Task State persistence across hook executions + +**As a** hook author +**I want** hooks to read and write taskState that persists across hook executions +**So that** hooks can coordinate their behavior without side effects + +**Acceptance Criteria:** + +``` +Given a Start hook that writes taskState.snapshotTests = { "test.js": "hash123" } +When the Start hook completes +Then the Task State Store persists the updated taskState +And the Stop hook can read taskState.snapshotTests in a subsequent execution +``` + +``` +Given a taskState that was previously persisted +When a new hook execution begins +Then the Task State Store loads the persisted taskState +And the hook receives the updated taskState via stdin +``` + +``` +Given the Task State Store is unavailable +When a hook attempts to read or write taskState +Then the hook execution fails with a descriptive error +And the orchestrator logs the failure +And the UoW is marked as failed +``` + +--- + +## Functional Requirements + +### FR-1: Task Source Interface + +The system MUST implement the `TaskSource` interface with the following methods: + +- `async watchTasks(): AsyncIterator`: Yield available tasks. The task source is responsible for coordination (claiming, locking, queue semantics) to ensure each task is yielded to at most one orchestrator instance. +- `async getTaskDetail(taskId: string): Promise`: Retrieve full task details +- `async completeTask(taskId: string): Promise`: Mark task as fulfilled +- `async failTask(taskId: string, error: string): Promise`: Mark task as failed + +Task Status enum values: +- `OPEN`: Task is available for processing +- `IN_PROGRESS`: Task is being executed +- `COMPLETED`: Task is fulfilled +- `FAILED`: Task execution failed +- `CANCELLED`: Task was cancelled + +The task source plugin is responsible for: +- Ensuring tasks yielded via `watchTasks()` are not simultaneously yielded to other orchestrators +- Not re-emitting tasks that have been marked as `FAILED` +- Handling coordination via atomic claims, distributed locks, queue dequeue, or other mechanisms + +### FR-2: Engine Interface + +The system MUST implement the `Engine` interface with the following methods: + +- `async execute(context: EngineContext, taskData: TaskData): Promise`: Execute a task +- `async sendFollowUp(message: string): Promise`: Optional method for follow-up execution + +EngineContext MUST contain: +- `taskId: string`: Unique task identifier +- `workingDir: string`: Project directory for execution +- `agentName: string`: Name of the agent to use +- `verbose: boolean`: Enable verbose logging + +EngineResult MUST contain: +- `success: boolean`: Whether execution succeeded +- `skipFulfill: boolean`: Whether to skip marking the task complete +- `lastMessage: string | null`: Final message from the agent +- `stats: ExecutionStats | null`: Telemetry data + +ExecutionStats MUST contain: +- `durationMs: number`: Execution duration in milliseconds +- `inputTokens: number`: Input tokens consumed +- `outputTokens: number`: Output tokens consumed +- `cacheReadTokens: number`: Cache read tokens +- `cacheCreationTokens: number`: Cache creation tokens +- `totalCostUsd: number`: Total cost in USD +- `numTurns: number`: Number of conversation turns + +### FR-3: Task State Store Interface + +The system MUST implement the `TaskStateStore` interface with the following methods: + +- `async loadTaskState(taskId: string): Promise | null>`: Load taskState for a task +- `async saveTaskState(taskId: string, taskState: Record): Promise`: Persist taskState for a task +- `async deleteTaskState(taskId: string): Promise`: Delete taskState for a task +- `async initializeTaskState(taskId: string, initialState: Record): Promise`: Initialize taskState for a new task + +TaskStateStore implementations MUST be thread-safe and support concurrent access. + +### FR-4: Agent Definition File (AGENT.md) + +Format: Markdown file with YAML frontmatter delimited by `---`. The prompt body follows the closing `---`. + +Required frontmatter fields: + +| Field | Type | Description | +|---|---|---| +| `name` | string | Unique agent identifier (kebab-case) | +| `description` | string | One-line description used for orchestrator routing | +| `tools` | string[] | Explicit allowlist of tools this agent may use | +| `toolClasses` | string[] | Optional. Tool role types this agent requires. Informational — documents what must be available. | +| `template.parameters` | object | Free-form YAML shape declaring the taskState structure this agent expects | + +The `tools` list is a strict allowlist enforced by the runtime. Any tool not listed is denied regardless of what the prompt requests. + +### FR-5: template.parameters Schema Rules + +`template.parameters` is a free-form YAML object of any shape. The following conventions govern optionality: + +- A field whose key ends with `?` is **optional**. It may be absent from `taskState` without causing a validation error. When absent, its Handlebars token renders as empty string. +- A field whose key does not end with `?` is **required**. It must be present and non-empty in `taskState`. +- Rules apply recursively: if an optional object is provided, all of its non-`?`-suffixed sub-fields are required. If an optional object is absent, none of its sub-fields are evaluated. +- Scalar values (e.g., `string`) are type hints only and are not enforced at runtime. +- Prompt authors use `{{#if paramName}}...{{/if}}` to guard sections that depend on optional parameters. + +Example: +```yaml +template: + parameters: + language: + name: string + prompt: string + testFramework: + name: string + prompt: string + testStyle: + name: string + prompt: string + userPrompt: string + context?: + prDescription: string + additionalNotes?: string +``` + +In this example: `language`, `testFramework`, `testStyle`, and `userPrompt` are required. `context` is optional; if provided, `context.prDescription` is required and `context.additionalNotes` is optional. + +Handlebars tokens in the prompt body must match declared parameter paths exactly. Unknown tokens are a parse-time error. + +### FR-6: projectDir Resolution + +`projectDir` is not passed as a CLI argument. When the orchestrator is invoked, it walks up from the current working directory to find the nearest ancestor directory containing a `.git` folder. That directory becomes `projectDir` for the duration of the run. If no git root is found, the program exits with a fatal error. + +### FR-7: Orchestrator Framework and Orchestrator Program + +The **orchestrator framework** is a TypeScript monorepo supporting npm workspaces: + +``` +orchestrator-framework/ + package.json # workspaces: ["packages/**"] + node_modules/ # shared, hoisted + packages/ + bdd-red -> /path/to/bdd-red # symlink + bdd-green -> /path/to/bdd-green +``` + +An **orchestrator program** is built on this framework and embeds a chosen set of agents. Agents are installed by symlinking them under `./packages/`. npm workspace dependency resolution handles hoisting and conflict resolution. + +When the orchestrator runs against a working repository for the first time, it performs a one-time install of all built-in repo scripts before any dispatch occurs. + +### FR-8: Working Repository Layout (after install) + +``` +/ + repoConfig.yaml + .ai/ + / + hooks/ + Start.d/ + .mjs # hard-copied, committed + Stop.d/ + .mjs +``` + +Repo scripts are hard-copied (never symlinked) and committed. The orchestrator program imports them via dynamic `import()` using the absolute path resolved from `projectDir + "/.ai//hooks/..."`. Their dependencies are declared in the orchestrator program's `package.json` and resolved from its `node_modules`. + +### FR-9: Agent Package Layout + +``` +/ + AGENT.md + package.json + hooks/ + Start.d/ + .mjs # provided script + .md # Gherkin acceptance spec + Stop.d/ + .mjs + .md +``` + +Hook execution order within each `.d/` directory: alphabetical by filename. + +### FR-10: Hook Contract + +**stdin (JSON):** +```json +{ + "projectDir": "string", + "params": {}, + "taskState": {} +} +``` + +The rendered agent prompt is NOT included. Cross-hook state is communicated via `taskState` mutations passed through to subsequent hooks. + +**Exit codes:** + +| Code | Meaning | Effect | +|---|---|---| +| 0 | Success | Proceed | +| 1 | Recoverable error | Hook stdout passed to agent as context; execution continues (triggers follow-up for Stop hooks) | +| 2 | Fatal error | Agent halts; UoW marked as failed; error surfaced to task source | + +**Script format:** `.mjs` (ES module) or executable shell script. Executed via dynamic `import()` (Node.js) or subprocess (shell). + +### FR-11: Built-in Hook Specs + +| Hook | Lifecycle | Type | Purpose | +|---|---|---|---| +| `validate-args` | Start | framework | Assert all required taskState fields are non-empty per declared schema | +| `snapshot-tests` | Start | framework | Hash existing test files into taskState | +| `check-new-tests` | Stop | framework | Assert at least one new test was added since snapshot | +| `lint` | Stop | repo script | Run project linter (resolved from repoConfig `linter` toolClass) | +| `format` | Stop | repo script | Run project formatter (resolved from repoConfig `formatter` toolClass) | + +Framework hooks run from the orchestrator's `packages/` context. Repo scripts are loaded from the working repository's `.ai/` directory via dynamic `import()`. + +### FR-12: repoConfig.yaml + +```yaml +languages: + - name: string # e.g., "csharp", "node" + version: string # semver range + tools: + - toolClass: string # "formatter" | "linter" | "testFramework" | "build" + name: string + version: string # optional +``` + +When multiple tools share the same `toolClass` within one language entry, the first entry wins and a warning is emitted identifying the language name, the conflicting toolClass, and the line numbers of both entries. + +### FR-13: Configuration + +Configuration file `.orchestrator.yaml` MUST support: + +```yaml +orchestrate: + task_source: + type: string # Plugin name + settings: + # Plugin-specific settings + + engine: + type: string # Plugin name + settings: + # Plugin-specific settings + + task_state_store: + type: string # "redis" | "memory" | "file" + settings: + # Type-specific settings + + concurrency: number + claimant: string | null + logging: "normal" | "verbose" +``` + +### FR-14: Orchestration Lifecycle + +The orchestrator MUST execute the following sequence: + +1. Receive task from Task Source's async iterator +2. Load agent definition from AGENT.md based on `worker:*` tag +3. Load/initialize taskState from Task State Store +4. Execute Start hooks with taskState +5. If any hook exits with code 2: mark UoW as failed, notify task source, continue to next task +6. Validate taskState against template.parameters +7. If validation fails: mark UoW as failed, notify task source, continue to next task +8. Render Handlebars prompt with taskState values +9. Build EngineContext and taskData +10. Execute engine +11. Execute Stop hooks +12. If any Stop hook exits with code 1: loop back to step 9 with follow-up message +13. If any Stop hook exits with code 2: mark UoW as failed, notify task source, continue to next task +14. Save final taskState to Task State Store +15. Mark task as complete via Task Source +16. Append completion note with telemetry +17. Continue to next task + +### FR-15: Level Hierarchy Constraints + +- Level 2 skills do not know about Level 3 task workflows. +- Level 3 task agents do not know about Level 4 orchestrators and do not read repoConfig directly. +- Level 3 agents MUST NOT embed language names, framework names, or version numbers directly in their prompt bodies. +- Level 4 orchestrator programs validate UoW `taskState` but do NOT derive or populate parameter values. +- The `tools` allowlist is enforced by the runtime harness, not the prompt. +- Task State Store provides the interface for persisting taskState without coupling to any specific backend. + +--- + +## Non-Functional Requirements + +### NFR-1: Performance + +- Task source polling interval MUST be configurable (default 10 seconds) +- API request timeout MUST be configurable (default 30 seconds) +- Hook execution MUST complete within 5 minutes or be terminated +- Engine execution has no hardcoded timeout (managed by engine) +- Task State Store operations MUST complete within 100ms for local stores, 500ms for remote stores +- AGENT.md parsing completes in under 100ms for files under 10KB + +### NFR-2: Reliability + +- The orchestrator MUST gracefully handle task source unavailability +- The orchestrator MUST log all hook failures without crashing +- The orchestrator MUST survive process restart and resume polling +- Task State Store MUST be thread-safe and support concurrent access +- Task State persistence failures MUST be logged and cause UoW failure + +### NFR-3: Monitoring and Observability + +- All task source operations MUST be logged with task ID +- All hook executions MUST be logged with command and exit code +- Engine execution MUST log telemetry on completion +- Task State Store operations MUST be logged with task ID +- Log levels MUST be configurable (normal, verbose) +- Structured JSON log entries for: git root resolution, agent load, taskState validation result, hook start, hook exit (with exit code and duration), agent dispatch + +### NFR-4: Concurrency + +- The orchestrator MUST support configurable worker concurrency +- Task Source MUST handle coordination (no built-in locking in orchestrator) +- Task State Store MUST support concurrent reads and writes to the same task_id + +### NFR-5: Error Handling + +- Invalid JSON on stdin MUST result in exit code 1 +- Unknown agent name MUST result in UoW being marked as failed +- Agent without model MUST result in UoW being marked as failed +- Task source unavailability MUST be logged and retried +- Hook execution exceptions MUST be caught, logged, and result in exit code 2 +- Task State Store unavailability MUST cause UoW failure with descriptive error +- Every validation failure, hook exit-2, and parse error must name the specific field or file path that caused the failure, using dot-notation for nested fields + +### NFR-6: Install Idempotency + +- Running the orchestrator multiple times against the same working repository produces the same result +- Already-present repo scripts are not overwritten +- No errors are raised for already-installed scripts +- Task State Store initialization is idempotent + +### NFR-7: Reproducibility + +- Given the same AGENT.md, the same `taskState`, and the same `projectDir`, two dispatches produce identical rendered prompts +- Handlebars rendering is deterministic and side-effect free + +### NFR-8: Security + +- The `tools` allowlist is enforced by the runtime +- A prompt that instructs the agent to use an unlisted tool is rejected before execution +- Repo scripts are executed with the same permissions as the orchestrator +- Task State Store does not execute code from stored taskState values + +--- + +## Data & Storage + +### Commands + +**CompleteTask** +- `taskId: string` +- Occurs when: Worker completes a task successfully + +**FailTask** +- `taskId: string` +- `error: string` +- Occurs when: Worker fails a task (validation error, hook exit code 2, etc.) + +**AppendCompletionNote** +- `taskId: string` +- `note: string` (JSON serialized ExecutionStats) +- Occurs when: Worker completes execution with telemetry + +**InitializeTaskState** +- `taskId: string` +- `initialState: Record` +- Occurs when: Task is received for the first time + +**UpdateTaskState** +- `taskId: string` +- `updates: Record` +- Occurs when: Hook modifies taskState + +**LoadTaskState** +- `taskId: string` +- Occurs when: Hook needs to read current taskState + +**DispatchAgent** +- `agentName: string`, `projectDir: string`, `uow: UnitOfWork`, `dispatchedAt: ISO8601` +- Occurs when: Orchestrator dispatches an agent + +**RunHook** +- `agentName: string`, `hookName: string`, `lifecycle: "Start" | "Stop"`, `projectDir: string` +- Occurs when: Hook is executed + +**InstallRepoScripts** +- `orchestratorName: string`, `projectDir: string`, `installedAt: ISO8601` +- Occurs when: Repo scripts are installed to working repository + +### Events + +**TaskCompleted** +- `taskId: string` +- `claimant: string` +- `telemetry: ExecutionStats` +- `timestamp: Date` + +**TaskFailed** +- `taskId: string` +- `claimant: string` +- `error: string` +- `timestamp: Date` + +**AgentDispatched** +- `agentName: string`, `taskStateSnapshot: Record`, `renderedPromptHash: string`, `dispatchedAt: ISO8601` + +**HookExecuted** +- `agentName: string`, `hookName: string`, `lifecycle: "Start" | "Stop"`, `exitCode: 0 | 1 | 2`, `stdout: string`, `durationMs: number`, `executedAt: ISO8601` + +**AgentHalted** +- `agentName: string`, `reason: string`, `haltedAt: ISO8601` + +**TaskStateValidationFailed** +- `agentName: string`, `missingFields: string[]`, `failedAt: ISO8601` + +**RepoScriptInstalled** +- `agentName: string`, `hookName: string`, `targetPath: string`, `installedAt: ISO8601` + +**RepoScriptAlreadyPresent** +- `agentName: string`, `hookName: string`, `targetPath: string`, `checkedAt: ISO8601` + +**TaskStateUpdated** +- `taskId: string`, `updatedFields: string[]`, `updatedAt: Date` + +**TaskStateInitialized** +- `taskId: string`, `initialState: Record`, `initializedAt: Date` + +### Aggregates + +**Task** +- `id: string` +- `title: string` +- `description: string | null` +- `status: TaskStatus` +- `tags: string[]` +- `claimant: string | null` +- `createdAt: Date | null` +- `updatedAt: Date | null` +- `priority: number` + +**TaskDetail** +- Extends Task +- `dependencies: DependencyRef[]` +- `notes: NoteEntry[]` +- `acceptanceCriteria: ACEntry[]` +- `retro: RetroEntry[]` + +**AgentExecution** +- `taskId: string` +- `agentName: string` +- `claimant: string` +- `startedAt: Date` +- `completedAt: Date | null` +- `telemetry: ExecutionStats | null` +- `verdict: OrchestrationResult` + +**AgentDefinition** +```typescript +type AgentDefinition = { + name: string + description: string + tools: string[] + toolClasses: string[] + template: { + parameters: Record // free-form; validated recursively at dispatch + } + hooks: { + Start: HookSpec[] + Stop: HookSpec[] + } + promptBody: string +} +``` + +**HookSpec** +```typescript +type HookSpec = { + name: string + scriptPath: string // relative to agent dir + specPath: string // path to .md Gherkin spec + isRepoScript: boolean // true = installed to working repo; false = runs from framework packages +} +``` + +**RepoConfig** +```typescript +type RepoConfig = { + languages: Array<{ + name: string + version: string + tools: Array<{ + toolClass: "formatter" | "linter" | "testFramework" | "build" + name: string + version?: string + }> + }> +} +``` + +**UnitOfWork** +```typescript +type UnitOfWork = { + id: string + agentName: string + projectDir: string + taskState: Record + createdAt: string + dispatchedAt: string | null +} +``` + +**DispatchRecord** +```typescript +type DispatchRecord = { + uowId: string + agentName: string + projectDir: string + taskStateSnapshot: Record + renderedPromptHash: string + hookResults: Array<{ + hookName: string + lifecycle: "Start" | "Stop" + exitCode: number + durationMs: number + }> + outcome: "completed" | "halted" + dispatchedAt: string + completedAt: string | null +} +``` + +### Query Projections + +**TaskDetailQuery** +- Question: What is the full context for a specific task? +- Projection: `TaskDetail` by `taskId` +- Used by: Agent execution + +**AgentExecutionHistoryQuery** +- Question: What executions have occurred for a task? +- Projection: List of `AgentExecution` by `taskId` ordered by `startedAt` +- Used by: Debugging and audit + +**AgentSchemaView** +- Question: What taskState shape, tools, and hook contracts does a named agent declare? +- Projection: `AgentDefinition` by `agentName` +- Used by: Dispatcher, validation + +**UoWReadinessView** +- Question: Does a given UoW's taskState satisfy the target agent's parameter schema? +- Projection: Boolean result of schema validation +- Used by: Pre-dispatch validation + +**HookHealthView** +- Question: Which hooks have failed (exit code ≥ 1) for a given agent in the last N dispatches? +- Projection: List of `HookExecuted` events filtered by failure +- Used by: Health monitoring + +**RepoScriptInstallStatusView** +- Question: Which repo scripts have been installed to a given working repository, and which are missing? +- Projection: Set of installed script paths vs required script paths +- Used by: Install validation + +**TaskStateView** +- Question: What is the current taskState for a given task? +- Projection: `taskState` dict by `taskId` +- Used by: Hook execution, follow-up loops + +### Data Retention + +- Task events MUST be retained indefinitely (event sourcing) +- Task aggregate MUST be rebuildable from event stream +- Completed tasks MUST NOT be deleted from task source +- Agent execution history MUST be appended to task as notes +- `DispatchRecord` and associated events: 90 days, then archived or deleted +- `AgentDefinition` snapshot captured at dispatch time: retained with its `DispatchRecord` for the same 90-day window +- `package.json` hash records: retained indefinitely (required for install-skip optimization) +- TaskState MUST be retained until task is completed and archived, then purged according to retention policy + +--- + +## Out of Scope + +- Multi-machine coordination (handled by Task Source plugin) +- Dynamic agent registration (agents must be installed in monorepo) +- Real-time task streaming (polling only, no websockets) +- Task prioritization within the orchestrator (priority is metadata only) +- Automatic retry on failure (manual retry only) +- Task dependencies (dependencies are metadata only) +- Custom scheduling algorithms (FIFO polling only) +- Agent sandboxing (agents run with same permissions as orchestrator) +- Web UI or API for orchestration management +- Level 5 meta-agent behavior: Automated prompt improvement, token-usage instrumentation, and eval-driven skill updates are out of scope. The schema must not prevent these being added later. +- Non-Node.js hook runtimes in v1: Python, Bash, and Rust hooks are out of scope. The exit-code contract is language-agnostic and could support other runtimes in a future version. +- Agent-to-agent calls within a Level 3 task: Level 3 agents execute a single task. Inter-agent calls within a prompt belong to Level 4. +- GUI or web interface for agent authoring: Authoring is file-based (markdown + YAML). +- Agent definition versioning: Semantic versioning of AGENT.md files and compatibility guarantees between versions are out of scope for v1. +- Multi-language dispatch in a single agent invocation: Each dispatch targets one language at a time. Polyglot support comes from separate dispatches. +- Automated Gherkin test execution for hook specs: The `.md` Gherkin spec files are documentation and acceptance criteria only. They are not automatically parsed and run. Provided hook scripts ship with unit tests; teams writing replacement implementations test against the spec on their own. +- Scalar type enforcement in template.parameters: The type hints (e.g., `string`) in `template.parameters` values document intent but are not enforced at runtime in v1. Validation only checks presence and non-emptiness of required fields. +- Orchestrator responsibility for taskState derivation: The orchestrator validates; it does not populate. Whatever produces the UoW — human, CI, or another agent — is responsible for all `taskState` values. +- Repo script upgrade path: When a newer version of the orchestrator program ships an updated repo script, there is no automated mechanism to update already-installed copies in working repositories. Teams update repo scripts manually. A future version may introduce a hash-comparison check with an explicit overwrite command. +- Malicious agent package supply chain: A compromised agent package could declare arbitrary dependencies installed into the shared orchestrator `node_modules`. The v1 mitigation is developer discipline — always review agent definitions and `package.json` before installing. + +--- + +## Dependencies and Assumptions + +### Dependencies + +| Dependency | Purpose | +|---|---| +| `repoConfig.yaml` (working repo) | Toolchain declarations; read by processes that produce UoW taskState | +| TypeScript runtime | Orchestrator framework execution | +| Node.js ≥24 | Runtime for hook script execution and orchestrator program | +| git | Working repository root resolution (`projectDir` detection) | +| npm workspaces | Agent dependency resolution and workspace test orchestration | +| vitest (agent-level devDependency) | Running provided hook unit tests | +| Handlebars (or equivalent) | Prompt template rendering at dispatch time | +| Task State Store backend (Redis, filesystem, in-memory) | taskState persistence across hook executions | + +### Assumptions + +1. The runtime enforces the `tools` allowlist at the execution level. An agent cannot bypass it via prompt instructions. +2. `repoConfig.yaml` is committed to the working repository and readable by any process that needs it. +3. Node.js ≥24 is available in the orchestrator program's execution environment. +4. All hook scripts (framework and repo) are ES modules (`.mjs`) or executable shell scripts. +5. The orchestrator framework is an npm workspace monorepo. `npm install` at the root resolves all agent hook dependencies. +6. `taskState` fields of type `prompt` carry the full text of a skill prompt section as a string value. The UoW producer is responsible for loading skill content and writing it as a string — not a file path. +7. A Level 3 agent is stateless between dispatches. Per-dispatch state lives in `taskState` and is threaded through hook stdin. +8. `validate-args` is the first Start hook by convention. Its absence is an authoring warning, not a parse-time fatal error. +9. When two tools share the same `toolClass` in a `repoConfig.yaml` language entry, the first listed entry is used and a warning with line numbers is emitted. +10. Repo script installation is a one-time operation performed by the orchestrator on first run against a working repository. Subsequent runs are safe and idempotent. +11. The orchestrator is always invoked from inside a valid git repository. The git root is the working repository; no other mechanism for specifying `projectDir` exists. +12. Absent optional Handlebars tokens render as empty string. Prompt authors guard optional sections with `{{#if}}` blocks. +13. Task State Store is available and reachable during hook execution. Unavailability causes UoW failure. +14. Task State Store operations are atomic for single task_id writes. +15. Configuration file exists: `.orchestrator.yaml` in project root or home directory. +16. Network connectivity: Task source is reachable from orchestrator. +17. File system permissions: Orchestrator has read/write access to project directory. +18. Shell availability: `/bin/sh` or compatible shell is available for hook execution. +19. Idempotent hooks: Hooks are safe to run multiple times (follow-up loops). +20. Hook timeouts: Hooks complete within reasonable time or are killed. + +### External System Assumptions + +- **Task Source**: Implements coordination (atomic claims, distributed locks, or queue semantics) to ensure tasks are not yielded to multiple orchestrators simultaneously. Does not re-emit failed tasks. +- **AI Runtime**: Supports executing agents with context and returning structured results. +- **Agent catalog format**: AGENT.md files following the specified schema for Level 3 agents. +- **Task State Store backend**: Supports get/set/delete operations with appropriate performance characteristics. + +--- + +## Open Questions + +### OQ-1: Task State Store Concurrency + +**Ambiguity**: How should multiple concurrent hook executions coordinate access to the Task State Store to prevent race conditions? + +**Assumption**: Task State Store implementations provide atomic operations for single task_id updates. Race conditions are handled by last-write-wins semantics. + +**Ideal Solution**: Implement optimistic locking via version field in taskState. Each update includes version, and updates fail if version has changed. This prevents silent overwrites while supporting distributed execution. + +**Alternatives**: +1. **Last-write-wins**: Simple but may lose updates (current assumption) +2. **Pessimistic locking**: Lock task_id during dispatch (blocks parallelism) +3. **Queue-based execution**: Single worker processes tasks sequentially (limits scalability) + +**Comparison**: Optimistic locking balances correctness with scalability. Last-write-wins is simple but risks data loss. Pessimistic locking eliminates race conditions but blocks parallelism. Queue-based execution is simple but doesn't leverage multi-instance capabilities. Optimistic locking provides the best balance for v1. + +### OQ-2: Task State Storage Backend for Offline Operations + +**Ambiguity**: Should taskState persist across orchestrator restarts? What happens if Task State Store is unavailable? + +**Assumption**: taskState persists across restarts. Task State Store unavailability causes UoW failure. + +**Ideal Solution**: Configurable persistence policy with three modes: `persist` (survives restarts), `ephemeral` (in-memory only), `hybrid` (file-based fallback for unavailability). Unavailability handling configurable: `fail` (current), `continue_with_warning`, `fallback_to_file`. + +**Alternatives**: +1. **Always persist, fail on unavailable**: Current behavior, safe but may halt all work +2. **In-memory only, survive restart via task source**: Complex but resilient +3. **File-based fallback**: Adds complexity but provides resilience + +**Comparison**: Configurable persistence policy provides flexibility for different deployment scenarios. Always persist is safest but creates dependency. In-memory only simplifies but loses state on restart. File-based fallback provides resilience but adds implementation complexity. Configurable policy with fail default offers safety with option for resilience. + +### OQ-3: Task State Size Limits + +**Ambiguity**: Are there limits on taskState size? What happens when hooks store large amounts of data? + +**Assumption**: No explicit limits in v1. Large taskState may cause performance issues or store failures. + +**Ideal Solution**: Configurable size limits with enforcement at save time. Defaults: 1MB for in-memory store, 10MB for Redis, 100MB for file store. Exceeding limit causes UoW failure with descriptive error. + +**Alternatives**: +1. **No limits**: Simple but risks resource exhaustion (current) +2. **Hard limits**: Prevents resource exhaustion but may block legitimate use cases +3. **Soft limits with warning**: Allows exceeding but warns on large taskState + +**Comparison**: Configurable limits with enforcement prevents resource exhaustion while allowing operators to adjust for their needs. No limits is risky. Hard limits may be too restrictive. Soft limits don't prevent problems. Configurable limits with reasonable defaults offers best balance. + +### OQ-4: Cross-Hook State Consistency + +**Ambiguity**: What happens when a hook fails midway through updating taskState? Is partial state saved? + +**Assumption**: taskState is saved only on successful hook completion (exit code 0). Failed hooks do not persist state changes. + +**Ideal Solution**: Transactional taskState updates within a hook execution. Either all changes persist or none do. Hook can explicitly commit mid-execution if needed. + +**Alternatives**: +1. **Save only on success**: Current behavior, simple but prevents incremental progress +2. **Save on each write**: Complex but provides more granular recovery +3. **Explicit commit model**: Flexible but requires hook author awareness + +**Comparison**: Save only on success is simple and prevents inconsistent state. Save on each write provides recovery but may persist invalid state. Explicit commit model offers flexibility but increases complexity. Save on success is appropriate for v1; explicit commit can be added later if needed. + +### OQ-5: Hook Timeout Handling + +**Ambiguity**: What happens to taskState when a hook times out? Are partial changes persisted? + +**Assumption**: Hook timeout is treated as failure (exit code 2). No taskState changes are persisted. + +**Ideal Solution**: Configurable timeout policy: `rollback` (discard changes, current default), `commit` (persist partial changes), `checkpoint` (periodic commits during execution). + +**Alternatives**: +1. **Always rollback**: Current behavior, safe but loses progress +2. **Always commit**: Preserves progress but may persist inconsistent state +3. **Configurable per hook**: Flexible but complex to configure + +**Comparison**: Always rollback is safest and simplest. Always commit risks inconsistent state. Configurable per hook provides flexibility but increases complexity. Always rollback with configurable timeout duration is appropriate for v1. diff --git a/prd-lvl3-agent-definition-v4.md b/prd-lvl3-agent-definition-v4.md new file mode 100644 index 0000000..5b34ca0 --- /dev/null +++ b/prd-lvl3-agent-definition-v4.md @@ -0,0 +1,707 @@ +# PRD: Level 3 Agent Definition Schema — v4 + +**Status:** Draft +**Authors:** Eric Siebeneich, Matthew Wright, Alexander Reeves +**Date:** 2026-05-07 + +--- + +## Product Description + +This system defines the **agent schema** and **orchestration contract** for Level 3 (**task agents**) in a multi-level AI factory model. The model has distinct levels: + +- **Level 2 (skill):** Language- or tool-specific knowledge capsule. Contains a prompt describing how to write code in a specific language, a **toolClass** registry mapping roles (formatter, linter, testFramework, build) to named tools, and file-level hooks that validate output. A skill is agnostic to any task — it only answers: "how do you write X?" +- **Level 3 (task agent):** A **parameterized, task-focused agent** that accepts a **unit of work (UoW)** with pre-populated `taskState` and executes one discrete workflow (e.g., BDD Red phase). It is language-agnostic by design — language, framework, and style arrive via `taskState` at dispatch time. +- **Level 4 (orchestrator program):** Built using the orchestrator framework. Validates that the incoming UoW `taskState` satisfies the target agent's parameter schema, then dispatches the agent. The orchestrator does NOT derive or populate `taskState` values — that is the responsibility of whoever produced the UoW (a human, a CI system, another UoW agent, etc.). +- **Level 5 (meta-agent):** Can augment or modify skill and agent definitions (e.g., add timing hooks, update prompts after evals). + +An **agent definition** is a markdown file with YAML frontmatter (the **AGENT.md**) that fully describes the contract a Level 3 agent exposes. A **hook** is a Node.js `.mjs` script that runs at defined lifecycle points (Start, Stop) to validate preconditions or postconditions. Hooks communicate via **stdin/stdout/exit codes**. + +The **orchestrator framework** is a Node.js monorepo with `workspaces: ["packages/**"]` that provides the scaffolding for building an orchestrator program. Agent packages are installed by symlinking them under `./packages/`. npm workspace dependency resolution handles hoisting and conflict resolution. Provided hook scripts are tested via `npm run test -ws`. + +An **orchestrator program** is a specific orchestrator built using the framework — it embeds a chosen set of agents and is invoked via `bf orchestrate` from within a working repository. `projectDir` is resolved automatically as the git root of the directory `bf orchestrate` was run in. On first run against a working repository, the orchestrator program installs all built-in agent repo scripts into that repository automatically. + +A **repo script** is a hook script that belongs to a specific working repository rather than the orchestrator. It is installed (hard-copied) into the working repo at `/.ai//hooks/.d/.mjs` and committed to version control. The orchestrator program dynamically imports it at runtime. Repo script dependencies are declared in the orchestrator program's `package.json` and resolved from its `node_modules`. + +A **repoConfig** (e.g., `repoConfig.yaml`) is committed to the working repository and declares which languages and tools the project uses. + +--- + +## Problem + +Marcus is a senior engineer building an AI-assisted development workflow. He wants to automate the BDD Red phase across his polyglot monorepo. He writes a BDD-Red agent prompt that hard-codes Python and pytest — but three weeks later the team adds a C# service. Marcus must now maintain two nearly identical agents. When a teammate writes a BDD-Green agent, they hard-code it for TypeScript/Vitest. The agents are not composable: changing the test framework means editing every agent. Hooks that enforce correctness are written as ad-hoc shell scripts with no documented contract, so no one knows which exit code means what. The whole thing collapses when a new engineer joins and tries to wire up a Go service. + +## Goal + +Marcus defines one BDD-Red agent. Its AGENT.md declares the `taskState` fields it requires: `language`, `testFramework`, and `testStyle`. Whatever process creates the UoW — a human, a CI trigger, or another agent — fills those fields before handing it to the orchestrator. The orchestrator validates the schema and dispatches. The same BDD-Red agent handles the C# service when a UoW arrives with C# / XUnit / Gherkin in `taskState`. Hooks are declared in the AGENT.md with a defined stdin schema and exit code contract. Provided hook scripts ship with their own tests and a Gherkin spec `.md` for teams who want to write their own implementation. When Marcus runs `bf orchestrate` in a new working repository for the first time, repo scripts are automatically installed and committed. Marcus's team ships BDD Red across every language without touching the agent definition. + +--- + +## User Stories + +### Story 1: Define a task agent +So that task agents are composable and language-agnostic, +As a developer authoring a new Level 3 agent, +I want to write an AGENT.md with a documented parameter schema, allowed tools, hook lifecycle specs, and a Handlebars prompt body. + +**AC 1.1 — AGENT.md parses correctly** +``` +Given an AGENT.md file with valid YAML frontmatter + And a prompt body containing Handlebars tokens matching declared template parameters +When an orchestrator program reads the file +Then the agent name, description, tools, toolClasses, template parameter schema, and prompt body are all accessible as structured data +``` + +**AC 1.2 — Missing required frontmatter fields fail at parse time** +``` +Given an AGENT.md missing a required frontmatter field (name, description, or tools) +When an orchestrator program reads the file +Then parsing fails with a descriptive error naming the missing field + And the agent is not dispatched +``` + +**AC 1.3 — Optional parameter declared with trailing ?** +``` +Given an AGENT.md template.parameters section where a field name ends with ? +When the parameter schema is parsed +Then that field is marked optional + And the Handlebars renderer does not error if that field is absent from taskState + And an absent optional field renders as empty string +``` + +**AC 1.4 — Optional object with required sub-fields** +``` +Given a template parameter declared as optional (name ends with ?) + And that parameter is an object with one or more sub-fields whose names do not end with ? +When taskState provides that optional parameter +Then all non-? sub-fields of that object must be present and non-empty + And validation fails naming any absent required sub-field by its dot-notation path +When taskState omits the optional parameter entirely +Then no validation error is raised for that parameter or any of its sub-fields +``` + +**AC 1.5 — Undeclared Handlebars tokens are a parse error** +``` +Given a prompt body referencing a Handlebars token not declared in template.parameters +When the AGENT.md is parsed +Then parsing fails identifying the undeclared token by name +``` + +--- + +### Story 2: Install an agent into an orchestrator framework +So that agents are available for dispatch without managing dependency conflicts manually, +As an orchestrator framework maintainer, +I want to symlink an agent directory into `./packages/` and run `npm install` once to resolve all hook dependencies. + +**AC 2.1 — Agent symlinked as workspace package** +``` +Given an orchestrator framework monorepo with workspaces: ["packages/**"] + And an agent directory symlinked under ./packages/ + And a valid package.json in the agent directory +When npm install runs at the orchestrator root +Then the agent's declared dependencies are resolved into the shared node_modules + And version conflicts are resolved by npm workspace hoisting rules +``` + +**AC 2.2 — All provided agent tests run via workspace test command** +``` +Given one or more agents installed as workspace packages + And each agent package declares a "test" script in its package.json +When npm run test -ws runs from the orchestrator root +Then each installed agent's test suite executes + And test results are attributed to their package by name +``` + +--- + +### Story 3: projectDir resolved from git root of CWD +So that running the orchestrator requires no path arguments and works from any subdirectory, +As a developer running `bf orchestrate` from within a working repository, +I want the orchestrator program to automatically resolve the working repository root by walking up from the current working directory. + +**AC 3.1 — Git root resolved from CWD** +``` +Given a developer runs bf orchestrate from a directory inside a git repository +When the orchestrator program starts +Then projectDir is set to the git root of the directory bf orchestrate was invoked from + And no --projectDir argument is required +``` + +**AC 3.2 — Not inside a git repo is a fatal error** +``` +Given a developer runs bf orchestrate from a directory that is not inside any git repository +When the orchestrator program starts +Then it exits with a descriptive error stating that no git root could be found + And no agent dispatch occurs +``` + +**AC 3.3 — Subdirectory invocation resolves to the same root** +``` +Given a git repository rooted at /home/user/myrepo + And a developer runs bf orchestrate from /home/user/myrepo/src/lib +When the orchestrator program starts +Then projectDir is /home/user/myrepo +``` + +--- + +### Story 4: First run installs repo scripts into the working repository +So that working repositories are ready for agent dispatch without manual file management, +As a developer running `bf orchestrate` against a working repository for the first time, +I want repo scripts for all built-in agents to be automatically hard-copied into the working repository and staged for commit. + +**AC 4.1 — Repo scripts installed on first run** +``` +Given an orchestrator program with one or more built-in agents that have repo scripts + And a working repository that has not previously been initialized by this orchestrator +When bf orchestrate runs against the working repository for the first time +Then each repo script is hard-copied to .ai//hooks/.d/.mjs + And no symlinks are created + And the orchestrator logs each installed path +``` + +**AC 4.2 — Already-installed scripts are not overwritten** +``` +Given a working repository that has already been initialized + And a repo script already exists at the expected path +When bf orchestrate runs again +Then the existing script is not overwritten + And the orchestrator logs that the script is already present +``` + +**AC 4.3 — Installed scripts are ready to commit** +``` +Given repo scripts installed by bf orchestrate +When a developer runs git status in the working repository +Then the installed .mjs files appear as new untracked files + And no other files in the working repository are modified by the install step +``` + +--- + +### Story 5: Dispatch an agent with a pre-populated unit of work +So that the orchestrator remains a thin validation and dispatch layer, +As a Level 4 orchestrator program, +I want to validate an incoming UoW's `taskState` against the target agent's parameter schema and dispatch only when all required fields are satisfied. + +**AC 5.1 — Valid taskState: agent dispatches** +``` +Given a Level 3 agent with a declared template.parameters schema + And a UoW whose taskState satisfies all required parameters recursively +When the orchestrator program dispatches +Then the rendered prompt is injected into the agent's context + And the agent begins work +``` + +**AC 5.2 — Missing required field: dispatch fails before agent starts** +``` +Given a UoW whose taskState is missing a required parameter +When the orchestrator program attempts dispatch +Then the Start hook validate-args exits with code 2 + And the agent does not execute any prompt + And the error identifies the missing field by its dot-notation path +``` + +**AC 5.3 — Missing required sub-field of present optional object** +``` +Given a template parameter that is an optional object (name ends with ?) + And the UoW taskState provides that object + And the object is missing a required sub-field (sub-field name does not end with ?) +When validate-args runs +Then validation fails identifying the missing sub-field by its dot-notation path +``` + +**AC 5.4 — Empty string treated as missing** +``` +Given a UoW taskState where a required field is present but set to empty string +When validate-args runs +Then validation fails as if the field were absent +``` + +**AC 5.5 — Orchestrator does not derive taskState values** +``` +Given a UoW with an incomplete taskState +When the orchestrator program receives it +Then the orchestrator does not read repoConfig, inspect the workspace, or call any external service to fill in missing values + And it fails validation and returns the error to the caller +``` + +--- + +### Story 6: Lifecycle hooks enforce preconditions and postconditions +So that agents produce correct, validated output, +As a developer defining a Level 3 agent, +I want to declare Start and Stop hooks in the AGENT.md that run `.mjs` scripts at defined lifecycle points with a documented stdin/stdout/exit-code contract. + +**AC 6.1 — Start hooks run before the agent prompt** +``` +Given an AGENT.md with a hooks.Start section containing one or more hook specs +When the agent is dispatched with a valid UoW +Then each Start hook executes in declaration order before the agent receives its prompt + And exit code 0 allows the agent to proceed + And exit code 1 passes hook stdout to the agent as a warning and continues + And exit code 2 halts the agent and surfaces the error to the orchestrator +``` + +**AC 6.2 — Stop hooks run after agent completes** +``` +Given an AGENT.md with a hooks.Stop section +When the agent's prompt execution finishes +Then each Stop hook executes in declaration order + And exit code 1 returns stdout to the agent for remediation + And exit code 2 halts and reports the error +``` + +**AC 6.3 — Hook receives UoW context via stdin** +``` +Given a running hook +When stdin is read +Then the hook receives a JSON object containing: projectDir (string), params (the resolved taskState values), taskState (full UoW taskState object) + And the rendered prompt is NOT present in stdin +``` + +**AC 6.4 — Cross-hook state passes through taskState** +``` +Given a Start hook that writes data into taskState (e.g., snapshot-tests writes file hashes) + And a Stop hook that reads that data (e.g., check-new-tests reads file hashes) +When both hooks run within the same dispatch +Then the Stop hook receives taskState as modified by all preceding hooks +``` + +**AC 6.5 — validate-args rejects invalid taskState** +``` +Given a Start hook of type validate-args + And a UoW taskState failing schema validation (missing or empty required field) +When the hook executes +Then the hook exits with code 2 + And stdout identifies the failing field by its dot-notation path +``` + +**AC 6.6 — snapshot-tests records existing test state into taskState** +``` +Given an agent that will write new tests + And test files already existing in the project +When the snapshot-tests Start hook executes +Then taskState contains a hash of each existing test file's content keyed by relative file path +``` + +**AC 6.7 — check-new-tests validates new tests were written** +``` +Given taskState containing the pre-dispatch test file snapshot +When the check-new-tests Stop hook executes +Then the hook compares current test files against the snapshot + And exits 2 if no new test file was added and no existing file's hash changed + And exits 0 if at least one new test file exists or at least one existing file's hash changed +``` + +--- + +### Story 7: Hook behavior is specified via Gherkin scenarios in markdown +So that teams can implement their own hook scripts and know exactly what behavior is required, +As a developer onboarding an agent into their orchestrator, +I want a Gherkin spec `.md` file co-located with each provided hook script that serves as the acceptance spec for custom implementations. + +**AC 7.1 — Spec file is co-located with each provided script** +``` +Given a provided hook script at hooks/.d/.mjs +When the agent directory is inspected +Then a co-located hooks/.d/.md exists + And it contains at least one Scenario block with Given/When/Then steps + And it contains no implementation-specific details (no function names, no line numbers) +``` + +**AC 7.2 — Provided scripts have unit tests** +``` +Given a provided hook script at hooks/.d/.mjs +When the agent package's test suite runs +Then at least one test case exercises the script's behavior directly + And all provided tests pass against the provided implementation +``` + +**AC 7.3 — Spec is the sole reference for custom implementations** +``` +Given a developer who replaces a provided script with their own implementation +When they read the co-located .md file +Then the Gherkin scenarios fully specify the required input/output/exit-code behavior + And no additional documentation is required to produce a conforming replacement +``` + +--- + +### Story 8: repoConfig toolClass conflicts emit a warning with location +So that misconfigured repos are caught early without silently producing wrong behavior, +As a developer maintaining a repoConfig.yaml, +I want the orchestrator program to warn me when multiple tools are registered for the same toolClass within one language entry, including the exact line numbers of both conflicting entries. + +**AC 8.1 — First match used, warning emitted with line numbers** +``` +Given a repoConfig.yaml where a language entry lists two tools with the same toolClass +When the orchestrator program reads repoConfig +Then the first matching tool entry is used + And a warning is emitted identifying the language name, the conflicting toolClass, and the line numbers of both conflicting entries +``` + +**AC 8.2 — No warning when each toolClass appears exactly once** +``` +Given a repoConfig.yaml where each language entry has at most one tool per toolClass +When the orchestrator program reads repoConfig +Then no toolClass conflict warning is emitted +``` + +--- + +## Functional Requirements + +### Agent Definition File (AGENT.md) + +Format: Markdown file with YAML frontmatter delimited by `---`. The prompt body follows the closing `---`. + +Required frontmatter fields: + +| Field | Type | Description | +|---|---|---| +| `name` | string | Unique agent identifier (kebab-case) | +| `description` | string | One-line description used for orchestrator routing | +| `tools` | string[] | Explicit allowlist of Claude Code tools this agent may use | +| `toolClasses` | string[] | Optional. Tool role types this agent requires. Informational — documents what must be available. | +| `template.parameters` | object | Free-form YAML shape declaring the taskState structure this agent expects | + +The `tools` list is a strict allowlist enforced by the harness. Bash is not implicitly permitted. Any tool not listed is denied regardless of what the prompt requests. + +### template.parameters Schema Rules + +`template.parameters` is a free-form YAML object of any shape. The following conventions govern optionality: + +- A field whose key ends with `?` is **optional**. It may be absent from `taskState` without causing a validation error. When absent, its Handlebars token renders as empty string. +- A field whose key does not end with `?` is **required**. It must be present and non-empty in `taskState`. +- Rules apply recursively: if an optional object is provided, all of its non-`?`-suffixed sub-fields are required. If an optional object is absent, none of its sub-fields are evaluated. +- Scalar values (e.g., `string`) are type hints only and are not enforced at runtime. +- Prompt authors use `{{#if paramName}}...{{/if}}` to guard sections that depend on optional parameters. + +Example: +```yaml +template: + parameters: + language: + name: string + prompt: string + testFramework: + name: string + prompt: string + testStyle: + name: string + prompt: string + userPrompt: string + context?: + prDescription: string + additionalNotes?: string +``` + +In this example: `language`, `testFramework`, `testStyle`, and `userPrompt` are required. `context` is optional; if provided, `context.prDescription` is required and `context.additionalNotes` is optional. + +Handlebars tokens in the prompt body must match declared parameter paths exactly. Unknown tokens are a parse-time error. + +### projectDir Resolution + +`projectDir` is not passed as a CLI argument. When `bf orchestrate` is invoked, the orchestrator program walks up from the current working directory to find the nearest ancestor directory containing a `.git` folder. That directory becomes `projectDir` for the duration of the run. If no git root is found, the program exits with a fatal error. + +### Orchestrator Framework and Orchestrator Program + +The **orchestrator framework** is a Node.js monorepo: + +``` +orchestrator-framework/ + package.json # workspaces: ["packages/**"] + node_modules/ # shared, hoisted + packages/ + bdd-red -> /path/to/bdd-red # symlink + bdd-green -> /path/to/bdd-green +``` + +An **orchestrator program** is built on this framework and embeds a chosen set of agents. When `bf orchestrate` runs against a working repository for the first time, it performs a one-time install of all built-in repo scripts before any dispatch occurs. + +### Working Repository Layout (after install) + +``` +/ + repoConfig.yaml + .ai/ + / + hooks/ + Start.d/ + .mjs # hard-copied, committed + Stop.d/ + .mjs +``` + +Repo scripts are hard-copied (never symlinked) and committed. The orchestrator program imports them via dynamic `import()` using the absolute path resolved from `projectDir + "/.ai//hooks/..."`. Their dependencies are declared in the orchestrator program's `package.json` and resolved from its `node_modules`. + +### Agent Package Layout + +``` +/ + AGENT.md + package.json + hooks/ + Start.d/ + .mjs # provided script + .md # Gherkin acceptance spec + Stop.d/ + .mjs + .md +``` + +Hook execution order within each `.d/` directory: alphabetical by filename. + +### Hook Contract + +**stdin (JSON):** +```json +{ + "projectDir": "string", + "params": {}, + "taskState": {} +} +``` + +The rendered agent prompt is NOT included. Cross-hook state is communicated via `taskState` mutations passed through to subsequent hooks. + +**Exit codes:** + +| Code | Meaning | Effect | +|---|---|---| +| 0 | Success | Proceed | +| 1 | Recoverable error | Hook stdout passed to agent as context; execution continues | +| 2 | Fatal error | Agent halts; error surfaced to orchestrator caller | + +**Script format:** `.mjs` (ES module). Executed in the orchestrator program's Node.js context via dynamic `import()`. + +### Built-in Hook Specs + +| Hook | Lifecycle | Type | Purpose | +|---|---|---|---| +| `validate-args` | Start | framework | Assert all required taskState fields are non-empty per declared schema | +| `snapshot-tests` | Start | framework | Hash existing test files into taskState | +| `check-new-tests` | Stop | framework | Assert at least one new test was added since snapshot | +| `lint` | Stop | repo script | Run project linter (resolved from repoConfig `linter` toolClass) | +| `format` | Stop | repo script | Run project formatter (resolved from repoConfig `formatter` toolClass) | + +Framework hooks run from the orchestrator's `packages/` context. Repo scripts are loaded from the working repository's `.ai/` directory via dynamic `import()`. + +### repoConfig.yaml + +```yaml +languages: + - name: string # e.g., "csharp", "node" + version: string # semver range + tools: + - toolClass: string # "formatter" | "linter" | "testFramework" | "build" + name: string + version: string # optional +``` + +When multiple tools share the same `toolClass` within one language entry, the first entry wins and a warning is emitted identifying the language name, the conflicting toolClass, and the line numbers of both entries. + +### Level Hierarchy Constraints + +- Level 2 skills do not know about Level 3 task workflows. +- Level 3 task agents do not know about Level 4 orchestrators and do not read repoConfig directly. +- Level 3 agents MUST NOT embed language names, framework names, or version numbers directly in their prompt bodies. +- Level 4 orchestrator programs validate UoW `taskState` but do NOT derive or populate parameter values. +- The `tools` allowlist is enforced by the runtime harness, not the prompt. + +--- + +## Non-Functional Requirements + +1. **Parse performance:** AGENT.md parsing completes in under 100ms for files under 10KB. No I/O outside the agent directory is performed during parsing. + +2. **Install idempotency:** Running `bf orchestrate` multiple times against the same working repository produces the same result. Already-present repo scripts are not overwritten. No errors are raised for already-installed scripts. + +3. **Dependency resolution:** Agent hook dependency conflicts are surfaced as npm version resolution warnings at `npm install` time, not as runtime failures. The orchestrator framework's single `node_modules` is authoritative. + +4. **Validation fail-fast:** `validate-args` executes before any tool calls are made. An agent must not consume tokens on a task it cannot complete. + +5. **Reproducibility:** Given the same AGENT.md, the same `taskState`, and the same `projectDir`, two dispatches produce identical rendered prompts. + +6. **Error clarity:** Every validation failure, hook exit-2, and parse error must name the specific field or file path that caused the failure, using dot-notation for nested fields. Generic messages are not acceptable. + +7. **Observability:** The orchestrator program emits structured JSON log entries for: git root resolution, agent load, taskState validation result, hook start, hook exit (with exit code and duration), and agent dispatch. Log level is configurable. + +8. **Security (tools allowlist):** The `tools` allowlist is enforced by the runtime harness. A prompt that instructs the agent to use an unlisted tool is rejected before execution. + +--- + +## Data & Storage + +### Commands + +| Command | Fields | +|---|---| +| `DispatchAgent` | `agentName: string`, `projectDir: string`, `uow: UnitOfWork`, `dispatchedAt: ISO8601` | +| `RunHook` | `agentName: string`, `hookName: string`, `lifecycle: "Start" \| "Stop"`, `projectDir: string` | +| `InstallRepoScripts` | `orchestratorName: string`, `projectDir: string`, `installedAt: ISO8601` | + +### Events + +| Event | Fields | +|---|---| +| `AgentDispatched` | `agentName: string`, `taskStateSnapshot: Record`, `renderedPromptHash: string`, `dispatchedAt: ISO8601` | +| `HookExecuted` | `agentName: string`, `hookName: string`, `lifecycle: "Start" \| "Stop"`, `exitCode: 0 \| 1 \| 2`, `stdout: string`, `durationMs: number`, `executedAt: ISO8601` | +| `AgentHalted` | `agentName: string`, `reason: string`, `haltedAt: ISO8601` | +| `TaskStateValidationFailed` | `agentName: string`, `missingFields: string[]`, `failedAt: ISO8601` | +| `RepoScriptInstalled` | `agentName: string`, `hookName: string`, `targetPath: string`, `installedAt: ISO8601` | +| `RepoScriptAlreadyPresent` | `agentName: string`, `hookName: string`, `targetPath: string`, `checkedAt: ISO8601` | + +### Aggregates + +**AgentDefinition** +```typescript +type AgentDefinition = { + name: string + description: string + tools: string[] + toolClasses: string[] + template: { + parameters: Record // free-form; validated recursively at dispatch + } + hooks: { + Start: HookSpec[] + Stop: HookSpec[] + } + promptBody: string +} +``` + +**HookSpec** +```typescript +type HookSpec = { + name: string + scriptPath: string // relative to agent dir + specPath: string // path to .md Gherkin spec + isRepoScript: boolean // true = installed to working repo; false = runs from framework packages +} +``` + +**RepoConfig** +```typescript +type RepoConfig = { + languages: Array<{ + name: string + version: string + tools: Array<{ + toolClass: "formatter" | "linter" | "testFramework" | "build" + name: string + version?: string + }> + }> +} +``` + +**UnitOfWork** +```typescript +type UnitOfWork = { + id: string + agentName: string + projectDir: string + taskState: Record + createdAt: string + dispatchedAt: string | null +} +``` + +**DispatchRecord** +```typescript +type DispatchRecord = { + uowId: string + agentName: string + projectDir: string + taskStateSnapshot: Record + renderedPromptHash: string + hookResults: Array<{ + hookName: string + lifecycle: "Start" | "Stop" + exitCode: number + durationMs: number + }> + outcome: "completed" | "halted" + dispatchedAt: string + completedAt: string | null +} +``` + +### Query Projections + +| Projection | Question answered | +|---|---| +| `AgentSchemaView` | What taskState shape, tools, and hook contracts does a named agent declare? | +| `UoWReadinessView` | Does a given UoW's taskState satisfy the target agent's parameter schema? | +| `HookHealthView` | Which hooks have failed (exit code ≥ 1) for a given agent in the last N dispatches? | +| `RepoScriptInstallStatusView` | Which repo scripts have been installed to a given working repository, and which are missing? | +| `DispatchHistoryView` | What is the full hook timeline and outcome for a given dispatch (by UoW id)? | + +### Data Retention + +- `DispatchRecord` and associated events: 90 days, then archived or deleted. +- `AgentDefinition` snapshot captured at dispatch time: retained with its `DispatchRecord` for the same 90-day window. +- `package.json` hash records: retained indefinitely (required for install-skip optimization). + +--- + +## Out of Scope + +- **Level 5 meta-agent behavior:** Automated prompt improvement, token-usage instrumentation, and eval-driven skill updates are out of scope. The schema must not prevent these being added later. + +- **Non-Node hook runtimes in v1:** Python, Bash, and Rust hooks are out of scope. The exit-code contract is language-agnostic and could support other runtimes in a future version. + +- **Agent-to-agent calls within a Level 3 task:** Level 3 agents execute a single task. Inter-agent calls within a prompt belong to Level 4. + +- **GUI or web interface for agent authoring:** Authoring is file-based (markdown + YAML). + +- **Agent definition versioning:** Semantic versioning of AGENT.md files and compatibility guarantees between versions are out of scope for v1. + +- **Multi-language dispatch in a single agent invocation:** Each dispatch targets one language at a time. Polyglot support comes from separate dispatches. + +- **Automated Gherkin test execution for hook specs:** The `.md` Gherkin spec files are documentation and acceptance criteria only. They are not automatically parsed and run. Provided hook scripts ship with vitest unit tests; teams writing replacement implementations test against the spec on their own. + +- **Scalar type enforcement in template.parameters:** The type hints (e.g., `string`) in `template.parameters` values document intent but are not enforced at runtime in v1. Validation only checks presence and non-emptiness of required fields. + +- **Orchestrator responsibility for taskState derivation:** The orchestrator validates; it does not populate. Whatever produces the UoW — human, CI, or another agent — is responsible for all `taskState` values. + +- **Dependency conflict isolation between agents via separate node_modules trees:** npm workspace hoisting is the conflict resolution mechanism. Per-agent isolation via Docker or separate processes is out of scope for v1. + +- **Repo script upgrade path:** When a newer version of the orchestrator program ships an updated repo script, there is no automated mechanism to update already-installed copies in working repositories. Teams update repo scripts manually. A future version may introduce a hash-comparison check with an explicit overwrite command. + +- **Malicious agent package supply chain:** A compromised agent package could declare arbitrary dependencies installed into the shared orchestrator `node_modules`. The v1 mitigation is developer discipline — always review agent definitions and `package.json` before installing. A future mitigation is a long-running agent process scoped to `/.ai/` that isolates the orchestrator process from untrusted hook code while avoiding per-invocation startup overhead. That architecture is not designed here. + +--- + +## Dependencies and Assumptions + +### Assumptions + +1. The Claude Code harness enforces the `tools` allowlist at the runtime level. An agent cannot bypass it via prompt instructions. +2. `repoConfig.yaml` is committed to the working repository and readable by any process that needs it. +3. Node.js ≥24 is available in the orchestrator program's execution environment. +4. All hook scripts (framework and repo) are ES modules (`.mjs`). CommonJS is not supported. +5. The orchestrator framework is an npm workspace monorepo. `npm install` at the root resolves all agent hook dependencies. +6. `taskState` fields of type `prompt` carry the full text of a skill prompt section as a string value. The UoW producer is responsible for loading skill content and writing it as a string — not a file path. +7. A Level 3 agent is stateless between dispatches. Per-dispatch state lives in `taskState` and is threaded through hook stdin. +8. `validate-args` is the first Start hook by convention. Its absence is an authoring warning, not a parse-time fatal error. +9. When two tools share the same `toolClass` in a `repoConfig.yaml` language entry, the first listed entry is used and a warning with line numbers is emitted. +10. Repo script installation is a one-time operation performed by `bf orchestrate` on first run against a working repository. Subsequent runs are safe and idempotent. +11. `bf orchestrate` is always invoked from inside a valid git repository. The git root is the working repository; no other mechanism for specifying `projectDir` exists. +12. Absent optional Handlebars tokens render as empty string. Prompt authors guard optional sections with `{{#if}}` blocks. + +### Dependencies + +| Dependency | Purpose | +|---|---| +| `repoConfig.yaml` (working repo) | Toolchain declarations; read by processes that produce UoW taskState | +| Claude Code harness | Runtime enforcement of the `tools` allowlist | +| Node.js ≥24 | Hook script execution and orchestrator program runtime | +| git | Working repository root resolution (`projectDir` detection) | +| npm workspaces | Agent dependency resolution and workspace test orchestration | +| vitest (agent-level devDependency) | Running provided hook unit tests via `npm run test -ws` | +| Handlebars (or equivalent) | Prompt template rendering at dispatch time | From fb8e4928c12713109eb8604a70cd97225ea39063 Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Thu, 7 May 2026 11:31:35 -0500 Subject: [PATCH 02/33] v3 --- orchestrator-prd-v2.md | 86 +++ orchestrator-prd-v3.md | 1215 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1301 insertions(+) create mode 100644 orchestrator-prd-v3.md diff --git a/orchestrator-prd-v2.md b/orchestrator-prd-v2.md index 2fd70ce..fb70555 100644 --- a/orchestrator-prd-v2.md +++ b/orchestrator-prd-v2.md @@ -1103,6 +1103,92 @@ type DispatchRecord = { --- +## Decisions + +This section records design decisions made to resolve the open questions below. These decisions establish clear boundaries between the orchestrator framework and its plugins, and inform the v3 iteration. + +### D-1: Task Source Concurrency + +**Decision:** Task Source coordination is entirely out of scope for the orchestrator framework. + +**Rationale:** The orchestrator consumes tasks via the `TaskSource.watchTasks()` async iterator. It does not manage claiming, locking, or queue semantics. The Task Source plugin developer is responsible for all coordination concerns. + +**Implications:** +- Orchestrator implements no distributed locking +- Orchestrator does not detect or handle duplicate task dispatch +- Task Source plugins must ensure each task yielded to at most one consumer +- Coordination for multiple orchestrator instances is the Task Source's responsibility + +### D-2: Task State Persistence and Reconnection + +**Decision:** Task state persistence is the Task Source's responsibility. Orchestrator recreates Task Source instance on iterator failure. + +**Rationale:** When the Task Source's async iterator dies (throws, crashes, disconnects), the orchestrator should reconnect automatically rather than requiring manual restart. Persistence of task state across restarts is the Task Source plugin's concern. + +**Behavior:** +1. If `watchTasks()` async iterator terminates, wait 1 minute +2. Create new Task Source instance with same configuration +3. Call `watchTasks()` again +4. Log the reconnection attempt +5. Continue indefinitely until explicit shutdown + +**Implications:** +- Orchestrator does not persist task state across restarts +- Task Source must handle its own persistence requirements +- Reconnection delay fixed at 1 minute for v1 +- Orchestrator does not distinguish error types — all trigger same retry logic + +### D-3: Task State Size Limits + +**Decision:** Orchestrator does not enforce taskState size limits. This is the Task State Store's responsibility. + +**Rationale:** The orchestrator treats taskState as an opaque object beyond parameter schema validation. Size limits, if any, are enforced by the Task State Store implementation. + +**Implications:** +- Orchestrator does not validate taskState size +- If Task State Store rejects due to size, UoW marked as failed +- Different implementations may have different limits +- Store error messages surfaced to task source + +### D-4: Task State Update Atomicity + +**Decision:** Task State Store operations are assumed to be atomic. Orchestrator implements no additional consistency mechanisms. + +**Rationale:** The `loadTaskState`/`saveTaskState` interface is treated as atomic. Either update succeeds or fails. Orchestrator does not implement transactions, versioning, or conflict resolution. + +**Implications:** +- Each `saveTaskState` call is single atomic operation +- Save failure = UoW marked as failed +- Orchestrator does not retry failed saves +- Concurrent writes follow Store's semantics (last-write-wins, error, etc.) + +### D-5: Hook Timeout Configuration and Handling + +**Decision:** Hook timeouts configured per-hook in AGENT.md frontmatter. Timeout results in exit code 2 (fatal error). + +**Configuration:** +```yaml +hooks: + Start: + - name: validate-args + scriptPath: hooks/Start.d/validate-args.mjs + timeout: 300000 # milliseconds, optional +``` + +**Behavior:** +- Hook exceeds timeout = execution terminated +- Treated as exit code 2 (fatal error) +- Logged: "Hook {hookName} exceeded timeout of {timeout}ms" +- UoW marked as failed +- Default if not specified: 5 minutes (300000ms) + +**Implications:** +- Each hook specifies own timeout +- Orchestrator terminates execution at timeout (subprocess kill) +- Partial state changes from timed-out hook not persisted + +--- + ## Open Questions ### OQ-1: Task State Store Concurrency diff --git a/orchestrator-prd-v3.md b/orchestrator-prd-v3.md new file mode 100644 index 0000000..dd31219 --- /dev/null +++ b/orchestrator-prd-v3.md @@ -0,0 +1,1215 @@ +# Unified Orchestrator PRD v3 + +**Status:** Draft +**Authors:** Eric Siebeneich, Matthew Wright, Alexander Reeves +**Date:** 2026-05-07 +**Version:** 3.0 + +--- + +## Product Description, Problem, and Goal + +### Product Description + +The **Orchestrator Framework** is a TypeScript-based distributed task execution system that coordinates **AI agents** to perform work across multiple projects. It provides a plugin architecture with **Task Sources** (providers of work), **Engines** (executors of work), and **Task State Stores** (task state persistence), connected by a core orchestration layer that manages task lifecycle, state transitions, and telemetry. + +The system implements a **multi-level AI factory model**: + +- **Level 2 (skill):** Language- or tool-specific knowledge capsule. Contains a prompt describing how to write code in a specific language, a **toolClass** registry mapping roles (formatter, linter, testFramework, build) to named tools, and file-level hooks that validate output. A skill is agnostic to any task — it only answers: "how do you write X?" +- **Level 3 (task agent):** A **parameterized, task-focused agent** that accepts a **unit of work (UoW)** with pre-populated `taskState` and executes one discrete workflow (e.g., BDD Red phase). It is language-agnostic by design — language, framework, and style arrive via `taskState` at dispatch time. +- **Level 4 (orchestrator program):** Built using the orchestrator framework. Validates that the incoming UoW `taskState` satisfies the target agent's parameter schema, then dispatches the agent. The orchestrator does NOT derive or populate `taskState` values — that is the responsibility of whoever produced the UoW (a human, a CI system, another UoW agent, etc.). +- **Level 5 (meta-agent):** Can augment or modify skill and agent definitions (e.g., add timing hooks, update prompts after evals). + +**Key Terms:** + +- **Task**: A unit of work to be executed by an agent, containing title, description, tags, and metadata +- **Agent**: An AI worker with specific capabilities (model, tools, prompt) that executes tasks +- **AGENT.md**: Markdown file with YAML frontmatter that fully describes a Level 3 agent's contract +- **taskState**: Free-form object containing all context for a single task execution, including language, framework, and cross-hook state +- **Task Source**: Plugin that discovers, claims, and fulfills tasks from an external system (e.g., API, database, queue) +- **Engine**: Plugin that executes tasks using a specific mechanism (e.g., AI runtime, CLI tool) +- **Task State Store**: Plugin that persists and retrieves taskState across hook executions +- **Hook**: Shell command or Node.js script executed before (Start) or after (Stop) agent execution +- **repo script**: Hook script that belongs to a specific working repository, installed to `.ai//hooks/` +- **repoConfig**: YAML file committed to working repository declaring languages and tools +- **toolClass**: Role identifier (formatter, linter, testFramework, build) for tool resolution +- **Claimant**: Identifier for the agent instance currently working on a task +- **projectDir**: Git root of the working repository, resolved automatically +- **Orchestration**: The process of coordinating hooks, engine execution, and follow-up loops +- **Follow-up**: Additional agent execution triggered by Stop hooks to address issues +- **Handlebars**: Template syntax used in AGENT.md prompt bodies for taskState substitution + +### Problem + +Sarah is a platform engineer managing a monorepo with 50+ services. Her team has implemented AI agents to help with routine maintenance tasks (dependency updates, code refactors, security patches). Each agent works great in isolation, but Sarah has five critical problems: + +1. **No coordination**: Multiple agent instances compete for the same tasks, causing duplicate work +2. **No observability**: When an agent fails, there's no telemetry—did it timeout? run out of tokens? hit a bug? +3. **No integration**: Agents can't validate work with project-specific checks (running tests, linters) before marking tasks complete +4. **Tight coupling**: Agents hard-code language and framework details. When the team adds a C# service, Sarah must maintain separate C# agents +5. **No state sharing**: Hooks can't pass data to each other—snapshot-tests captures file hashes but check-new-tests can't read them + +Marcus, a senior engineer on Sarah's team, wants to automate the BDD Red phase across the polyglot monorepo. He writes a BDD-Red agent prompt that hard-codes Python and pytest — but three weeks later the team adds a C# service. Marcus must now maintain two nearly identical agents. When a teammate writes a BDD-Green agent, they hard-code it for TypeScript/Vitest. The agents are not composable: changing the test framework means editing every agent. Hooks that enforce correctness are written as ad-hoc shell scripts with no documented contract, so no one knows which exit code means what. + +Sarah spends hours manually deconflicting agents, digging through logs to understand failures, and manually validating work before merging. She can't scale her automation beyond a few agents without creating more work than she saves. + +### Goal + +With the Orchestrator Framework, Sarah builds orchestrator instances tailored to her infrastructure. She configures a **Task Source** that connects to her task management system, an **Engine** that uses her preferred AI runtime, and a **Task State Store** that persists taskState in Redis. When a task is ready, the orchestrator: + +1. Receives the task from the Task Source's async iterator +2. Loads agent definition from AGENT.md (template parameters, hooks, prompt) +3. Validates taskState against agent's parameter schema +4. Runs **Start hooks** (validate-args, snapshot-tests, project-specific validation) +5. Renders Handlebars prompt with taskState values +6. Executes the task with the appropriate agent (model, tools from agent catalog) +7. Runs **Stop hooks** (check-new-tests, lint, format, custom checks) +8. If hooks report issues (exit code 1), triggers a **follow-up** loop to address them +9. If hooks report fatal errors (exit code 2), marks the UoW as failed +10. Persists updated taskState (including cross-hook state) via Task State Store +11. Marks the task complete with full telemetry (tokens used, duration, cost) + +Marcus defines one BDD-Red agent. Its AGENT.md declares the `taskState` fields it requires: `language`, `testFramework`, and `testStyle`. Whatever process creates the UoW — a human, a CI trigger, or another agent — fills those fields before handing it to the orchestrator. The orchestrator validates the schema and dispatches. The same BDD-Red agent handles the C# service when a UoW arrives with C# / XUnit / Gherkin in taskState. Hooks are declared in the AGENT.md with a defined stdin schema and exit code contract. Repo scripts are automatically installed to the working repository's `.ai/` directory on first run. Hooks communicate via taskState, so snapshot-tests writes file hashes that check-new-tests reads. + +Sarah now has reliable, scalable, composable automation. She can deploy multiple agents knowing the Task Source provides coordination (claiming, locking, or queue semantics). She has full visibility into execution. She trusts the automation because hooks validate work before completion. Marcus's team ships BDD Red across every language without touching the agent definition. + +--- + +## User Stories / Use Cases + +### US-1: Define a task agent with AGENT.md + +**As an** agent author +**I want** to write an AGENT.md with a documented parameter schema, allowed tools, hook lifecycle specs, and a Handlebars prompt body +**So that** task agents are composable and language-agnostic + +**Acceptance Criteria:** + +``` +Given an AGENT.md file with valid YAML frontmatter + And a prompt body containing Handlebars tokens matching declared template parameters +When the orchestrator reads the file +Then the agent name, description, tools, toolClasses, template parameter schema, and prompt body are all accessible as structured data +``` + +``` +Given an AGENT.md missing a required frontmatter field (name, description, or tools) +When the orchestrator reads the file +Then parsing fails with a descriptive error naming the missing field + And the agent is not dispatched +``` + +``` +Given an AGENT.md template.parameters section where a field name ends with ? +When the parameter schema is parsed +Then that field is marked optional + And the Handlebars renderer does not error if that field is absent from taskState + And an absent optional field renders as empty string +``` + +``` +Given a template parameter declared as optional (name ends with ?) + And that parameter is an object with one or more sub-fields whose names do not end with ? +When taskState provides that optional parameter +Then all non-? sub-fields of that object must be present and non-empty + And validation fails naming any absent required sub-field by its dot-notation path +When taskState omits the optional parameter entirely +Then no validation error is raised for that parameter or any of its sub-fields +``` + +``` +Given a prompt body referencing a Handlebars token not declared in template.parameters +When the AGENT.md is parsed +Then parsing fails identifying the undeclared token by name +``` + +### US-2: Task Source emits available tasks + +**As a** task source plugin author +**I want** to emit available tasks via an async iterator +**So that** the orchestrator can dispatch them + +**Acceptance Criteria:** + +``` +Given a task is available for processing + And the task source has exclusive ownership (via claim, lock, or queue dequeue) +When the orchestrator polls the task source +Then the task source yields the task via its async iterator +``` + +``` +Given a task source supports concurrent polling + And two orchestrator instances poll simultaneously +When both poll the task source +Then each task is yielded to at most one orchestrator + And the task source handles coordination (atomic claims, distributed locks, or queue semantics) +``` + +``` +Given a task source yields a task +When the orchestrator receives the task +Then the orchestrator dispatches the task without checking ownership + And ownership is the task source's responsibility +``` + +``` +Given the task source async iterator throws an error or terminates unexpectedly +When the orchestrator detects the termination +Then the orchestrator waits 1 minute + And creates a new Task Source instance with the same configuration + And calls watchTasks() again + And logs the reconnection attempt +``` + +### US-3: Agent Operator - Dispatch agent on task + +**As an** agent operator +**I want** to dispatch a task with the appropriate agent +**So that** work is completed + +**Acceptance Criteria:** + +``` +Given a task is yielded from the task source + And the task has a "worker:reviewer" tag + And the reviewer agent is configured in the agent catalog +When the orchestrator receives the task +Then Start hooks are executed in sequence + And the reviewer agent is invoked with the task context + And Stop hooks are executed in sequence + And the task is marked complete + And execution telemetry is recorded +``` + +``` +Given a task has no worker tag (no "worker:*" in tags) +When the orchestrator receives the task +Then the task is marked as failed + And the orchestrator logs the reason + And the task source is notified of the failure +``` + +``` +Given a task's taskState fails agent schema validation +When the orchestrator receives the task +Then the task is marked as failed + And the orchestrator logs the validation error + And the task source is notified of the failure +``` + +### US-4: Project Maintainer - Extend Agent with Hooks + +**As a** project maintainer +**I want** to attach shell commands to agent lifecycle events +**So that** agents can validate work and augment prompts + +**Acceptance Criteria:** + +``` +Given an AGENT.md with a hooks.Start section containing one or more hook specs +When the agent is dispatched with a valid UoW +Then each Start hook executes in declaration order before the agent receives its prompt + And exit code 0 allows the agent to proceed + And exit code 1 passes hook stdout to the agent as a warning and continues + And exit code 2 halts the agent, marks UoW as failed, and surfaces the error to the task source +``` + +``` +Given an AGENT.md with a hooks.Stop section +When the agent's prompt execution finishes +Then each Stop hook executes in declaration order + And exit code 1 returns stdout to the agent for remediation (follow-up loop) + And exit code 2 halts, marks UoW as failed, and reports the error to the task source +``` + +``` +Given a running hook +When stdin is read +Then the hook receives a JSON object containing: projectDir (string), params (the resolved taskState values), taskState (full UoW taskState object) + And the rendered prompt is NOT present in stdin +``` + +``` +Given a Start hook that writes data into taskState (e.g., snapshot-tests writes file hashes) + And a Stop hook that reads that data (e.g., check-new-tests reads file hashes) +When both hooks run within the same dispatch +Then the Stop hook receives taskState as modified by all preceding hooks +``` + +``` +Given a hook with a timeout configured in AGENT.md frontmatter + And the hook execution exceeds the timeout +When the timeout is exceeded +Then the hook execution is terminated + And the hook is treated as having exited with code 2 + And an error message is logged: "Hook {hookName} exceeded timeout of {timeout}ms" + And the UoW is marked as failed +``` + +``` +Given a hook without a timeout configured in AGENT.md frontmatter +When the hook executes +Then the default timeout of 300000ms (5 minutes) is applied +``` + +### US-5: First run installs repo scripts into the working repository + +**As a** developer running the orchestrator against a working repository for the first time +**I want** repo scripts for all built-in agents to be automatically hard-copied into the working repository and staged for commit +**So that** working repositories are ready for agent dispatch without manual file management + +**Acceptance Criteria:** + +``` +Given an orchestrator program with one or more built-in agents that have repo scripts + And a working repository that has not previously been initialized by this orchestrator +When the orchestrator runs against the working repository for the first time +Then each repo script is hard-copied to .ai//hooks/.d/.mjs + And no symlinks are created + And the orchestrator logs each installed path +``` + +``` +Given a working repository that has already been initialized + And a repo script already exists at the expected path +When the orchestrator runs again +Then the existing script is not overwritten + And the orchestrator logs that the script is already present +``` + +``` +Given repo scripts installed by the orchestrator +When a developer runs git status in the working repository +Then the installed .mjs files appear as new untracked files + And no other files in the working repository are modified by the install step +``` + +### US-6: Dispatch an agent with a pre-populated unit of work + +**As a** Level 4 orchestrator program +**I want** to validate an incoming UoW's `taskState` against the target agent's parameter schema and dispatch only when all required fields are satisfied +**So that** the orchestrator remains a thin validation and dispatch layer + +**Acceptance Criteria:** + +``` +Given a Level 3 agent with a declared template.parameters schema + And a UoW whose taskState satisfies all required parameters recursively +When the orchestrator dispatches +Then the rendered prompt is injected into the agent's context + And the agent begins work +``` + +``` +Given a UoW whose taskState is missing a required parameter +When the orchestrator attempts dispatch +Then the Start hook validate-args exits with code 2 + And the agent does not execute any prompt + And the error identifies the missing field by its dot-notation path + And the UoW is marked as failed +``` + +``` +Given a template parameter that is an optional object (name ends with ?) + And the UoW taskState provides that object + And the object is missing a required sub-field (sub-field name does not end with ?) +When validate-args runs +Then validation fails identifying the missing sub-field by its dot-notation path +``` + +``` +Given a UoW taskState where a required field is present but set to empty string +When validate-args runs +Then validation fails as if the field were absent +``` + +``` +Given a UoW with an incomplete taskState +When the orchestrator receives it +Then the orchestrator does not read repoConfig, inspect the workspace, or call any external service to fill in missing values + And it fails validation, marks the UoW as failed, and returns the error to the task source +``` + +### US-7: Platform Engineer - Observe Agent Execution + +**As a** platform engineer +**I want** to collect telemetry from agent executions +**So that** I can debug failures and optimize costs + +**Acceptance Criteria:** + +``` +Given an agent executes successfully +When the orchestration completes +Then execution telemetry includes: duration_ms, input_tokens, output_tokens +And telemetry includes: cache_read_tokens, cache_creation_tokens +And telemetry includes: total_cost_usd, num_turns +And telemetry is appended as a completion note on the task +``` + +``` +Given an agent executes with follow-up +When multiple engine executions occur +Then telemetry is accumulated across all executions +And cumulative telemetry is reported on completion +``` + +### US-8: System Administrator - Configure Multiple Sources and Engines + +**As a** system administrator +**I want** to configure task sources, engines, and task state stores via YAML +**So that** the orchestrator works with different backends + +**Acceptance Criteria:** + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_source.type is "api" +And orchestrate.task_source.settings.base_url is "https://api.example.com" +And orchestrate.task_source.settings.poll_interval is 30 +When the orchestrator loads configuration +Then an APITaskSource is created with the specified base_url +And the task source polls every 30 seconds +``` + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.engine.type is "ai-runtime" +And orchestrate.engine.settings.endpoint is "https://ai.example.com" +When the orchestrator loads configuration +Then an AIRuntimeEngine is created with the specified endpoint +``` + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_state_store.type is "redis" +And orchestrate.task_state_store.settings.url is "redis://localhost:6379" +When the orchestrator loads configuration +Then a RedisTaskStateStore is created with the specified URL +``` + +``` +Given an unknown task source type is configured +When the orchestrator attempts to create the task source +Then an error is raised with message "Unknown task source type: {type}" +``` + +### US-9: Developer - List Available Agents + +**As a** developer +**I want** to list all configured agents and their capabilities +**So that** I can choose the right agent for a task + +**Acceptance Criteria:** + +``` +Given the agent catalog contains agents +And agent "reviewer" has description, model, tools, and hooks +When the orchestrator CLI is invoked with --list-agents +Then each agent name is printed +And agent description is printed if present +And agent model is printed if present +And agent tools are printed as comma-separated list if present +And start_hooks are printed as comma-separated list if present +And stop_hooks are printed as comma-separated list if present +``` + +``` +Given the agent catalog is empty +When the orchestrator CLI is invoked with --list-agents +Then "No agents found." is printed to stderr +``` + +### US-10: projectDir resolved from git root of CWD + +**As a** developer running the orchestrator from within a working repository +**I want** the orchestrator program to automatically resolve the working repository root by walking up from the current working directory +**So that** running the orchestrator requires no path arguments and works from any subdirectory + +**Acceptance Criteria:** + +``` +Given a developer runs the orchestrator from a directory inside a git repository +When the orchestrator program starts +Then projectDir is set to the git root of the directory the orchestrator was invoked from + And no --projectDir argument is required +``` + +``` +Given a developer runs the orchestrator from a directory that is not inside any git repository +When the orchestrator program starts +Then it exits with a descriptive error stating that no git root could be found + And no agent dispatch occurs +``` + +``` +Given a git repository rooted at /home/user/myrepo + And a developer runs the orchestrator from /home/user/myrepo/src/lib +When the orchestrator program starts +Then projectDir is /home/user/myrepo +``` + +### US-11: Task State persistence across hook executions + +**As a** hook author +**I want** hooks to read and write taskState that persists across hook executions +**So that** hooks can coordinate their behavior without side effects + +**Acceptance Criteria:** + +``` +Given a Start hook that writes taskState.snapshotTests = { "test.js": "hash123" } +When the Start hook completes +Then the Task State Store persists the updated taskState +And the Stop hook can read taskState.snapshotTests in a subsequent execution +``` + +``` +Given a taskState that was previously persisted +When a new hook execution begins +Then the Task State Store loads the persisted taskState +And the hook receives the updated taskState via stdin +``` + +``` +Given the Task State Store is unavailable +When a hook attempts to read or write taskState +Then the hook execution fails with a descriptive error +And the orchestrator logs the failure +And the UoW is marked as failed +``` + +``` +Given a hook completes successfully (exit code 0) +When taskState is saved +Then the save operation is atomic + And either the entire taskState is persisted or none is + And partial updates are not possible +``` + +``` +Given a hook fails or times out +When execution terminates +Then no taskState changes from that hook are persisted + And taskState reflects the state from the prior successful operation +``` + +--- + +## Functional Requirements + +### FR-1: Task Source Interface + +The system MUST implement the `TaskSource` interface with the following methods: + +- `async watchTasks(): AsyncIterator`: Yield available tasks. The task source is responsible for coordination (claiming, locking, queue semantics) to ensure each task is yielded to at most one orchestrator instance. +- `async getTaskDetail(taskId: string): Promise`: Retrieve full task details +- `async completeTask(taskId: string): Promise`: Mark task as fulfilled +- `async failTask(taskId: string, error: string): Promise`: Mark task as failed + +Task Status enum values: +- `OPEN`: Task is available for processing +- `IN_PROGRESS`: Task is being executed +- `COMPLETED`: Task is fulfilled +- `FAILED`: Task execution failed +- `CANCELLED`: Task was cancelled + +The task source plugin is responsible for: +- Ensuring tasks yielded via `watchTasks()` are not simultaneously yielded to other orchestrators +- Not re-emitting tasks that have been marked as `FAILED` +- Handling coordination via atomic claims, distributed locks, queue dequeue, or other mechanisms +- Persisting task state according to its own requirements +- Handling network connectivity and reconnection to its backend service + +### FR-2: Engine Interface + +The system MUST implement the `Engine` interface with the following methods: + +- `async execute(context: EngineContext, taskData: TaskData): Promise`: Execute a task +- `async sendFollowUp(message: string): Promise`: Optional method for follow-up execution + +EngineContext MUST contain: +- `taskId: string`: Unique task identifier +- `workingDir: string`: Project directory for execution +- `agentName: string`: Name of the agent to use +- `verbose: boolean`: Enable verbose logging + +EngineResult MUST contain: +- `success: boolean`: Whether execution succeeded +- `skipFulfill: boolean`: Whether to skip marking the task complete +- `lastMessage: string | null`: Final message from the agent +- `stats: ExecutionStats | null`: Telemetry data + +ExecutionStats MUST contain: +- `durationMs: number`: Execution duration in milliseconds +- `inputTokens: number`: Input tokens consumed +- `outputTokens: number`: Output tokens consumed +- `cacheReadTokens: number`: Cache read tokens +- `cacheCreationTokens: number`: Cache creation tokens +- `totalCostUsd: number`: Total cost in USD +- `numTurns: number`: Number of conversation turns + +### FR-3: Task State Store Interface + +The system MUST implement the `TaskStateStore` interface with the following methods: + +- `async loadTaskState(taskId: string): Promise | null>`: Load taskState for a task +- `async saveTaskState(taskId: string, taskState: Record): Promise`: Persist taskState for a task +- `async deleteTaskState(taskId: string): Promise`: Delete taskState for a task +- `async initializeTaskState(taskId: string, initialState: Record): Promise`: Initialize taskState for a new task + +TaskStateStore implementations MUST be thread-safe and support concurrent access. Each operation MUST be atomic. The orchestrator does not implement additional consistency mechanisms. + +### FR-4: Agent Definition File (AGENT.md) + +Format: Markdown file with YAML frontmatter delimited by `---`. The prompt body follows the closing `---`. + +Required frontmatter fields: + +| Field | Type | Description | +|---|---|---| +| `name` | string | Unique agent identifier (kebab-case) | +| `description` | string | One-line description used for orchestrator routing | +| `tools` | string[] | Explicit allowlist of tools this agent may use | +| `toolClasses` | string[] | Optional. Tool role types this agent requires. Informational — documents what must be available. | +| `template.parameters` | object | Free-form YAML shape declaring the taskState structure this agent expects | +| `hooks` | object | Optional. Hook specifications with timeout configuration | + +Hook spec format: +```yaml +hooks: + Start: + - name: validate-args + scriptPath: hooks/Start.d/validate-args.mjs + timeout: 300000 # optional, milliseconds + Stop: + - name: check-new-tests + scriptPath: hooks/Stop.d/check-new-tests.mjs + timeout: 120000 # optional, milliseconds +``` + +The `tools` list is a strict allowlist enforced by the runtime. Any tool not listed is denied regardless of what the prompt requests. + +### FR-5: template.parameters Schema Rules + +`template.parameters` is a free-form YAML object of any shape. The following conventions govern optionality: + +- A field whose key ends with `?` is **optional**. It may be absent from `taskState` without causing a validation error. When absent, its Handlebars token renders as empty string. +- A field whose key does not end with `?` is **required**. It must be present and non-empty in `taskState`. +- Rules apply recursively: if an optional object is provided, all of its non-`?`-suffixed sub-fields are required. If an optional object is absent, none of its sub-fields are evaluated. +- Scalar values (e.g., `string`) are type hints only and are not enforced at runtime. +- Prompt authors use `{{#if paramName}}...{{/if}}` to guard sections that depend on optional parameters. + +Example: +```yaml +template: + parameters: + language: + name: string + prompt: string + testFramework: + name: string + prompt: string + testStyle: + name: string + prompt: string + userPrompt: string + context?: + prDescription: string + additionalNotes?: string +``` + +In this example: `language`, `testFramework`, `testStyle`, and `userPrompt` are required. `context` is optional; if provided, `context.prDescription` is required and `context.additionalNotes` is optional. + +Handlebars tokens in the prompt body must match declared parameter paths exactly. Unknown tokens are a parse-time error. + +### FR-6: projectDir Resolution + +`projectDir` is not passed as a CLI argument. When the orchestrator is invoked, it walks up from the current working directory to find the nearest ancestor directory containing a `.git` folder. That directory becomes `projectDir` for the duration of the run. If no git root is found, the program exits with a fatal error. + +### FR-7: Orchestrator Framework and Orchestrator Program + +The **orchestrator framework** is a TypeScript monorepo supporting npm workspaces: + +``` +orchestrator-framework/ + package.json # workspaces: ["packages/**"] + node_modules/ # shared, hoisted + packages/ + bdd-red -> /path/to/bdd-red # symlink + bdd-green -> /path/to/bdd-green +``` + +An **orchestrator program** is built on this framework and embeds a chosen set of agents. Agents are installed by symlinking them under `./packages/`. npm workspace dependency resolution handles hoisting and conflict resolution. + +When the orchestrator runs against a working repository for the first time, it performs a one-time install of all built-in repo scripts before any dispatch occurs. + +### FR-8: Working Repository Layout (after install) + +``` +/ + repoConfig.yaml + .ai/ + / + hooks/ + Start.d/ + .mjs # hard-copied, committed + Stop.d/ + .mjs +``` + +Repo scripts are hard-copied (never symlinked) and committed. The orchestrator program imports them via dynamic `import()` using the absolute path resolved from `projectDir + "/.ai//hooks/..."`. Their dependencies are declared in the orchestrator program's `package.json` and resolved from its `node_modules`. + +### FR-9: Agent Package Layout + +``` +/ + AGENT.md + package.json + hooks/ + Start.d/ + .mjs # provided script + .md # Gherkin acceptance spec + Stop.d/ + .mjs + .md +``` + +Hook execution order within each `.d/` directory: alphabetical by filename. + +### FR-10: Hook Contract + +**stdin (JSON):** +```json +{ + "projectDir": "string", + "params": {}, + "taskState": {} +} +``` + +The rendered agent prompt is NOT included. Cross-hook state is communicated via `taskState` mutations passed through to subsequent hooks. + +**Exit codes:** + +| Code | Meaning | Effect | +|---|---|---| +| 0 | Success | Proceed | +| 1 | Recoverable error | Hook stdout passed to agent as context; execution continues (triggers follow-up for Stop hooks) | +| 2 | Fatal error | Agent halts; UoW marked as failed; error surfaced to task source | + +**Script format:** `.mjs` (ES module) or executable shell script. Executed via dynamic `import()` (Node.js) or subprocess (shell). + +**Timeout behavior:** If a hook exceeds its configured timeout, execution is terminated and the hook is treated as having exited with code 2 (fatal error). Partial state changes are not persisted. + +### FR-11: Built-in Hook Specs + +| Hook | Lifecycle | Type | Purpose | Default Timeout | +|---|---|---|---|---| +| `validate-args` | Start | framework | Assert all required taskState fields are non-empty per declared schema | 300000ms (5 min) | +| `snapshot-tests` | Start | framework | Hash existing test files into taskState | 300000ms (5 min) | +| `check-new-tests` | Stop | framework | Assert at least one new test was added since snapshot | 300000ms (5 min) | +| `lint` | Stop | repo script | Run project linter (resolved from repoConfig `linter` toolClass) | 300000ms (5 min) | +| `format` | Stop | repo script | Run project formatter (resolved from repoConfig `formatter` toolClass) | 300000ms (5 min) | + +Framework hooks run from the orchestrator's `packages/` context. Repo scripts are loaded from the working repository's `.ai/` directory via dynamic `import()`. + +### FR-12: repoConfig.yaml + +```yaml +languages: + - name: string # e.g., "csharp", "node" + version: string # semver range + tools: + - toolClass: string # "formatter" | "linter" | "testFramework" | "build" + name: string + version: string # optional +``` + +When multiple tools share the same `toolClass` within one language entry, the first entry wins and a warning is emitted identifying the language name, the conflicting toolClass, and the line numbers of both entries. + +### FR-13: Configuration + +Configuration file `.orchestrator.yaml` MUST support: + +```yaml +orchestrate: + task_source: + type: string # Plugin name + settings: + # Plugin-specific settings + + engine: + type: string # Plugin name + settings: + # Plugin-specific settings + + task_state_store: + type: string # "redis" | "memory" | "file" + settings: + # Type-specific settings + + concurrency: number + claimant: string | null + logging: "normal" | "verbose" +``` + +### FR-14: Orchestration Lifecycle + +The orchestrator MUST execute the following sequence: + +1. Receive task from Task Source's async iterator +2. Load agent definition from AGENT.md based on `worker:*` tag +3. Load/initialize taskState from Task State Store +4. Execute Start hooks with taskState +5. If any hook exits with code 2: mark UoW as failed, notify task source, continue to next task +6. Validate taskState against template.parameters +7. If validation fails: mark UoW as failed, notify task source, continue to next task +8. Render Handlebars prompt with taskState values +9. Build EngineContext and taskData +10. Execute engine +11. Execute Stop hooks +12. If any Stop hook exits with code 1: loop back to step 9 with follow-up message +13. If any Stop hook exits with code 2: mark UoW as failed, notify task source, continue to next task +14. Save final taskState to Task State Store +15. Mark task as complete via Task Source +16. Append completion note with telemetry +17. Continue to next task + +### FR-15: Task Source Reconnection + +If the Task Source's `watchTasks()` async iterator terminates unexpectedly (throws error, completes, or crashes): + +1. Log the termination with error details if available +2. Wait 1 minute (configurable in v2) +3. Create a new Task Source instance using the same configuration +4. Call `watchTasks()` on the new instance +5. Continue normal task processing + +This reconnection loop continues indefinitely until the orchestrator receives an explicit shutdown signal. + +### FR-16: Level Hierarchy Constraints + +- Level 2 skills do not know about Level 3 task workflows. +- Level 3 task agents do not know about Level 4 orchestrators and do not read repoConfig directly. +- Level 3 agents MUST NOT embed language names, framework names, or version numbers directly in their prompt bodies. +- Level 4 orchestrator programs validate UoW `taskState` but do NOT derive or populate parameter values. +- The `tools` allowlist is enforced by the runtime harness, not the prompt. +- Task State Store provides the interface for persisting taskState without coupling to any specific backend. +- The orchestrator does not enforce taskState size limits — this is the Task State Store's responsibility. +- The orchestrator does not implement coordination (claiming, locking) — this is the Task Source's responsibility. +- Task State Store operations are assumed to be atomic — the orchestrator does not implement additional consistency mechanisms. + +--- + +## Non-Functional Requirements + +### NFR-1: Performance + +- Task source polling interval MUST be configurable (default 10 seconds) +- API request timeout MUST be configurable (default 30 seconds) +- Hook execution timeout defaults to 5 minutes and is configurable per-hook in AGENT.md +- Hook timeout MUST be enforced via process termination +- Engine execution has no hardcoded timeout (managed by engine) +- Task State Store operations MUST complete within 100ms for local stores, 500ms for remote stores +- AGENT.md parsing completes in under 100ms for files under 10KB + +### NFR-2: Reliability + +- The orchestrator MUST gracefully handle task source unavailability +- The orchestrator MUST automatically attempt task source reconnection after 1-minute delay +- The orchestrator MUST log all hook failures without crashing +- The orchestrator MUST survive process restart and resume polling +- Task State Store MUST be thread-safe and support concurrent access +- Task State persistence failures MUST be logged and cause UoW failure +- Hook timeouts MUST be enforced — a hung hook cannot block the orchestrator indefinitely + +### NFR-3: Monitoring and Observability + +- All task source operations MUST be logged with task ID +- All hook executions MUST be logged with command, exit code, and duration +- Engine execution MUST log telemetry on completion +- Task State Store operations MUST be logged with task ID +- Log levels MUST be configurable (normal, verbose) +- Structured JSON log entries for: git root resolution, agent load, taskState validation result, hook start, hook exit (with exit code and duration), agent dispatch +- Task Source reconnection attempts MUST be logged + +### NFR-4: Concurrency + +- The orchestrator MUST support configurable worker concurrency +- Task Source MUST handle coordination (no built-in locking in orchestrator) +- Task State Store MUST support concurrent reads and writes to the same task_id +- Task State Store operations MUST be atomic per-operation + +### NFR-5: Error Handling + +- Invalid JSON on stdin MUST result in exit code 1 +- Unknown agent name MUST result in UoW being marked as failed +- Agent without model MUST result in UoW being marked as failed +- Task source async iterator termination MUST trigger reconnection logic +- Hook execution exceptions MUST be caught, logged, and result in exit code 2 +- Task State Store unavailability MUST cause UoW failure with descriptive error +- Every validation failure, hook exit-2, and parse error must name the specific field or file path that caused the failure, using dot-notation for nested fields +- Hook timeouts MUST be logged with hook name and configured timeout value + +### NFR-6: Install Idempotency + +- Running the orchestrator multiple times against the same working repository produces the same result +- Already-present repo scripts are not overwritten +- No errors are raised for already-installed scripts +- Task State Store initialization is idempotent + +### NFR-7: Reproducibility + +- Given the same AGENT.md, the same `taskState`, and the same `projectDir`, two dispatches produce identical rendered prompts +- Handlebars rendering is deterministic and side-effect free + +### NFR-8: Security + +- The `tools` allowlist is enforced by the runtime +- A prompt that instructs the agent to use an unlisted tool is rejected before execution +- Repo scripts are executed with the same permissions as the orchestrator +- Task State Store does not execute code from stored taskState values + +--- + +## Data & Storage + +### Commands + +**CompleteTask** +- `taskId: string` +- Occurs when: Worker completes a task successfully + +**FailTask** +- `taskId: string` +- `error: string` +- Occurs when: Worker fails a task (validation error, hook exit code 2, etc.) + +**AppendCompletionNote** +- `taskId: string` +- `note: string` (JSON serialized ExecutionStats) +- Occurs when: Worker completes execution with telemetry + +**InitializeTaskState** +- `taskId: string` +- `initialState: Record` +- Occurs when: Task is received for the first time + +**UpdateTaskState** +- `taskId: string` +- `updates: Record` +- Occurs when: Hook modifies taskState + +**LoadTaskState** +- `taskId: string` +- Occurs when: Hook needs to read current taskState + +**DispatchAgent** +- `agentName: string`, `projectDir: string`, `uow: UnitOfWork`, `dispatchedAt: ISO8601` +- Occurs when: Orchestrator dispatches an agent + +**RunHook** +- `agentName: string`, `hookName: string`, `lifecycle: "Start" | "Stop"`, `projectDir: string` +- Occurs when: Hook is executed + +**ReconnectTaskSource** +- `taskSourceType: string`, `reason: string`, `reconnectedAt: ISO8601` +- Occurs when: Task Source async iterator terminates and orchestrator attempts reconnection + +**InstallRepoScripts** +- `orchestratorName: string`, `projectDir: string`, `installedAt: ISO8601` +- Occurs when: Repo scripts are installed to working repository + +### Events + +**TaskCompleted** +- `taskId: string` +- `claimant: string` +- `telemetry: ExecutionStats` +- `timestamp: Date` + +**TaskFailed** +- `taskId: string` +- `claimant: string` +- `error: string` +- `timestamp: Date` + +**AgentDispatched** +- `agentName: string`, `taskStateSnapshot: Record`, `renderedPromptHash: string`, `dispatchedAt: ISO8601` + +**HookExecuted** +- `agentName: string`, `hookName: string`, `lifecycle: "Start" | "Stop"`, `exitCode: 0 | 1 | 2`, `stdout: string`, `durationMs: number`, `executedAt: ISO8601` + +**HookTimedOut** +- `agentName: string`, `hookName: string`, `lifecycle: "Start" | "Stop"`, `configuredTimeout: number`, `durationMs: number`, `executedAt: ISO8601` + +**AgentHalted** +- `agentName: string`, `reason: string`, `haltedAt: ISO8601` + +**TaskStateValidationFailed** +- `agentName: string`, `missingFields: string[]`, `failedAt: ISO8601` + +**RepoScriptInstalled** +- `agentName: string`, `hookName: string`, `targetPath: string`, `installedAt: ISO8601` + +**RepoScriptAlreadyPresent** +- `agentName: string`, `hookName: string`, `targetPath: string`, `checkedAt: ISO8601` + +**TaskStateUpdated** +- `taskId: string`, `updatedFields: string[]`, `updatedAt: Date` + +**TaskStateInitialized** +- `taskId: string`, `initialState: Record`, `initializedAt: Date` + +**TaskSourceReconnecting** +- `taskSourceType: string`, `reason: string`, `reattemptDelay: number`, `reattemptAt: ISO8601` + +**TaskSourceReconnected** +- `taskSourceType: string`, `previousError: string`, `reconnectedAt: Date` + +### Aggregates + +**Task** +- `id: string` +- `title: string` +- `description: string | null` +- `status: TaskStatus` +- `tags: string[]` +- `claimant: string | null` +- `createdAt: Date | null` +- `updatedAt: Date | null` +- `priority: number` + +**TaskDetail** +- Extends Task +- `dependencies: DependencyRef[]` +- `notes: NoteEntry[]` +- `acceptanceCriteria: ACEntry[]` +- `retro: RetroEntry[]` + +**AgentExecution** +- `taskId: string` +- `agentName: string` +- `claimant: string` +- `startedAt: Date` +- `completedAt: Date | null` +- `telemetry: ExecutionStats | null` +- `verdict: OrchestrationResult` + +**AgentDefinition** +```typescript +type AgentDefinition = { + name: string + description: string + tools: string[] + toolClasses: string[] + template: { + parameters: Record // free-form; validated recursively at dispatch + } + hooks: { + Start: HookSpec[] + Stop: HookSpec[] + } + promptBody: string +} +``` + +**HookSpec** +```typescript +type HookSpec = { + name: string + scriptPath: string // relative to agent dir + specPath: string // path to .md Gherkin spec + isRepoScript: boolean // true = installed to working repo; false = runs from framework packages + timeout?: number // milliseconds, optional +} +``` + +**RepoConfig** +```typescript +type RepoConfig = { + languages: Array<{ + name: string + version: string + tools: Array<{ + toolClass: "formatter" | "linter" | "testFramework" | "build" + name: string + version?: string + }> + }> +} +``` + +**UnitOfWork** +```typescript +type UnitOfWork = { + id: string + agentName: string + projectDir: string + taskState: Record + createdAt: string + dispatchedAt: string | null +} +``` + +**DispatchRecord** +```typescript +type DispatchRecord = { + uowId: string + agentName: string + projectDir: string + taskStateSnapshot: Record + renderedPromptHash: string + hookResults: Array<{ + hookName: string + lifecycle: "Start" | "Stop" + exitCode: number + durationMs: number + timedOut: boolean + }> + outcome: "completed" | "halted" + dispatchedAt: string + completedAt: string | null +} +``` + +### Query Projections + +**TaskDetailQuery** +- Question: What is the full context for a specific task? +- Projection: `TaskDetail` by `taskId` +- Used by: Agent execution + +**AgentExecutionHistoryQuery** +- Question: What executions have occurred for a task? +- Projection: List of `AgentExecution` by `taskId` ordered by `startedAt` +- Used by: Debugging and audit + +**AgentSchemaView** +- Question: What taskState shape, tools, and hook contracts does a named agent declare? +- Projection: `AgentDefinition` by `agentName` +- Used by: Dispatcher, validation + +**UoWReadinessView** +- Question: Does a given UoW's taskState satisfy the target agent's parameter schema? +- Projection: Boolean result of schema validation +- Used by: Pre-dispatch validation + +**HookHealthView** +- Question: Which hooks have failed (exit code ≥ 1) for a given agent in the last N dispatches? +- Projection: List of `HookExecuted` events filtered by failure +- Used by: Health monitoring + +**RepoScriptInstallStatusView** +- Question: Which repo scripts have been installed to a given working repository, and which are missing? +- Projection: Set of installed script paths vs required script paths +- Used by: Install validation + +**TaskStateView** +- Question: What is the current taskState for a given task? +- Projection: `taskState` dict by `taskId` +- Used by: Hook execution, follow-up loops + +**TaskSourceReconnectionHistoryQuery** +- Question: What reconnection attempts have occurred for the task source? +- Projection: List of `TaskSourceReconnecting` and `TaskSourceReconnected` events ordered by timestamp +- Used by: Debugging connectivity issues + +### Data Retention + +- Task events MUST be retained indefinitely (event sourcing) +- Task aggregate MUST be rebuildable from event stream +- Completed tasks MUST NOT be deleted from task source +- Agent execution history MUST be appended to task as notes +- `DispatchRecord` and associated events: 90 days, then archived or deleted +- `AgentDefinition` snapshot captured at dispatch time: retained with its `DispatchRecord` for the same 90-day window +- `package.json` hash records: retained indefinitely (required for install-skip optimization) +- TaskState MUST be retained until task is completed and archived, then purged according to retention policy + +--- + +## Out of Scope + +- Multi-machine coordination (handled by Task Source plugin) +- Dynamic agent registration (agents must be installed in monorepo) +- Real-time task streaming (polling only, no websockets) +- Task prioritization within the orchestrator (priority is metadata only) +- Automatic retry on failure (manual retry only) +- Task dependencies (dependencies are metadata only) +- Custom scheduling algorithms (FIFO polling only) +- Agent sandboxing (agents run with same permissions as orchestrator) +- Web UI or API for orchestration management +- Level 5 meta-agent behavior: Automated prompt improvement, token-usage instrumentation, and eval-driven skill updates are out of scope. The schema must not prevent these being added later. +- Non-Node.js hook runtimes in v1: Python, Bash, and Rust hooks are out of scope. The exit-code contract is language-agnostic and could support other runtimes in a future version. +- Agent-to-agent calls within a Level 3 task: Level 3 agents execute a single task. Inter-agent calls within a prompt belong to Level 4. +- GUI or web interface for agent authoring: Authoring is file-based (markdown + YAML). +- Agent definition versioning: Semantic versioning of AGENT.md files and compatibility guarantees between versions are out of scope for v1. +- Multi-language dispatch in a single agent invocation: Each dispatch targets one language at a time. Polyglot support comes from separate dispatches. +- Automated Gherkin test execution for hook specs: The `.md` Gherkin spec files are documentation and acceptance criteria only. They are not automatically parsed and run. Provided hook scripts ship with unit tests; teams writing replacement implementations test against the spec on their own. +- Scalar type enforcement in template.parameters: The type hints (e.g., `string`) in `template.parameters` values document intent but are not enforced at runtime in v1. Validation only checks presence and non-emptiness of required fields. +- Orchestrator responsibility for taskState derivation: The orchestrator validates; it does not populate. Whatever produces the UoW — human, CI, or another agent — is responsible for all `taskState` values. +- Repo script upgrade path: When a newer version of the orchestrator program ships an updated repo script, there is no automated mechanism to update already-installed copies in working repositories. Teams update repo scripts manually. A future version may introduce a hash-comparison check with an explicit overwrite command. +- Malicious agent package supply chain: A compromised agent package could declare arbitrary dependencies installed into the shared orchestrator `node_modules`. The v1 mitigation is developer discipline — always review agent definitions and `package.json` before installing. +- Task State size enforcement: The orchestrator does not enforce taskState size limits. Size limits, if any, are enforced by the Task State Store implementation. +- Task State concurrency control: The orchestrator does not implement optimistic locking, versioning, or conflict resolution for taskState updates. Each `saveTaskState` operation is assumed to be atomic. +- Task state persistence across orchestrator restarts: The orchestrator does not persist state across restarts. Persistence is the Task Source's responsibility. +- Configurable reconnection delay: The reconnection delay is fixed at 1 minute for v1. + +--- + +## Dependencies and Assumptions + +### Dependencies + +| Dependency | Purpose | +|---|---| +| `repoConfig.yaml` (working repo) | Toolchain declarations; read by processes that produce UoW taskState | +| TypeScript runtime | Orchestrator framework execution | +| Node.js ≥24 | Runtime for hook script execution and orchestrator program | +| git | Working repository root resolution (`projectDir` detection) | +| npm workspaces | Agent dependency resolution and workspace test orchestration | +| vitest (agent-level devDependency) | Running provided hook unit tests | +| Handlebars (or equivalent) | Prompt template rendering at dispatch time | +| Task State Store backend (Redis, filesystem, in-memory) | taskState persistence across hook executions | + +### Assumptions + +1. The runtime enforces the `tools` allowlist at the execution level. An agent cannot bypass it via prompt instructions. +2. `repoConfig.yaml` is committed to the working repository and readable by any process that needs it. +3. Node.js ≥24 is available in the orchestrator program's execution environment. +4. All hook scripts (framework and repo) are ES modules (`.mjs`) or executable shell scripts. +5. The orchestrator framework is an npm workspace monorepo. `npm install` at the root resolves all agent hook dependencies. +6. `taskState` fields of type `prompt` carry the full text of a skill prompt section as a string value. The UoW producer is responsible for loading skill content and writing it as a string — not a file path. +7. A Level 3 agent is stateless between dispatches. Per-dispatch state lives in `taskState` and is threaded through hook stdin. +8. `validate-args` is the first Start hook by convention. Its absence is an authoring warning, not a parse-time fatal error. +9. When two tools share the same `toolClass` in a `repoConfig.yaml` language entry, the first listed entry is used and a warning with line numbers is emitted. +10. Repo script installation is a one-time operation performed by the orchestrator on first run against a working repository. Subsequent runs are safe and idempotent. +11. The orchestrator is always invoked from inside a valid git repository. The git root is the working repository; no other mechanism for specifying `projectDir` exists. +12. Absent optional Handlebars tokens render as empty string. Prompt authors guard optional sections with `{{#if}}` blocks. +13. Task State Store is available and reachable during hook execution. Unavailability causes UoW failure. +14. Task State Store operations are atomic for single task_id writes. The orchestrator does not implement additional consistency mechanisms. +15. Configuration file exists: `.orchestrator.yaml` in project root or home directory. +16. Network connectivity: Task source is reachable from orchestrator. If not, reconnection logic is triggered. +17. File system permissions: Orchestrator has read/write access to project directory. +18. Shell availability: `/bin/sh` or compatible shell is available for hook execution. +19. Idempotent hooks: Hooks are safe to run multiple times (follow-up loops). +20. Hook timeouts: Hooks complete within their configured timeout or are killed. +21. Task Source handles all coordination (claiming, locking, queue semantics) to prevent duplicate task processing. +22. Task Source is responsible for persisting its own state. The orchestrator does not persist task state across restarts. +23. Task Source async iterator termination triggers automatic reconnection after 1-minute delay. +24. Task State Store enforces any size limits. The orchestrator does not inspect taskState size. + +### External System Assumptions + +- **Task Source**: Implements coordination (atomic claims, distributed locks, or queue semantics) to ensure tasks are not yielded to multiple orchestrators simultaneously. Does not re-emit failed tasks. Handles its own persistence requirements. Can be recreated if the async iterator terminates. +- **AI Runtime**: Supports executing agents with context and returning structured results. +- **Agent catalog format**: AGENT.md files following the specified schema for Level 3 agents. +- **Task State Store backend**: Supports get/set/delete operations with atomic operations and appropriate performance characteristics. Enforces size limits if any. + +--- + +## Open Questions + +None. All questions from v2 have been resolved and their answers incorporated into the requirements. From 1ec54d06e9352831e48070e239a883a49972c989 Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Thu, 7 May 2026 16:28:31 -0500 Subject: [PATCH 03/33] ts orchestrator --- .claude/hooks/dispatch | 21 + .claude/settings.json | 16 + .claude/skills/node/SKILL.md | 53 + .../node/examples/tsconfig-references.md | 44 + .claude/skills/node/examples/vite-build.md | 46 + .claude/skills/node/examples/vitest-setup.md | 26 + .../skills/node/examples/workspace-setup.md | 44 + .../skills/node/hooks/PreToolUse.d/ban-npx | 5 + .claude/skills/node/hooks/settings.json | 18 + .claude/skills/typescript/SKILL.md | 49 + .vscode/settings.json | 4 +- orchestrator/.bifrost.yaml.example | 25 - orchestrator/.claude | 1 + orchestrator/.gitignore | 3 + orchestrator/README.md | 123 - orchestrator/last-statusline | 1 + orchestrator/package-lock.json | 3088 +++++++++++++++++ orchestrator/package.json | 30 + orchestrator/packages/cli/package.json | 12 + orchestrator/packages/cli/src/config.spec.ts | 122 + orchestrator/packages/cli/src/config.ts | 124 + .../packages/cli/src/git-root.spec.ts | 34 + orchestrator/packages/cli/src/git-root.ts | 36 + orchestrator/packages/cli/src/index.spec.ts | 44 + orchestrator/packages/cli/src/index.ts | 63 + orchestrator/packages/cli/tsconfig.json | 14 + orchestrator/packages/cli/vite.config.ts | 29 + orchestrator/packages/core/package.json | 20 + .../packages/core/src/agent-parser.spec.ts | 196 ++ .../packages/core/src/agent-parser.ts | 173 + .../core/src/file-task-state-store.spec.ts | 109 + .../core/src/file-task-state-store.ts | 82 + .../core/src/handlebars-renderer.spec.ts | 121 + .../packages/core/src/handlebars-renderer.ts | 29 + .../packages/core/src/hook-executor.spec.ts | 232 ++ .../packages/core/src/hook-executor.ts | 106 + orchestrator/packages/core/src/index.ts | 10 + .../core/src/memory-task-source.spec.ts | 184 + .../packages/core/src/memory-task-source.ts | 143 + .../core/src/memory-task-state-store.spec.ts | 69 + .../core/src/memory-task-state-store.ts | 59 + .../packages/core/src/orchestrator.spec.ts | 210 ++ .../packages/core/src/orchestrator.ts | 198 ++ .../packages/core/src/repo-installer.spec.ts | 181 + .../packages/core/src/repo-installer.ts | 68 + .../core/src/task-state-store.spec.ts | 132 + .../packages/core/src/task-state-store.ts | 32 + orchestrator/packages/core/src/types.ts | 26 + .../packages/core/src/validator.spec.ts | 156 + orchestrator/packages/core/src/validator.ts | 86 + orchestrator/packages/core/tsconfig.json | 17 + orchestrator/packages/core/vite.config.ts | 31 + .../engine-claude-code/pyproject.toml | 19 - .../src/engine_claude_code/__init__.py | 4 - .../agent_catalog/__init__.py | 5 - .../agent_catalog/loader.py | 97 - .../agent_catalog/parser.py | 70 - .../engine_claude_code/agent_catalog/types.py | 35 - .../src/engine_claude_code/config.py | 22 - .../src/engine_claude_code/sdk_runner.py | 263 -- .../tests/test_claude_config_dir.py | 66 - orchestrator/packages/engine/package.json | 17 + orchestrator/packages/engine/src/index.ts | 3 + .../packages/engine/src/interface.spec.ts | 90 + orchestrator/packages/engine/src/interface.ts | 10 + .../packages/engine/src/test-engine.spec.ts | 119 + .../packages/engine/src/test-engine.ts | 96 + .../packages/engine/src/types.spec.ts | 82 + orchestrator/packages/engine/src/types.ts | 26 + orchestrator/packages/engine/tsconfig.json | 9 + orchestrator/packages/engine/vite.config.ts | 27 + .../packages/interface-engine/pyproject.toml | 12 - .../src/interface_engine/__init__.py | 5 - .../src/interface_engine/base.py | 37 - .../src/interface_engine/config.py | 14 - .../src/interface_engine/types.py | 49 - .../packages/interface-tasks/pyproject.toml | 12 - .../src/interface_tasks/__init__.py | 5 - .../src/interface_tasks/base.py | 73 - .../src/interface_tasks/config.py | 14 - .../src/interface_tasks/types.py | 68 - .../packages/orchestrator/pyproject.toml | 20 - .../orchestrator/src/orchestrator/__init__.py | 3 - .../src/orchestrator/cli/__init__.py | 4 - .../src/orchestrator/cli/agent_entry.py | 95 - .../src/orchestrator/cli/config.py | 29 - .../src/orchestrator/core/__init__.py | 26 - .../src/orchestrator/core/config.py | 103 - .../src/orchestrator/core/domain.py | 34 - .../src/orchestrator/core/factory.py | 69 - .../src/orchestrator/core/hook_runner.py | 207 -- .../src/orchestrator/core/orchestrator.py | 193 -- .../src/orchestrator/core/reporting.py | 73 - .../packages/task-source/package.json | 17 + .../task-source/src/api-task-source.spec.ts | 206 ++ .../task-source/src/api-task-source.ts | 158 + .../packages/task-source/src/index.ts | 3 + .../task-source/src/interface.spec.ts | 152 + .../packages/task-source/src/interface.ts | 16 + .../packages/task-source/src/types.spec.ts | 58 + .../packages/task-source/src/types.ts | 54 + .../packages/task-source/tsconfig.json | 9 + .../packages/task-source/vite.config.ts | 27 + .../packages/tasks-bifrost/pyproject.toml | 18 - .../src/tasks_bifrost/__init__.py | 4 - .../src/tasks_bifrost/api_client.py | 154 - .../tasks-bifrost/src/tasks_bifrost/config.py | 24 - .../tasks-bifrost/src/tasks_bifrost/models.py | 83 - .../src/tasks_bifrost/task_source.py | 130 - orchestrator/pyproject.toml | 42 - orchestrator/scripts/agent.py | 117 - orchestrator/scripts/dispatcher.py | 96 - orchestrator/tsconfig.base.json | 19 + orchestrator/tsconfig.json | 16 + orchestrator/uv.lock | 1136 ------ orchestrator/vitest.config.ts | 8 + 116 files changed, 7818 insertions(+), 3679 deletions(-) create mode 100755 .claude/hooks/dispatch create mode 100644 .claude/settings.json create mode 100644 .claude/skills/node/SKILL.md create mode 100644 .claude/skills/node/examples/tsconfig-references.md create mode 100644 .claude/skills/node/examples/vite-build.md create mode 100644 .claude/skills/node/examples/vitest-setup.md create mode 100644 .claude/skills/node/examples/workspace-setup.md create mode 100755 .claude/skills/node/hooks/PreToolUse.d/ban-npx create mode 100644 .claude/skills/node/hooks/settings.json create mode 100644 .claude/skills/typescript/SKILL.md delete mode 100644 orchestrator/.bifrost.yaml.example create mode 120000 orchestrator/.claude create mode 100644 orchestrator/.gitignore delete mode 100644 orchestrator/README.md create mode 100644 orchestrator/last-statusline create mode 100644 orchestrator/package-lock.json create mode 100644 orchestrator/package.json create mode 100644 orchestrator/packages/cli/package.json create mode 100644 orchestrator/packages/cli/src/config.spec.ts create mode 100644 orchestrator/packages/cli/src/config.ts create mode 100644 orchestrator/packages/cli/src/git-root.spec.ts create mode 100644 orchestrator/packages/cli/src/git-root.ts create mode 100644 orchestrator/packages/cli/src/index.spec.ts create mode 100644 orchestrator/packages/cli/src/index.ts create mode 100644 orchestrator/packages/cli/tsconfig.json create mode 100644 orchestrator/packages/cli/vite.config.ts create mode 100644 orchestrator/packages/core/package.json create mode 100644 orchestrator/packages/core/src/agent-parser.spec.ts create mode 100644 orchestrator/packages/core/src/agent-parser.ts create mode 100644 orchestrator/packages/core/src/file-task-state-store.spec.ts create mode 100644 orchestrator/packages/core/src/file-task-state-store.ts create mode 100644 orchestrator/packages/core/src/handlebars-renderer.spec.ts create mode 100644 orchestrator/packages/core/src/handlebars-renderer.ts create mode 100644 orchestrator/packages/core/src/hook-executor.spec.ts create mode 100644 orchestrator/packages/core/src/hook-executor.ts create mode 100644 orchestrator/packages/core/src/index.ts create mode 100644 orchestrator/packages/core/src/memory-task-source.spec.ts create mode 100644 orchestrator/packages/core/src/memory-task-source.ts create mode 100644 orchestrator/packages/core/src/memory-task-state-store.spec.ts create mode 100644 orchestrator/packages/core/src/memory-task-state-store.ts create mode 100644 orchestrator/packages/core/src/orchestrator.spec.ts create mode 100644 orchestrator/packages/core/src/orchestrator.ts create mode 100644 orchestrator/packages/core/src/repo-installer.spec.ts create mode 100644 orchestrator/packages/core/src/repo-installer.ts create mode 100644 orchestrator/packages/core/src/task-state-store.spec.ts create mode 100644 orchestrator/packages/core/src/task-state-store.ts create mode 100644 orchestrator/packages/core/src/types.ts create mode 100644 orchestrator/packages/core/src/validator.spec.ts create mode 100644 orchestrator/packages/core/src/validator.ts create mode 100644 orchestrator/packages/core/tsconfig.json create mode 100644 orchestrator/packages/core/vite.config.ts delete mode 100644 orchestrator/packages/engine-claude-code/pyproject.toml delete mode 100644 orchestrator/packages/engine-claude-code/src/engine_claude_code/__init__.py delete mode 100644 orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/__init__.py delete mode 100644 orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/loader.py delete mode 100644 orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/parser.py delete mode 100644 orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/types.py delete mode 100644 orchestrator/packages/engine-claude-code/src/engine_claude_code/config.py delete mode 100644 orchestrator/packages/engine-claude-code/src/engine_claude_code/sdk_runner.py delete mode 100644 orchestrator/packages/engine-claude-code/tests/test_claude_config_dir.py create mode 100644 orchestrator/packages/engine/package.json create mode 100644 orchestrator/packages/engine/src/index.ts create mode 100644 orchestrator/packages/engine/src/interface.spec.ts create mode 100644 orchestrator/packages/engine/src/interface.ts create mode 100644 orchestrator/packages/engine/src/test-engine.spec.ts create mode 100644 orchestrator/packages/engine/src/test-engine.ts create mode 100644 orchestrator/packages/engine/src/types.spec.ts create mode 100644 orchestrator/packages/engine/src/types.ts create mode 100644 orchestrator/packages/engine/tsconfig.json create mode 100644 orchestrator/packages/engine/vite.config.ts delete mode 100644 orchestrator/packages/interface-engine/pyproject.toml delete mode 100644 orchestrator/packages/interface-engine/src/interface_engine/__init__.py delete mode 100644 orchestrator/packages/interface-engine/src/interface_engine/base.py delete mode 100644 orchestrator/packages/interface-engine/src/interface_engine/config.py delete mode 100644 orchestrator/packages/interface-engine/src/interface_engine/types.py delete mode 100644 orchestrator/packages/interface-tasks/pyproject.toml delete mode 100644 orchestrator/packages/interface-tasks/src/interface_tasks/__init__.py delete mode 100644 orchestrator/packages/interface-tasks/src/interface_tasks/base.py delete mode 100644 orchestrator/packages/interface-tasks/src/interface_tasks/config.py delete mode 100644 orchestrator/packages/interface-tasks/src/interface_tasks/types.py delete mode 100644 orchestrator/packages/orchestrator/pyproject.toml delete mode 100644 orchestrator/packages/orchestrator/src/orchestrator/__init__.py delete mode 100644 orchestrator/packages/orchestrator/src/orchestrator/cli/__init__.py delete mode 100644 orchestrator/packages/orchestrator/src/orchestrator/cli/agent_entry.py delete mode 100644 orchestrator/packages/orchestrator/src/orchestrator/cli/config.py delete mode 100644 orchestrator/packages/orchestrator/src/orchestrator/core/__init__.py delete mode 100644 orchestrator/packages/orchestrator/src/orchestrator/core/config.py delete mode 100644 orchestrator/packages/orchestrator/src/orchestrator/core/domain.py delete mode 100644 orchestrator/packages/orchestrator/src/orchestrator/core/factory.py delete mode 100644 orchestrator/packages/orchestrator/src/orchestrator/core/hook_runner.py delete mode 100644 orchestrator/packages/orchestrator/src/orchestrator/core/orchestrator.py delete mode 100644 orchestrator/packages/orchestrator/src/orchestrator/core/reporting.py create mode 100644 orchestrator/packages/task-source/package.json create mode 100644 orchestrator/packages/task-source/src/api-task-source.spec.ts create mode 100644 orchestrator/packages/task-source/src/api-task-source.ts create mode 100644 orchestrator/packages/task-source/src/index.ts create mode 100644 orchestrator/packages/task-source/src/interface.spec.ts create mode 100644 orchestrator/packages/task-source/src/interface.ts create mode 100644 orchestrator/packages/task-source/src/types.spec.ts create mode 100644 orchestrator/packages/task-source/src/types.ts create mode 100644 orchestrator/packages/task-source/tsconfig.json create mode 100644 orchestrator/packages/task-source/vite.config.ts delete mode 100644 orchestrator/packages/tasks-bifrost/pyproject.toml delete mode 100644 orchestrator/packages/tasks-bifrost/src/tasks_bifrost/__init__.py delete mode 100644 orchestrator/packages/tasks-bifrost/src/tasks_bifrost/api_client.py delete mode 100644 orchestrator/packages/tasks-bifrost/src/tasks_bifrost/config.py delete mode 100644 orchestrator/packages/tasks-bifrost/src/tasks_bifrost/models.py delete mode 100644 orchestrator/packages/tasks-bifrost/src/tasks_bifrost/task_source.py delete mode 100644 orchestrator/pyproject.toml delete mode 100644 orchestrator/scripts/agent.py delete mode 100644 orchestrator/scripts/dispatcher.py create mode 100644 orchestrator/tsconfig.base.json create mode 100644 orchestrator/tsconfig.json delete mode 100644 orchestrator/uv.lock create mode 100644 orchestrator/vitest.config.ts diff --git a/.claude/hooks/dispatch b/.claude/hooks/dispatch new file mode 100755 index 0000000..f6c4efc --- /dev/null +++ b/.claude/hooks/dispatch @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +INPUT=$(cat) + +get_field() { + local field="$1" + local json="${2:-$INPUT}" + echo "$json" | jq -r ".$field // empty" 2>/dev/null || echo "" +} + +deny() { + echo '{"hookSpecificOutput":{"hookEventName":"'$(get_field hook_event_name)'","permissionDecision":"deny","permissionDecisionReason":"'$1'"}}' +} + +export -f get_field +export -f deny +export INPUT + +$CLAUDE_PROJECT_DIR/.claude/skills/$1/hooks/$(get_field hook_event_name).d/$2 \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8f90fee --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(npx *)", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/dispatch node ban-npx" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.claude/skills/node/SKILL.md b/.claude/skills/node/SKILL.md new file mode 100644 index 0000000..15a2517 --- /dev/null +++ b/.claude/skills/node/SKILL.md @@ -0,0 +1,53 @@ +--- +name: node +description: | + Use when working with Node.js, npm, package.json, or npx. Alternative package managers + like Yarn and pnpm as well as monorepo tools like nx, yarn, or turborepo should also use this skill +--- + +# Node.js + +## Invoking scripts + +Prefer explicit `scripts` entries over `npx`. Write scripts to package.json: + +```json +"scripts": { + "test": "vitest run", + "dev": "vitest --watch", + "build": "vite build" +} +``` + +Reason: Enforces consistent flags, prevents drift, works better in monorepos. + +## Monorepos + +Use native npm workspaces unless user explicitly chose otherwise (turborepo, nx, pnpm). + +**Workspace pattern** ([examples/workspace-setup](examples/workspace-setup.md)): +- Root `workspaces: ["packages/**"]` in package.json +- Workspace scripts: `npm run build -ws`, `npm run test -ws` +- Local deps use workspace names: `@orchestrator/core` + +**TypeScript project references** ([examples/tsconfig-references](examples/tsconfig-references.md)): +- Base config with `composite: true` +- Package configs extend base, declare `references` +- Enables cross-package type checking, faster builds + +**Build** ([examples/vite-build](examples/vite-build.md)): +- Vite for fast builds +- `vite-plugin-dts` for .d.ts generation +- `external:` workspace deps in rollupOptions + +**Testing** ([examples/vitest-setup](examples/vitest-setup.md)): +- Vitest config per workspace or root +- `*.spec.ts` naming, exclude from builds +- Root script runs all: `"test": "vitest run"` + +## See also + +- [TypeScript config](examples/tsconfig-references.md) - Project references pattern +- [Vite build](examples/vite-build.md) - Library build with deps +- [Vitest setup](examples/vitest-setup.md) - Test config for monorepos +- [Workspace setup](examples/workspace-setup.md) - Complete monorepo structure diff --git a/.claude/skills/node/examples/tsconfig-references.md b/.claude/skills/node/examples/tsconfig-references.md new file mode 100644 index 0000000..8782b35 --- /dev/null +++ b/.claude/skills/node/examples/tsconfig-references.md @@ -0,0 +1,44 @@ +# TypeScript Project References + +Enables cross-package type checking, faster incremental builds. + +Root tsconfig.base.json: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "lib": ["ESNext"], + "types": ["node"], + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true + }, + "exclude": ["node_modules", "dist"] +} +``` + +Package tsconfig.json (packages/core/tsconfig.json): + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../another-monorepo-package" } + ], + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] +} +``` diff --git a/.claude/skills/node/examples/vite-build.md b/.claude/skills/node/examples/vite-build.md new file mode 100644 index 0000000..f4664f8 --- /dev/null +++ b/.claude/skills/node/examples/vite-build.md @@ -0,0 +1,46 @@ +# Vite Build for Libraries + +Fast builds, TypeScript declarations, workspace deps. + +vite.config.ts: + +```ts +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' +import pkg from './package.json' + +export default defineConfig({ + plugins: [ + dts({ + tsconfigPath: './tsconfig.json', + rollupTypes: true, + }), + ], + build: { + lib: { + entry: './src/index.ts', + formats: ['es'], + fileName: 'index', + }, + rollupOptions: { + external: [ + '@my-monorepo/utils', + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), + ], + output: { + globals: { + '@my-monorepo/utils': 'Utils', + }, + }, + }, + target: 'esnext', + emptyOutDir: true, + }, +}) +``` + +**Key points:** +- `external:` workspace deps + all deps +- `rollupTypes: true` bundles .d.ts files +- `formats: ['es']` for ESM-only packages diff --git a/.claude/skills/node/examples/vitest-setup.md b/.claude/skills/node/examples/vitest-setup.md new file mode 100644 index 0000000..b1ea6a7 --- /dev/null +++ b/.claude/skills/node/examples/vitest-setup.md @@ -0,0 +1,26 @@ +# Vitest Testing Setup + +Root vitest.config.ts: + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}) +``` + +Test file naming: +- `*.spec.ts` for unit tests +- Exclude from tsconfig `exclude` array + +Usage: + +```bash +npm run test # run all tests once +npm run dev # watch mode +npm run test -- -ui # vitest ui (if installed) +``` diff --git a/.claude/skills/node/examples/workspace-setup.md b/.claude/skills/node/examples/workspace-setup.md new file mode 100644 index 0000000..e716d8c --- /dev/null +++ b/.claude/skills/node/examples/workspace-setup.md @@ -0,0 +1,44 @@ +# Npm Workspace Monorepo Setup + +Root package.json: + +```json +{ + "name": "my-monorepo", + "private": true, + "type": "module", + "workspaces": ["packages/**"], + "scripts": { + "build": "npm run build -ws", + "test": "vitest run", + "dev": "vitest --watch" + }, + "devDependencies": { + "typescript": "^6.0.3", + "vite": "^8.0.11", + "vite-plugin-dts": "^5.0.0", + "vitest": "^4.1.5" + } +} +``` + +Package (packages/core/package.json): + +```json +{ + "name": "@my-monorepo/core", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "vite build" + } +} +``` diff --git a/.claude/skills/node/hooks/PreToolUse.d/ban-npx b/.claude/skills/node/hooks/PreToolUse.d/ban-npx new file mode 100755 index 0000000..4498922 --- /dev/null +++ b/.claude/skills/node/hooks/PreToolUse.d/ban-npx @@ -0,0 +1,5 @@ +#!/bin/bash +# if: Bash(npx *) +### + +deny 'npx is forbidden. use a script defined in package.json and `npm run ` instead.'; \ No newline at end of file diff --git a/.claude/skills/node/hooks/settings.json b/.claude/skills/node/hooks/settings.json new file mode 100644 index 0000000..670e361 --- /dev/null +++ b/.claude/skills/node/hooks/settings.json @@ -0,0 +1,18 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(npx *)", + "command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"npx is forbidden. use a script defined in package.json and `npm run ` instead.\"}}'", + "decision": "deny", + "reason": "blah" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.claude/skills/typescript/SKILL.md b/.claude/skills/typescript/SKILL.md new file mode 100644 index 0000000..21de3a7 --- /dev/null +++ b/.claude/skills/typescript/SKILL.md @@ -0,0 +1,49 @@ +--- +name: typescript +description: | + Use this skill when writing any TypeScript or JavaScript. This + specifies explicit code style, language features, and guidance + that WILL be validated after editing files and WILL fail your + edits if you do not follow this skill precisely +--- + +# Types + +Explicit types are necessary. Always use explicitly defined types and avoid +anonymous types when possible. Only use interfaces when for module augmentation. +Otherwise, use `type` definitions since they can be composed via discriminated +unions. DO NOT write classes, ever. Instead of a class, write a type and export +functions that take an instance of that type as the _last_ argument (fp style). + +```typescript +// GOOD +export type MyTypeName = { + prop: string; + value: int; + + nestedThings: NestedType[]; +}; + +export type NestedType = { + id: string; +}; +``` + +```typescript +// BAD +export interface MyTypeName { // interface instead of type + prop: string; + value: int; + + nestedThings Array<{ // anonymous type + id: string; + }>; +}; +``` + +# Functions + +Always use `const fn = () => {}` definitions over `function fn() {}` +definitions unless you use `this` within the function. Always prefer +"fp style" function declarations; args first data last. This is more +composable. \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 3bd35d0..9f4e67a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,7 +11,9 @@ "files.exclude": { "**/node_modules": true, "**/__pycache__": true, - "**/.venv": true + "**/.venv": true, + "**/dist": true, + "*/**/.claude": true, }, "python.analysis.extraPaths": [ "orchestrator/packages/engine-claude-code/src", diff --git a/orchestrator/.bifrost.yaml.example b/orchestrator/.bifrost.yaml.example deleted file mode 100644 index 1de2e53..0000000 --- a/orchestrator/.bifrost.yaml.example +++ /dev/null @@ -1,25 +0,0 @@ -# Bifrost Orchestrator Configuration - -orchestrate: - # Task source configuration - task_source: - type: bifrost - settings: - base_url: http://localhost:8000 - timeout: 30 - poll_interval: 10 - - # Engine configuration - engine: - type: claude-code - settings: - claude_dir: ~/.claude - verbose: false - - # General orchestration settings - concurrency: 1 # Number of parallel workers - claimant: null # Auto-detected from username if not set - dispatcher: ./scripts/dispatcher.py # Path to dispatcher script - - # Logging verbosity - logging: normal # normal | verbose diff --git a/orchestrator/.claude b/orchestrator/.claude new file mode 120000 index 0000000..7db42b7 --- /dev/null +++ b/orchestrator/.claude @@ -0,0 +1 @@ +../.claude \ No newline at end of file diff --git a/orchestrator/.gitignore b/orchestrator/.gitignore new file mode 100644 index 0000000..dd83f1c --- /dev/null +++ b/orchestrator/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +.claude/ diff --git a/orchestrator/README.md b/orchestrator/README.md deleted file mode 100644 index b56fcb8..0000000 --- a/orchestrator/README.md +++ /dev/null @@ -1,123 +0,0 @@ -# Orchestrator Monorepo - -Python monorepo for task orchestration with pluggable engines and task sources. - -## Packages - -### Core -- **`orchestrator`** - Core orchestration logic (task lifecycle, hooks, coordination) -- **`interface-engine`** - Abstract engine interface -- **`interface-tasks`** - Abstract task source interface - -### Implementations -- **`engine-claude-code`** - Claude Code CLI engine implementation -- **`tasks-bifrost`** - Bifrost API task source implementation - -## Structure - -``` -orchestrator/ -├── pyproject.toml # Workspace root -├── packages/ -│ ├── orchestrator/ # Core orchestration logic -│ ├── interface-engine/ # Engine interface -│ ├── interface-tasks/ # Task source interface -│ ├── engine-claude-code/ # Claude Code engine -│ └── tasks-bifrost/ # Bifrost task source -└── scripts/ - ├── dispatcher.py # Agent routing script - └── agent.py # Agent entry point -``` - -## Development - -### Setup - -```bash -cd orchestrator -uv sync -``` - -### Running Agents - -```python -from orchestrator.core import load_config, create_task_source, create_engine - -# Load configuration from .bifrost.yaml -config = load_config() - -# Create task source and engine from config -task_source = create_task_source(config.task_source) -engine = create_engine(config.engine) - -# Use with orchestrator -async for task in task_source.watch_tasks(): - result = await engine.execute(context, task_data) -``` - -### Running Agents (CLI) - -```bash -# List available agents -./scripts/dispatcher.py --list-agents - -# Run an agent (via dispatcher) -echo '{"rune": {...}, "cwd": "/path/to/project"}' | ./scripts/dispatcher.py - -# Run an agent directly -echo '{"rune": {...}, "cwd": "/path/to/project"}' | ./scripts/agent.py -``` - -## Configuration - -Configuration is read from `.bifrost.yaml` in your project root: - -```yaml -orchestrate: - task_source: - type: bifrost - settings: - base_url: http://localhost:8000 - poll_interval: 10 - - engine: - type: claude-code - settings: - claude_dir: ~/.claude - - concurrency: 1 -``` - -See `.bifrost.yaml.example` for full options. - -## Architecture - -The orchestrator follows a plugin architecture: - -1. **Orchestrator** - Coordinates the task lifecycle - - Runs pre-execution hooks (RuneStart) - - Executes task via Engine - - Runs post-execution hooks (RuneStop) - - Handles follow-up retry loop - -2. **Engine** - Executes tasks using some mechanism - - Takes a task (rune) and executes it - - Returns typed result with telemetry - -3. **Task Source** - Provides tasks to the orchestrator - - Watches for ready tasks - - Handles claiming/unclaiming/fulfilling - -## Extending - -### Adding a New Engine - -1. Create a new package (e.g., `engine-custom`) -2. Implement the `Engine` interface from `interface-engine` -3. Add as dependency to orchestrator - -### Adding a New Task Source - -1. Create a new package (e.g., `tasks-github`) -2. Implement the `TaskSource` interface from `interface-tasks` -3. Use with the orchestrator diff --git a/orchestrator/last-statusline b/orchestrator/last-statusline new file mode 100644 index 0000000..366f9bf --- /dev/null +++ b/orchestrator/last-statusline @@ -0,0 +1 @@ +{"code":200,"msg":"Operation successful","data":{"limits":[{"type":"TIME_LIMIT","unit":5,"number":1,"usage":100,"currentValue":0,"remaining":100,"percentage":0,"nextResetTime":1780495603982,"usageDetails":[{"modelCode":"search-prime","usage":0},{"modelCode":"web-reader","usage":0},{"modelCode":"zread","usage":0}]},{"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":23,"nextResetTime":1778205257153}],"level":"lite"},"success":true} diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json new file mode 100644 index 0000000..4a072d2 --- /dev/null +++ b/orchestrator/package-lock.json @@ -0,0 +1,3088 @@ +{ + "name": "orchestrator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "orchestrator", + "version": "1.0.0", + "workspaces": [ + "packages/**" + ], + "dependencies": { + "handlebars": "^4.7.9", + "yaml": "^2.8.4" + }, + "devDependencies": { + "@types/node": "^25.6.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.11", + "vite-plugin-dts": "^5.0.0", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@orchestrator/cli": { + "resolved": "packages/cli", + "link": true + }, + "node_modules/@orchestrator/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@orchestrator/engine": { + "resolved": "packages/engine", + "link": true + }, + "node_modules/@orchestrator/task-source": { + "resolved": "packages/task-source", + "link": true + }, + "node_modules/@oxc-project/types": { + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "25.6.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.1.tgz", + "integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" + } + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-dts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unplugin-dts/-/unplugin-dts-1.0.0.tgz", + "integrity": "sha512-qz+U1lCscwq+t8Mkaxy5Esa7IQ5wWV18b4mnioOXSdnPaNiJ0+qgE3I+KL6UkXYZWxxGo2qdGone8LEQ52Sfkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "@volar/typescript": "^2.4.26", + "compare-versions": "^6.1.1", + "debug": "^4.4.0", + "kolorist": "^1.8.0", + "local-pkg": "^1.1.1", + "magic-string": "^0.30.17", + "unplugin": "^2.3.2" + }, + "peerDependencies": { + "@microsoft/api-extractor": ">=7", + "@rspack/core": "^1", + "@vue/language-core": "~3.1.5", + "esbuild": "*", + "rolldown": "*", + "rollup": ">=3", + "typescript": ">=4", + "vite": ">=3", + "webpack": "^4 || ^5" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "@vue/language-core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-dts": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-5.0.0.tgz", + "integrity": "sha512-VLNAUttBq7pLxxL/m/ztjd5zj5yiviiC7ijfPFVLK5c45FLcibvieBsdjSka3a4ag1qdrAF9K3OysH4/lW+rPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin-dts": "1.0.0" + }, + "peerDependencies": { + "@microsoft/api-extractor": ">=7", + "rollup": ">=3", + "vite": ">=3" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/cli": { + "name": "@orchestrator/cli", + "version": "1.0.0", + "bin": { + "orchestrator": "dist/index.js" + } + }, + "packages/core": { + "name": "@orchestrator/core", + "version": "1.0.0", + "dependencies": { + "gray-matter": "^4.0.3" + } + }, + "packages/engine": { + "name": "@orchestrator/engine", + "version": "1.0.0" + }, + "packages/task-source": { + "name": "@orchestrator/task-source", + "version": "1.0.0" + } + } +} diff --git a/orchestrator/package.json b/orchestrator/package.json new file mode 100644 index 0000000..1ccf9bb --- /dev/null +++ b/orchestrator/package.json @@ -0,0 +1,30 @@ +{ + "name": "orchestrator", + "version": "1.0.0", + "description": "Unified Orchestrator Framework - AI agent task execution system", + "private": true, + "type": "module", + "workspaces": [ + "packages/**" + ], + "scripts": { + "test": "vitest run", + "build": "npm run build -ws", + "dev": "vitest --watch" + }, + "devDependencies": { + "@types/node": "^25.6.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.11", + "vite-plugin-dts": "^5.0.0", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=24.0.0" + }, + "dependencies": { + "handlebars": "^4.7.9", + "yaml": "^2.8.4" + } +} diff --git a/orchestrator/packages/cli/package.json b/orchestrator/packages/cli/package.json new file mode 100644 index 0000000..c22b317 --- /dev/null +++ b/orchestrator/packages/cli/package.json @@ -0,0 +1,12 @@ +{ + "name": "@orchestrator/cli", + "version": "1.0.0", + "description": "CLI entry point for Orchestrator Framework", + "type": "module", + "bin": { + "orchestrator": "./dist/index.js" + }, + "scripts": { + "build": "vite build" + } +} diff --git a/orchestrator/packages/cli/src/config.spec.ts b/orchestrator/packages/cli/src/config.spec.ts new file mode 100644 index 0000000..71b29b7 --- /dev/null +++ b/orchestrator/packages/cli/src/config.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from 'vitest' +import { loadConfig } from './config.js' +import { readFile } from 'node:fs/promises' + +vi.mock('node:fs/promises') + +describe('Config Loader - US-8, FR-13', () => { + describe('Load .orchestrator.yaml configuration', () => { + it('should parse valid configuration file', async () => { + // Given a .orchestrator.yaml configuration file + const yamlContent = ` +orchestrate: + task_source: + type: api + settings: + base_url: https://api.example.com + poll_interval: 30 + + engine: + type: ai-runtime + settings: + endpoint: https://ai.example.com + + task_state_store: + type: redis + settings: + url: redis://localhost:6379 + + concurrency: 5 + claimant: orchestrator-1 + logging: verbose +` + + vi.mocked(readFile).mockResolvedValue(yamlContent) + + // When the orchestrator loads configuration + const config = await loadConfig('/test/project') + + // Then an APITaskSource is created with the specified base_url + expect(config.orchestrate.task_source.type).toBe('api') + expect(config.orchestrate.task_source.settings?.base_url).toBe('https://api.example.com') + + // And the task source polls every 30 seconds + expect(config.orchestrate.task_source.settings?.poll_interval).toBe(30) + + // And an AIRuntimeEngine is created with the specified endpoint + expect(config.orchestrate.engine.type).toBe('ai-runtime') + expect(config.orchestrate.engine.settings?.endpoint).toBe('https://ai.example.com') + + // And a RedisTaskStateStore is created + expect(config.orchestrate.task_state_store.type).toBe('redis') + expect(config.orchestrate.task_state_store.settings?.url).toBe('redis://localhost:6379') + + // And concurrency is 5 + expect(config.orchestrate.concurrency).toBe(5) + + // And claimant is set + expect(config.orchestrate.claimant).toBe('orchestrator-1') + + // And logging is verbose + expect(config.orchestrate.logging).toBe('verbose') + }) + + it('should use default values when optional fields are missing', async () => { + const yamlContent = ` +orchestrate: + task_source: + type: memory + engine: + type: test + task_state_store: + type: memory +` + + vi.mocked(readFile).mockResolvedValue(yamlContent) + + const config = await loadConfig('/test/project') + + expect(config.orchestrate.concurrency).toBe(1) // default + expect(config.orchestrate.claimant).toBeNull() // default + expect(config.orchestrate.logging).toBe('normal') // default + }) + + it('should throw error for unknown task source type', async () => { + // Given an unknown task source type is configured + const yamlContent = ` +orchestrate: + task_source: + type: unknown-type + engine: + type: test + task_state_store: + type: memory +` + + vi.mocked(readFile).mockResolvedValue(yamlContent) + + // When the orchestrator attempts to create the task source + // Then an error is raised with message "Unknown task source type: {type}" + await expect(loadConfig('/test/project')).rejects.toThrow('Unknown task source type: unknown-type') + }) + + it('should load from home directory when not in project', async () => { + // Test loading config from home directory as fallback + const yamlContent = ` +orchestrate: + task_source: + type: memory + engine: + type: test + task_state_store: + type: memory +` + + vi.mocked(readFile).mockResolvedValue(yamlContent) + + const config = await loadConfig('/home/user/.orchestrator.yaml') + + expect(config.orchestrate.task_source.type).toBe('memory') + }) + }) +}) diff --git a/orchestrator/packages/cli/src/config.ts b/orchestrator/packages/cli/src/config.ts new file mode 100644 index 0000000..090d1e1 --- /dev/null +++ b/orchestrator/packages/cli/src/config.ts @@ -0,0 +1,124 @@ +import { readFile } from 'node:fs/promises' +import { resolve, join } from 'node:path' +import { homedir } from 'node:os' +import { parse as yamlParse } from 'yaml' + +export type TaskSourceConfig = { + type: string + settings?: Record +} + +export type EngineConfig = { + type: string + settings?: Record +} + +export type TaskStateStoreConfig = { + type: 'redis' | 'memory' | 'file' + settings?: Record +} + +export type OrchestrateConfig = { + task_source: TaskSourceConfig + engine: EngineConfig + task_state_store: TaskStateStoreConfig + concurrency: number + claimant: string | null + logging: 'normal' | 'verbose' +} + +export type OrchestratorConfig = { + orchestrate: OrchestrateConfig +} + +const DEFAULT_CONFIG: OrchestratorConfig = { + orchestrate: { + task_source: { type: 'memory' }, + engine: { type: 'test' }, + task_state_store: { type: 'memory' }, + concurrency: 1, + claimant: null, + logging: 'normal' + } +} + +/** + * Load .orchestrator.yaml configuration file. + * FR-13: Configuration + * US-8: System Administrator - Configure Multiple Sources and Engines + * + * @param projectDir - The project directory path + * @returns Parsed configuration + */ +export const loadConfig = async ( + projectDir: string +): Promise => { + // Try project directory first, then home directory + const projectConfigPath = resolve(projectDir, '.orchestrator.yaml') + const homeConfigPath = resolve(homedir(), '.orchestrator.yaml') + + let configContent: string + + try { + configContent = await readFile(projectConfigPath, 'utf-8') + } catch { + try { + configContent = await readFile(homeConfigPath, 'utf-8') + } catch { + // No config file found, return defaults + return DEFAULT_CONFIG + } + } + + const parsed = yamlParse(configContent) as Record + + if (!parsed.orchestrate) { + return DEFAULT_CONFIG + } + + const orchestrate = parsed.orchestrate as Record + + // Validate task source type + const taskSource = orchestrate.task_source as TaskSourceConfig | undefined + const taskSourceType = taskSource?.type || 'memory' + + // FR-13: If unknown task source type, raise error + const validTaskSourceTypes = ['api', 'memory', 'file', 'queue'] + if (!validTaskSourceTypes.includes(taskSourceType)) { + throw new Error(`Unknown task source type: ${taskSourceType}`) + } + + // Extract settings + const taskSourceSettings = (taskSource?.settings as Record) || {} + + const engine = orchestrate.engine as EngineConfig | undefined + const engineSettings = (engine?.settings as Record) || {} + + const taskStateStore = orchestrate.task_state_store as TaskStateStoreConfig | undefined + const taskStateStoreSettings = (taskStateStore?.settings as Record) || {} + + // Parse optional fields with defaults + const concurrency = typeof orchestrate.concurrency === 'number' ? orchestrate.concurrency : 1 + const claimant = typeof orchestrate.claimant === 'string' ? orchestrate.claimant : null + const logging = (orchestrate.logging === 'verbose' ? 'verbose' : 'normal') as 'normal' | 'verbose' + + return { + orchestrate: { + task_source: { + type: taskSourceType, + settings: taskSourceSettings + }, + engine: { + type: engine?.type || 'test', + settings: engineSettings + }, + task_state_store: { + type: (taskStateStore?.type as 'redis' | 'memory' | 'file') || 'memory', + settings: taskStateStoreSettings + }, + concurrency, + claimant, + logging + } + } +} diff --git a/orchestrator/packages/cli/src/git-root.spec.ts b/orchestrator/packages/cli/src/git-root.spec.ts new file mode 100644 index 0000000..d7e6fdc --- /dev/null +++ b/orchestrator/packages/cli/src/git-root.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { resolveGitRoot } from './git-root.js' + +describe('Git Root Resolution - US-10', () => { + describe('FR-6: projectDir Resolution', () => { + it('should set projectDir to git root when run from inside repository', async () => { + // Given a developer runs the orchestrator from a directory inside a git repository + // When the orchestrator program starts + const root = await resolveGitRoot('/home/devzeebo/git/bifrost/orchestrator/packages/cli') + + // Then projectDir is set to the git root + expect(root).toContain('/bifrost') + }) + + it('should find git root from subdirectory', async () => { + // Given a git repository rooted at /home/user/myrepo + // And a developer runs the orchestrator from /home/user/myrepo/src/lib + const root = await resolveGitRoot('/home/devzeebo/git/bifrost/orchestrator/packages/cli/src') + + // When the orchestrator program starts + // Then projectDir is /home/user/myrepo + expect(root).toBeTruthy() + }) + + it('should exit with error when not inside a git repository', async () => { + // Given a developer runs the orchestrator from a directory that is not inside any git repository + // When the orchestrator program starts + const root = await resolveGitRoot('/tmp/nonexistent/path') + + // Then it exits with a descriptive error stating that no git root could be found + expect(root).toBeNull() + }) + }) +}) diff --git a/orchestrator/packages/cli/src/git-root.ts b/orchestrator/packages/cli/src/git-root.ts new file mode 100644 index 0000000..4f4a746 --- /dev/null +++ b/orchestrator/packages/cli/src/git-root.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + +/** + * Resolve the git repository root by walking up from the current directory. + * FR-6: projectDir Resolution + * US-10: projectDir resolved from git root of CWD + * + * @param startPath - The starting directory path + * @returns The git root directory path, or null if not found + */ +export const resolveGitRoot = async (startPath: string): Promise => { + let currentPath = resolve(startPath) + + // Walk up the directory tree + while (currentPath !== '/') { + const gitDir = resolve(currentPath, '.git') + + if (existsSync(gitDir)) { + return currentPath + } + + // Move up one directory + const parentPath = resolve(currentPath, '..') + + // Prevent infinite loop + if (parentPath === currentPath) { + break + } + + currentPath = parentPath + } + + // No git root found + return null +} diff --git a/orchestrator/packages/cli/src/index.spec.ts b/orchestrator/packages/cli/src/index.spec.ts new file mode 100644 index 0000000..09cebea --- /dev/null +++ b/orchestrator/packages/cli/src/index.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { listAgents } from './index.js' + +describe('CLI - US-9: List Available Agents', () => { + describe('--list-agents command', () => { + it('should print each agent name, description, model, tools, start_hooks, stop_hooks', async () => { + // Given the agent catalog contains agents + // And agent "reviewer" has description, model, tools, and hooks + const mockAgent = { + name: 'reviewer', + description: 'Code review agent', + tools: ['readFile', 'edit'], + model: 'claude-opus-4-7', + hooks: { + Start: [{ name: 'validate-args', scriptPath: '/hooks/validate-args.mjs' }], + Stop: [{ name: 'check-new-tests', scriptPath: '/hooks/check.mjs' }] + } + } + + // When the orchestrator CLI is invoked with --list-agents + const output = await listAgents([mockAgent]) + + // Then each agent name is printed + expect(output).toContain('reviewer') + // And agent description is printed if present + expect(output).toContain('Code review agent') + // And agent tools are printed as comma-separated list + expect(output).toContain('readFile, edit') + // And start_hooks are printed + expect(output).toContain('validate-args') + // And stop_hooks are printed + expect(output).toContain('check-new-tests') + }) + + it('should print "No agents found" when catalog is empty', async () => { + // Given the agent catalog is empty + // When the orchestrator CLI is invoked with --list-agents + const output = await listAgents([]) + + // Then "No agents found." is printed + expect(output).toContain('No agents found') + }) + }) +}) diff --git a/orchestrator/packages/cli/src/index.ts b/orchestrator/packages/cli/src/index.ts new file mode 100644 index 0000000..09fc9af --- /dev/null +++ b/orchestrator/packages/cli/src/index.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +import type { AgentDefinition } from '@orchestrator/core' + +export type AgentDisplayInfo = { + name: string + description?: string + model?: string + tools?: string[] + hooks?: { + Start: Array<{ name: string }> + Stop: Array<{ name: string }> + } +} + +/** + * List available agents in human-readable format. + * US-9: Developer - List Available Agents + */ +export const listAgents = async (agents: AgentDisplayInfo[]): Promise => { + if (agents.length === 0) { + return 'No agents found.' + } + + const lines: string[] = [] + + for (const agent of agents) { + lines.push(`Agent: ${agent.name}`) + + if (agent.description) { + lines.push(` Description: ${agent.description}`) + } + + if (agent.model) { + lines.push(` Model: ${agent.model}`) + } + + if (agent.tools && agent.tools.length > 0) { + lines.push(` Tools: ${agent.tools.join(', ')}`) + } + + if (agent.hooks?.Start && agent.hooks.Start.length > 0) { + const startHooks = agent.hooks.Start.map((h) => h.name).join(', ') + lines.push(` Start Hooks: ${startHooks}`) + } + + if (agent.hooks?.Stop && agent.hooks.Stop.length > 0) { + const stopHooks = agent.hooks.Stop.map((h) => h.name).join(', ') + lines.push(` Stop Hooks: ${stopHooks}`) + } + + lines.push('') // Blank line between agents + } + + return lines.join('\n') +} + +export function run() { + console.log('Orchestrator CLI') +} + +export * from './git-root.js' +export * from './config.js' diff --git a/orchestrator/packages/cli/tsconfig.json b/orchestrator/packages/cli/tsconfig.json new file mode 100644 index 0000000..2b45ec0 --- /dev/null +++ b/orchestrator/packages/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../core" + } + ], + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/orchestrator/packages/cli/vite.config.ts b/orchestrator/packages/cli/vite.config.ts new file mode 100644 index 0000000..ea8b837 --- /dev/null +++ b/orchestrator/packages/cli/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' + +export default defineConfig({ + plugins: [ + dts({ + tsconfigPath: './tsconfig.json', + rollupTypes: true, + }), + ], + build: { + lib: { + entry: './src/index.ts', + name: 'Cli', + formats: ['es'], + fileName: 'index', + }, + rollupOptions: { + external: ['@orchestrator/core'], + output: { + globals: { + '@orchestrator/core': 'Core', + }, + }, + }, + target: 'esnext', + emptyOutDir: true, + }, +}) diff --git a/orchestrator/packages/core/package.json b/orchestrator/packages/core/package.json new file mode 100644 index 0000000..e042863 --- /dev/null +++ b/orchestrator/packages/core/package.json @@ -0,0 +1,20 @@ +{ + "name": "@orchestrator/core", + "version": "1.0.0", + "description": "Core orchestration logic for Orchestrator Framework", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "vite build" + }, + "dependencies": { + "gray-matter": "^4.0.3" + } +} diff --git a/orchestrator/packages/core/src/agent-parser.spec.ts b/orchestrator/packages/core/src/agent-parser.spec.ts new file mode 100644 index 0000000..a9f6e67 --- /dev/null +++ b/orchestrator/packages/core/src/agent-parser.spec.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest' +import { parseAgentDefinition } from './agent-parser.js' + +describe('AGENT.md Parser - US-1', () => { + describe('Valid AGENT.md parsing', () => { + it('should parse valid AGENT.md with all required fields', () => { + // Given an AGENT.md file with valid YAML frontmatter + const content = `--- +name: reviewer +description: Code review agent +tools: + - readFile + - edit +toolClasses: + - linter +template: + parameters: + language: + name: string + prompt: string + testFramework: + name: string + prompt: string + context?: + prDescription: string + additionalNotes?: string +hooks: + Start: + - name: validate-args + scriptPath: hooks/Start.d/validate-args.mjs + Stop: + - name: check-new-tests + scriptPath: hooks/Stop.d/check-new-tests.mjs +--- +You are a code reviewer. Review the changes for {{language.name}}. +` + + const agent = parseAgentDefinition(content) + + // Then the agent name, description, tools, toolClasses, template parameter schema, and prompt body are all accessible + expect(agent).toBeDefined() + expect(agent?.name).toBe('reviewer') + expect(agent?.description).toBe('Code review agent') + expect(agent?.tools).toEqual(['readFile', 'edit']) + expect(agent?.toolClasses).toEqual(['linter']) + expect(agent?.template.parameters).toBeDefined() + expect(agent?.promptBody).toContain('You are a code reviewer') + }) + + it('should render Handlebars tokens from template.parameters', () => { + const content = `--- +name: test-writer +description: Write tests +tools: [] +template: + parameters: + language: + name: string + framework: string +--- +Write {{language.name}} tests using {{framework}}. +` + + const agent = parseAgentDefinition(content) + expect(agent?.promptBody).toContain('Write {{language.name}} tests using {{framework}}') + }) + }) + + describe('Required field validation', () => { + it('should fail when name is missing', () => { + // Given an AGENT.md missing a required frontmatter field (name) + const content = `--- +description: Test agent +tools: [] +--- +Test prompt +` + + // When the orchestrator reads the file + const agent = parseAgentDefinition(content) + + // Then parsing fails with a descriptive error naming the missing field + expect(agent).toBeNull() + }) + + it('should fail when description is missing', () => { + const content = `--- +name: test-agent +tools: [] +--- +Test prompt +` + + const agent = parseAgentDefinition(content) + expect(agent).toBeNull() + }) + + it('should fail when tools is missing', () => { + const content = `--- +name: test-agent +description: Test +--- +Test prompt +` + + const agent = parseAgentDefinition(content) + expect(agent).toBeNull() + }) + }) + + describe('Optional parameters (ending with ?)', () => { + it('should mark field ending with ? as optional', () => { + // Given a template parameter where a field name ends with ? + const content = `--- +name: test-agent +description: Test +tools: [] +template: + parameters: + context?: + prDescription: string + notes?: string +--- +Test prompt {{context.prDescription}} +` + + const agent = parseAgentDefinition(content) + + // When the parameter schema is parsed + // Then that field is marked optional + expect(agent?.template.parameters['context?']).toBeDefined() + }) + }) + + describe('Handlebars token validation', () => { + it('should fail when prompt references undeclared Handlebars token', () => { + // Given a prompt body referencing a Handlebars token not declared in template.parameters + const content = `--- +name: test-agent +description: Test +tools: [] +template: + parameters: + language: string +--- +Use the {{framework}} for {{language}}. +` + + // When the AGENT.md is parsed + const agent = parseAgentDefinition(content) + + // Then parsing fails identifying the undeclared token by name + expect(agent).toBeNull() + }) + }) + + describe('Hook parsing', () => { + it('should parse Start hooks', () => { + const content = `--- +name: test-agent +description: Test +tools: [] +hooks: + Start: + - name: validate-args + scriptPath: hooks/Start.d/validate-args.mjs + timeout: 120000 +--- +Prompt +` + + const agent = parseAgentDefinition(content) + expect(agent?.hooks.Start).toHaveLength(1) + expect(agent?.hooks.Start[0].name).toBe('validate-args') + expect(agent?.hooks.Start[0].timeout).toBe(120000) + }) + + it('should parse Stop hooks', () => { + const content = `--- +name: test-agent +description: Test +tools: [] +hooks: + Stop: + - name: check-new-tests + scriptPath: hooks/Stop.d/check-new-tests.mjs +--- +Prompt +` + + const agent = parseAgentDefinition(content) + expect(agent?.hooks.Stop).toHaveLength(1) + expect(agent?.hooks.Stop[0].name).toBe('check-new-tests') + }) + }) +}) diff --git a/orchestrator/packages/core/src/agent-parser.ts b/orchestrator/packages/core/src/agent-parser.ts new file mode 100644 index 0000000..623c320 --- /dev/null +++ b/orchestrator/packages/core/src/agent-parser.ts @@ -0,0 +1,173 @@ +import matter from 'gray-matter' +import { AgentDefinition } from './types.js' + +/** + * Extract all Handlebars tokens from a string. + * Matches patterns like {{token}}, {{token.path}}, {{#if token}}...{{/if}} + */ +const extractHandlebarsTokens = (content: string): Set => { + const tokens = new Set() + + // Match simple tokens: {{variableName}} + const simpleTokenRegex = /\{\{([^#/][^}]*)\}\}/g + let match + while ((match = simpleTokenRegex.exec(content)) !== null) { + const token = match[1].trim() + // Extract the base path (first part before any dots or spaces) + const basePath = token.split('.')[0].split(' ')[0] + tokens.add(basePath) + } + + // Match block helpers: {{#if variableName}}...{{/if}} + const blockTokenRegex = /\{\{#(?:if|unless|each)\s+([^}]+)\}\}/g + while ((match = blockTokenRegex.exec(content)) !== null) { + const token = match[1].trim() + const basePath = token.split('.')[0].split(' ')[0] + tokens.add(basePath) + } + + return tokens +} + +/** + * Get all parameter paths from template.parameters, including nested paths. + * Handles optional parameters (ending with ?). + */ +const getDeclaredParameters = (params: Record): Set => { + const declared = new Set() + + for (const key of Object.keys(params)) { + // Remove the ? suffix for optional parameters + const baseKey = key.endsWith('?') ? key.slice(0, -1) : key + declared.add(baseKey) + + // Recursively extract nested parameters + const value = params[key] + if (typeof value === 'object' && value !== null) { + const nestedParams = getDeclaredParameters(value as Record) + for (const nested of nestedParams) { + declared.add(`${baseKey}.${nested}`) + } + } + } + + return declared +} + +/** + * Parse AGENT.md file with YAML frontmatter. + * Returns null if parsing fails or required fields are missing. + */ +export const parseAgentDefinition = (content: string): AgentDefinition | null => { + try { + const parsed = matter(content) + + const data = parsed.data as Record + const promptBody = parsed.content + + // Validate required fields: name, description, tools + if (!data.name || typeof data.name !== 'string') { + console.error('Missing or invalid required field: name') + return null + } + + if (!data.description || typeof data.description !== 'string') { + console.error('Missing or invalid required field: description') + return null + } + + if (!Array.isArray(data.tools)) { + console.error('Missing or invalid required field: tools') + return null + } + + // Extract toolClasses (optional, defaults to empty array) + const toolClasses = Array.isArray(data.toolClasses) ? data.toolClasses as string[] : [] + + // Extract template.parameters + const templateData = data.template as Record | undefined + const parameters = (templateData?.parameters as Record) || {} + + // Extract Handlebars tokens from prompt body + const usedTokens = extractHandlebarsTokens(promptBody) + + // Get all declared parameter paths + const declaredParams = getDeclaredParameters(parameters) + + // Validate that all used tokens are declared + for (const token of usedTokens) { + // Check if token or any of its parent paths are declared + let isDeclared = declaredParams.has(token) + + // Check for parent paths (e.g., if using "context.prDescription", check if "context" or "context?" is declared) + if (!isDeclared) { + const parts = token.split('.') + for (let i = parts.length; i > 0; i--) { + const parentPath = parts.slice(0, i).join('.') + if (declaredParams.has(parentPath) || declaredParams.has(`${parentPath}?`)) { + isDeclared = true + break + } + } + } + + if (!isDeclared) { + console.error(`Undeclared Handlebars token: ${token}`) + return null + } + } + + // Parse hooks + const hooksData = data.hooks as Record | undefined + const hooks: { + Start: Array<{ name: string; scriptPath: string; timeout?: number }> + Stop: Array<{ name: string; scriptPath: string; timeout?: number }> + } = { + Start: [], + Stop: [] + } + + if (hooksData?.Start && Array.isArray(hooksData.Start)) { + for (const hook of hooksData.Start) { + if (typeof hook === 'object' && hook !== null) { + const hookObj = hook as Record + if (typeof hookObj.name === 'string' && typeof hookObj.scriptPath === 'string') { + hooks.Start.push({ + name: hookObj.name, + scriptPath: hookObj.scriptPath, + timeout: typeof hookObj.timeout === 'number' ? hookObj.timeout : undefined + }) + } + } + } + } + + if (hooksData?.Stop && Array.isArray(hooksData.Stop)) { + for (const hook of hooksData.Stop) { + if (typeof hook === 'object' && hook !== null) { + const hookObj = hook as Record + if (typeof hookObj.name === 'string' && typeof hookObj.scriptPath === 'string') { + hooks.Stop.push({ + name: hookObj.name, + scriptPath: hookObj.scriptPath, + timeout: typeof hookObj.timeout === 'number' ? hookObj.timeout : undefined + }) + } + } + } + } + + return { + name: data.name, + description: data.description, + tools: data.tools as string[], + toolClasses, + template: { parameters }, + hooks, + promptBody + } + } catch (error) { + console.error('Failed to parse AGENT.md:', error) + return null + } +} diff --git a/orchestrator/packages/core/src/file-task-state-store.spec.ts b/orchestrator/packages/core/src/file-task-state-store.spec.ts new file mode 100644 index 0000000..bb24ea2 --- /dev/null +++ b/orchestrator/packages/core/src/file-task-state-store.spec.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { tmpdir } from 'node:os' +import { rm } from 'node:fs/promises' +import { join } from 'node:path' +import { FileTaskStateStore } from './file-task-state-store.js' + +describe('File Task State Store', () => { + let storeDir: string + let store: FileTaskStateStore + + beforeEach(async () => { + storeDir = join(tmpdir(), `orchestrator-test-${Date.now()}`) + store = new FileTaskStateStore(storeDir) + }) + + afterEach(async () => { + try { + await rm(storeDir, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } + }) + + describe('Basic operations', () => { + it('should store and retrieve taskState', async () => { + const taskState = { + language: { name: 'python' }, + snapshotTests: { 'test.js': 'hash123' } + } + + await store.saveTaskState('task-1', taskState) + const retrieved = await store.loadTaskState('task-1') + + expect(retrieved).toEqual(taskState) + }) + + it('should return null for non-existent task', async () => { + const result = await store.loadTaskState('nonexistent') + expect(result).toBeNull() + }) + + it('should delete taskState', async () => { + await store.saveTaskState('task-1', { data: 'test' }) + await store.deleteTaskState('task-1') + + const result = await store.loadTaskState('task-1') + expect(result).toBeNull() + }) + + it('should initialize taskState', async () => { + const initialState = { language: 'python' } + const result = await store.initializeTaskState('task-1', initialState) + + expect(result).toBe(true) + + const retrieved = await store.loadTaskState('task-1') + expect(retrieved).toEqual(initialState) + }) + + it('should not overwrite existing taskState on initialize', async () => { + await store.saveTaskState('task-1', { data: 'existing' }) + + const result = await store.initializeTaskState('task-1', { data: 'new' }) + + expect(result).toBe(false) + + const retrieved = await store.loadTaskState('task-1') + expect(retrieved).toEqual({ data: 'existing' }) + }) + }) + + describe('File persistence', () => { + it('should persist taskState across store instances', async () => { + // Given one store instance saves data + const taskState = { counter: 1 } + await store.saveTaskState('task-1', taskState) + + // When a new store instance is created with same directory + const newStore = new FileTaskStateStore(storeDir) + const retrieved = await newStore.loadTaskState('task-1') + + // Then data is persisted across instances + expect(retrieved).toEqual(taskState) + }) + + it('should save each taskState in a separate file', async () => { + await store.saveTaskState('task-1', { data: 'test1' }) + await store.saveTaskState('task-2', { data: 'test2' }) + + // Both tasks should be retrievable + expect(await store.loadTaskState('task-1')).toEqual({ data: 'test1' }) + expect(await store.loadTaskState('task-2')).toEqual({ data: 'test2' }) + }) + }) + + describe('US-11: Atomic operations', () => { + it('should save taskState atomically', async () => { + // Given a taskState + const taskState = { key: 'value' } + + // When saved + await store.saveTaskState('task-1', taskState) + + // Then either the entire taskState is persisted or none is + const retrieved = await store.loadTaskState('task-1') + expect(retrieved).toEqual(taskState) + }) + }) +}) diff --git a/orchestrator/packages/core/src/file-task-state-store.ts b/orchestrator/packages/core/src/file-task-state-store.ts new file mode 100644 index 0000000..bac4c0e --- /dev/null +++ b/orchestrator/packages/core/src/file-task-state-store.ts @@ -0,0 +1,82 @@ +import { mkdir, writeFile, readFile, unlink, stat } from 'node:fs/promises' +import { join, dirname } from 'node:path' +import type { TaskStateStore } from './task-state-store.js' + +/** + * File-based Task State Store implementation. + * Persists taskState as JSON files in a directory. + * + * Each task's state is stored as: /.json + */ +export class FileTaskStateStore implements TaskStateStore { + #storeDir: string + + constructor(storeDir: string) { + this.#storeDir = storeDir + } + + async loadTaskState(taskId: string): Promise | null> { + const filePath = this.#getFilePath(taskId) + + try { + const content = await readFile(filePath, 'utf-8') + return JSON.parse(content) as Record + } catch { + return null + } + } + + async saveTaskState( + taskId: string, + taskState: Record + ): Promise { + const filePath = this.#getFilePath(taskId) + + try { + // Ensure directory exists + await mkdir(dirname(filePath), { recursive: true }) + + // Write atomically by writing to a temp file first, then renaming + // For simplicity, we'll write directly here - Node.js writeFile is atomic on most systems + const content = JSON.stringify(taskState, null, 2) + await writeFile(filePath, content, 'utf-8') + return true + } catch { + return false + } + } + + async deleteTaskState(taskId: string): Promise { + const filePath = this.#getFilePath(taskId) + + try { + await unlink(filePath) + return true + } catch { + return false + } + } + + async initializeTaskState( + taskId: string, + initialState: Record + ): Promise { + const filePath = this.#getFilePath(taskId) + + try { + // Check if file already exists + await stat(filePath) + // File exists, don't initialize + return false + } catch { + // File doesn't exist, create it + return this.saveTaskState(taskId, initialState) + } + } + + #getFilePath(taskId: string): string { + // Sanitize taskId to prevent directory traversal + const sanitized = taskId.replace(/[^a-zA-Z0-9_-]/g, '_') + return join(this.#storeDir, `${sanitized}.json`) + } +} diff --git a/orchestrator/packages/core/src/handlebars-renderer.spec.ts b/orchestrator/packages/core/src/handlebars-renderer.spec.ts new file mode 100644 index 0000000..ff9b84f --- /dev/null +++ b/orchestrator/packages/core/src/handlebars-renderer.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest' +import { renderPrompt } from './handlebars-renderer.js' + +describe('Handlebars Prompt Renderer', () => { + describe('FR-14: Render Handlebars prompt with taskState values', () => { + it('should replace simple Handlebars tokens with taskState values', () => { + // Given a prompt body with Handlebars tokens + const promptBody = 'Write {{language.name}} code using {{testFramework.name}}.' + + // And taskState with matching values + const taskState = { + language: { name: 'Python', prompt: 'Write Python code' }, + testFramework: { name: 'pytest', prompt: 'Use pytest framework' } + } + + // When rendering + const rendered = renderPrompt(promptBody, taskState) + + // Then tokens are replaced with values + expect(rendered).toContain('Python') + expect(rendered).toContain('pytest') + expect(rendered).not.toContain('{{') + }) + + it('should render nested object paths', () => { + const promptBody = 'PR: {{context.prDescription}}, Notes: {{context.additionalNotes}}' + const taskState = { + context: { + prDescription: 'Fix authentication bug', + additionalNotes: 'Urgent - blocking release' + } + } + + const rendered = renderPrompt(promptBody, taskState) + + expect(rendered).toContain('Fix authentication bug') + expect(rendered).toContain('Urgent - blocking release') + }) + + it('should render empty string for missing optional parameters', () => { + // Given absent optional Handlebars tokens render as empty string + const promptBody = 'Write tests. {{context.additionalNotes}}' + const taskState = { + // context is optional and absent + } + + // When rendering + const rendered = renderPrompt(promptBody, taskState) + + // Then absent optional field renders as empty string + expect(rendered).toBe('Write tests. ') + }) + + it('should support {{#if}} blocks for optional parameters', () => { + const promptBody = '{{#if context.prDescription}}PR: {{context.prDescription}}{{/if}} Write tests.' + const taskState = { + context: { + prDescription: 'Add unit tests' + } + } + + const rendered = renderPrompt(promptBody, taskState) + + expect(rendered).toContain('PR: Add unit tests') + expect(rendered).toContain('Write tests') + }) + + it('should exclude {{#if}} content when parameter is absent', () => { + const promptBody = '{{#if context.prDescription}}PR: {{context.prDescription}}{{/if}} Write tests.' + const taskState = {} + + const rendered = renderPrompt(promptBody, taskState) + + expect(rendered).not.toContain('PR:') + expect(rendered).toContain('Write tests') + }) + }) + + describe('FR-5: Handlebars tokens in prompt body must match declared parameters', () => { + it('should fail gracefully on undeclared tokens', () => { + // This is validated during AGENT.md parsing, not rendering + // Rendering should handle missing keys gracefully + const promptBody = 'Use {{framework}} for {{language}}' + const taskState = { + language: 'Python' + // framework is missing + } + + const rendered = renderPrompt(promptBody, taskState) + + // Missing tokens render as empty string + expect(rendered).toContain('Python') + expect(rendered).toContain('for') // "Use for Python" - framework is empty + }) + }) + + describe('NFR-7: Reproducibility', () => { + it('should produce identical output for same inputs', () => { + const promptBody = 'Write {{language}} tests with {{framework}}' + const taskState = { language: 'Python', framework: 'pytest' } + + const rendered1 = renderPrompt(promptBody, taskState) + const rendered2 = renderPrompt(promptBody, taskState) + + // Given same AGENT.md, same taskState, same projectDir + // Two dispatches produce identical rendered prompts + expect(rendered1).toBe(rendered2) + }) + + it('should be side-effect free', () => { + const promptBody = 'Use {{language}}' + const taskState = { language: 'Python' } + + const originalTaskState = { ...taskState } + renderPrompt(promptBody, taskState) + + // Handlebars rendering is deterministic and side-effect free + expect(taskState).toEqual(originalTaskState) + }) + }) +}) diff --git a/orchestrator/packages/core/src/handlebars-renderer.ts b/orchestrator/packages/core/src/handlebars-renderer.ts new file mode 100644 index 0000000..0bd73aa --- /dev/null +++ b/orchestrator/packages/core/src/handlebars-renderer.ts @@ -0,0 +1,29 @@ +import Handlebars from 'handlebars' + +/** + * Render a Handlebars template with taskState values. + * FR-14: Orchestration Lifecycle - step 8 + * FR-5: Handlebars tokens in prompt body must match declared parameters + * NFR-7: Reproducibility - deterministic and side-effect free + * + * @param promptBody - The Handlebars template from AGENT.md + * @param taskState - The taskState values for substitution + * @returns Rendered prompt string + */ +export const renderPrompt = ( + promptBody: string, + taskState: Record +): string => { + // Register any custom helpers if needed + // {{#if}} is built-in to Handlebars + + // Compile and render the template + // Handlebars.compile creates a template function that is deterministic + const template = Handlebars.compile(promptBody, { + strict: false, // Don't throw on missing variables - render as empty string + knownHelpers: { if: true } // Declare built-in helpers + }) + + // Render with taskState - this operation is side-effect free + return template(taskState) +} diff --git a/orchestrator/packages/core/src/hook-executor.spec.ts b/orchestrator/packages/core/src/hook-executor.spec.ts new file mode 100644 index 0000000..9386a55 --- /dev/null +++ b/orchestrator/packages/core/src/hook-executor.spec.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi } from 'vitest' +import { executeHooks, HookExecutionContext } from './hook-executor.js' + +describe('Hook Executor - US-4', () => { + describe('Start hooks execution', () => { + it('should execute Start hooks in sequence before agent', async () => { + // Given an AGENT.md with hooks.Start section + const hooks = [ + { + name: 'validate-args', + scriptPath: '/hooks/validate-args.mjs', + timeout: 30000 + } + ] + + const context: HookExecutionContext = { + projectDir: '/test/project', + params: { language: 'python' }, + taskState: { language: { name: 'python' } } + } + + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + + // When the agent is dispatched + const results = await executeHooks(hooks, 'Start', context, mockExec) + + // Then each Start hook executes in declaration order + expect(results).toHaveLength(1) + expect(results[0].hookName).toBe('validate-args') + expect(results[0].exitCode).toBe(0) + }) + + it('should pass exit code 0 allows agent to proceed', async () => { + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }] + const context = { + projectDir: '/test', + params: {}, + taskState: {} + } + + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + + const results = await executeHooks(hooks, 'Start', context, mockExec) + + // Exit code 0 allows the agent to proceed + expect(results[0].shouldProceed).toBe(true) + }) + + it('should pass exit code 1 passes stdout as warning and continues', async () => { + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }] + const context = { + projectDir: '/test', + params: {}, + taskState: {} + } + + const mockExec = vi.fn().mockResolvedValue({ + exitCode: 1, + stdout: 'Warning: deprecated usage', + stderr: '' + }) + + const results = await executeHooks(hooks, 'Start', context, mockExec) + + // Exit code 1 passes hook stdout to agent as warning and continues + expect(results[0].exitCode).toBe(1) + expect(results[0].stdout).toBe('Warning: deprecated usage') + expect(results[0].shouldProceed).toBe(true) + }) + + it('should pass exit code 2 halts agent and marks UoW as failed', async () => { + const hooks = [{ name: 'validate-args', scriptPath: '/test.mjs', timeout: 30000 }] + const context = { + projectDir: '/test', + params: {}, + taskState: {} + } + + const mockExec = vi.fn().mockResolvedValue({ + exitCode: 2, + stdout: '', + stderr: 'Validation failed' + }) + + const results = await executeHooks(hooks, 'Start', context, mockExec) + + // Exit code 2 halts the agent, marks UoW as failed + expect(results[0].exitCode).toBe(2) + expect(results[0].shouldProceed).toBe(false) + expect(results[0].fatal).toBe(true) + }) + }) + + describe('Hook stdin format', () => { + it('should receive JSON with projectDir, params, taskState', async () => { + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }] + const context = { + projectDir: '/test/project', + params: { language: 'python' }, + taskState: { language: { name: 'python' } } + } + + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + + await executeHooks(hooks, 'Start', context, mockExec) + + // Hook receives JSON containing: projectDir, params, taskState + expect(mockExec).toHaveBeenCalledWith( + expect.objectContaining({ + stdin: expect.stringContaining('projectDir') + }) + ) + }) + + it('should NOT include rendered prompt in stdin', async () => { + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }] + const context = { + projectDir: '/test', + params: {}, + taskState: {} + } + + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + + await executeHooks(hooks, 'Start', context, mockExec) + + // The rendered prompt is NOT present in stdin + const callArgs = mockExec.mock.calls[0] + const stdin = callArgs[0].stdin + expect(stdin).not.toContain('prompt') + }) + }) + + describe('Hook timeout behavior', () => { + it('should use default timeout of 300000ms when not configured', async () => { + const hooks = [{ name: 'test', scriptPath: '/test.mjs' }] // no timeout + const context = { + projectDir: '/test', + params: {}, + taskState: {} + } + + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + + await executeHooks(hooks, 'Start', context, mockExec) + + // Default timeout of 300000ms (5 minutes) is applied + expect(mockExec).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: 300000 + }) + ) + }) + + it('should use configured timeout when specified', async () => { + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 120000 }] + const context = { + projectDir: '/test', + params: {}, + taskState: {} + } + + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + + await executeHooks(hooks, 'Start', context, mockExec) + + expect(mockExec).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: 120000 + }) + ) + }) + + it('should treat timeout as exit code 2', async () => { + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 100 }] + const context = { + projectDir: '/test', + params: {}, + taskState: {} + } + + const mockExec = vi.fn().mockRejectedValue(new Error('Timeout')) + + const results = await executeHooks(hooks, 'Start', context, mockExec) + + // Hook execution is terminated and treated as exit code 2 + expect(results[0].fatal).toBe(true) + expect(results[0].timedOut).toBe(true) + }) + }) + + describe('Stop hooks execution', () => { + it('should execute Stop hooks after agent finishes', async () => { + const hooks = [ + { name: 'check-new-tests', scriptPath: '/check.mjs', timeout: 30000 } + ] + const context = { + projectDir: '/test', + params: {}, + taskState: {} + } + + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + + const results = await executeHooks(hooks, 'Stop', context, mockExec) + + expect(results).toHaveLength(1) + expect(results[0].hookName).toBe('check-new-tests') + }) + + it('should trigger follow-up on exit code 1', async () => { + const hooks = [{ name: 'lint', scriptPath: '/lint.mjs', timeout: 30000 }] + const context = { + projectDir: '/test', + params: {}, + taskState: {} + } + + const mockExec = vi.fn().mockResolvedValue({ + exitCode: 1, + stdout: 'Lint errors found', + stderr: '' + }) + + const results = await executeHooks(hooks, 'Stop', context, mockExec) + + // Exit code 1 returns stdout to agent for remediation (follow-up loop) + expect(results[0].needsFollowUp).toBe(true) + expect(results[0].stdout).toBe('Lint errors found') + }) + }) +}) diff --git a/orchestrator/packages/core/src/hook-executor.ts b/orchestrator/packages/core/src/hook-executor.ts new file mode 100644 index 0000000..cbbdcbf --- /dev/null +++ b/orchestrator/packages/core/src/hook-executor.ts @@ -0,0 +1,106 @@ +import { HookSpec } from './types.js' + +export type HookExecutionContext = { + projectDir: string + params: Record + taskState: Record +} + +export type HookResult = { + hookName: string + exitCode: number + stdout: string + stderr: string + durationMs: number + shouldProceed: boolean + fatal: boolean + needsFollowUp: boolean + timedOut: boolean +} + +type HookExecFunction = ( + opts: { scriptPath: string; stdin: string; timeout: number } +) => Promise<{ exitCode: number; stdout: string; stderr: string }> + +const DEFAULT_HOOK_TIMEOUT = 300000 // 5 minutes in ms + +/** + * Execute hooks for a lifecycle phase (Start or Stop). + * US-4: Project Maintainer - Extend Agent with Hooks + * FR-10: Hook Contract + */ +export const executeHooks = async ( + hooks: HookSpec[], + lifecycle: 'Start' | 'Stop', + context: HookExecutionContext, + execFn: HookExecFunction +): Promise => { + const results: HookResult[] = [] + + for (const hook of hooks) { + const startTime = Date.now() + const timeout = hook.timeout ?? DEFAULT_HOOK_TIMEOUT + + // FR-10: stdin format - projectDir, params, taskState (no rendered prompt) + const stdin = JSON.stringify({ + projectDir: context.projectDir, + params: context.params, + taskState: context.taskState + }) + + try { + const { exitCode, stdout, stderr } = await execFn({ + scriptPath: hook.scriptPath, + stdin, + timeout + }) + + const durationMs = Date.now() - startTime + + // FR-10: Exit codes + // 0 = Success, proceed + // 1 = Recoverable error, pass stdout as context, continue + // 2 = Fatal error, halt, mark UoW as failed + const fatal = exitCode === 2 + const shouldProceed = exitCode !== 2 + const needsFollowUp = lifecycle === 'Stop' && exitCode === 1 + + results.push({ + hookName: hook.name, + exitCode, + stdout, + stderr, + durationMs, + shouldProceed, + fatal, + needsFollowUp, + timedOut: false + }) + + // If hook returned fatal error, stop processing further hooks + if (fatal) { + break + } + } catch (error) { + const durationMs = Date.now() - startTime + + // Hook execution exception - treat as fatal (exit code 2) + results.push({ + hookName: hook.name, + exitCode: 2, + stdout: '', + stderr: error instanceof Error ? error.message : String(error), + durationMs, + shouldProceed: false, + fatal: true, + needsFollowUp: false, + timedOut: true + }) + + // Stop processing further hooks on timeout/error + break + } + } + + return results +} diff --git a/orchestrator/packages/core/src/index.ts b/orchestrator/packages/core/src/index.ts new file mode 100644 index 0000000..eed58d5 --- /dev/null +++ b/orchestrator/packages/core/src/index.ts @@ -0,0 +1,10 @@ +export * from './types.js' +export * from './agent-parser.js' +export * from './validator.js' +export * from './hook-executor.js' +export * from './handlebars-renderer.js' +export * from './orchestrator.js' +export * from './task-state-store.js' +export * from './repo-installer.js' +export * from './memory-task-state-store.js' +export * from './memory-task-source.js' diff --git a/orchestrator/packages/core/src/memory-task-source.spec.ts b/orchestrator/packages/core/src/memory-task-source.spec.ts new file mode 100644 index 0000000..1a67a00 --- /dev/null +++ b/orchestrator/packages/core/src/memory-task-source.spec.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest' +import { MemoryTaskSource } from './memory-task-source.js' +import type { Task } from '@orchestrator/task-source' + +describe('Memory Task Source - US-2', () => { + describe('Task Source emits available tasks', () => { + it('should yield tasks via async iterator', async () => { + // Given a task is available for processing + const source = new MemoryTaskSource() + + const task: Task = { + id: 'task-1', + title: 'Test Task', + description: 'Test Description', + status: 'OPEN' as const, + tags: ['worker:reviewer'], + claimant: null, + createdAt: new Date(), + updatedAt: new Date(), + priority: 1 + } + + await source.addTask(task) + + // When the orchestrator polls the task source + const tasks: Task[] = [] + for await (const t of source.watchTasks()) { + tasks.push(t) + break // Get first task + } + + // Then the task source yields the task via its async iterator + expect(tasks).toHaveLength(1) + expect(tasks[0].id).toBe('task-1') + }) + + it('should handle concurrent polling without duplicates', async () => { + // US-2: Task Source supports concurrent polling + // Given two orchestrator instances poll simultaneously + const source = new MemoryTaskSource() + + const task: Task = { + id: 'task-1', + title: 'Test', + description: null, + status: 'OPEN' as const, + tags: ['worker:test'], + claimant: null, + createdAt: null, + updatedAt: null, + priority: 1 + } + + await source.addTask(task) + + // When both poll the task source + const tasks1: Task[] = [] + const tasks2: Task[] = [] + + const poll1 = (async () => { + for await (const t of source.watchTasks()) { + tasks1.push(t) + break + } + })() + + const poll2 = (async () => { + for await (const t of source.watchTasks()) { + tasks2.push(t) + break + } + })() + + await Promise.all([poll1, poll2]) + + // Then each task is yielded to at most one orchestrator + // (In memory implementation, first consumer wins) + expect(tasks1.length + tasks2.length).toBe(1) + }) + + it('should not yield tasks after they are completed', async () => { + // Given a task that has been completed + const source = new MemoryTaskSource() + + const task: Task = { + id: 'task-1', + title: 'Test', + description: null, + status: 'OPEN' as const, + tags: [], + claimant: null, + createdAt: null, + updatedAt: null, + priority: 1 + } + + await source.addTask(task) + await source.completeTask('task-1') + + // When the orchestrator polls + const tasks: Task[] = [] + for await (const t of source.watchTasks()) { + tasks.push(t) + break + } + + // Then completed tasks are not yielded + expect(tasks).toHaveLength(0) + }) + }) + + describe('Task lifecycle', () => { + it('should mark task as completed', async () => { + const source = new MemoryTaskSource() + + const task: Task = { + id: 'task-1', + title: 'Test', + description: null, + status: 'OPEN' as const, + tags: [], + claimant: null, + createdAt: null, + updatedAt: null, + priority: 1 + } + + await source.addTask(task) + const completed = await source.completeTask('task-1') + + expect(completed).toBe(true) + + const retrieved = await source.getTaskDetail('task-1') + expect(retrieved?.status).toBe('COMPLETED') + }) + + it('should mark task as failed', async () => { + const source = new MemoryTaskSource() + + const task: Task = { + id: 'task-1', + title: 'Test', + description: null, + status: 'OPEN' as const, + tags: [], + claimant: null, + createdAt: null, + updatedAt: null, + priority: 1 + } + + await source.addTask(task) + const failed = await source.failTask('task-1', 'Test error') + + expect(failed).toBe(true) + + const retrieved = await source.getTaskDetail('task-1') + expect(retrieved?.status).toBe('FAILED') + }) + + it('should retrieve task detail', async () => { + const source = new MemoryTaskSource() + + const task: Task = { + id: 'task-1', + title: 'Test Task', + description: 'Test Description', + status: 'OPEN' as const, + tags: ['worker:test'], + claimant: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + priority: 1 + } + + await source.addTask(task) + const detail = await source.getTaskDetail('task-1') + + expect(detail).toBeDefined() + expect(detail?.id).toBe('task-1') + expect(detail?.title).toBe('Test Task') + }) + }) +}) diff --git a/orchestrator/packages/core/src/memory-task-source.ts b/orchestrator/packages/core/src/memory-task-source.ts new file mode 100644 index 0000000..6578611 --- /dev/null +++ b/orchestrator/packages/core/src/memory-task-source.ts @@ -0,0 +1,143 @@ +import type { TaskSource, Task, TaskDetail, TaskStatus } from '@orchestrator/task-source' + +/** + * In-memory Task Source implementation. + * Useful for testing and single-process scenarios. + * + * Note: This implementation provides basic coordination for single-process scenarios. + * For distributed scenarios, use Redis, queue-based, or database-backed implementations. + */ +export class MemoryTaskSource implements TaskSource { + #tasks: Map + #pending: Set + #claimed: Set + + constructor() { + this.#tasks = new Map() + this.#pending = new Set() + this.#claimed = new Set() + } + + /** + * Add a task to the task source. + */ + async addTask(task: Task): Promise { + const taskDetail: TaskDetail = { + ...task, + dependencies: [], + notes: [], + acceptanceCriteria: [], + retro: [] + } + + this.#tasks.set(task.id, taskDetail) + this.#pending.add(task.id) + } + + /** + * Yield available tasks via async iterator. + * US-2: Task Source emits available tasks via async iterator + * + * This implementation provides basic coordination by tracking claimed tasks. + * In distributed scenarios, use proper locking or queue semantics. + */ + async *watchTasks(): AsyncGenerator { + // Poll for available tasks + // Continue while there are pending or claimed tasks (tasks being processed) + let iterations = 0 + const maxIterations = 10 // Prevent infinite loops in tests + + while (this.#pending.size > 0 || this.#claimed.size > 0) { + // Find first unclaimed pending task + for (const taskId of this.#pending) { + if (!this.#claimed.has(taskId)) { + const task = this.#tasks.get(taskId) + if (task && task.status === 'OPEN') { + // Mark as claimed + this.#claimed.add(taskId) + + // Update task status + task.status = 'IN_PROGRESS' as TaskStatus + task.claimant = 'memory-source' + + yield { + id: task.id, + title: task.title, + description: task.description, + status: task.status, + tags: task.tags, + claimant: task.claimant, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + priority: task.priority + } + + // Return after yielding one task + return + } + } + } + + // No tasks available, wait a bit before polling again + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Safety check for tests + iterations++ + if (iterations > maxIterations) { + break + } + } + } + + async getTaskDetail(taskId: string): Promise { + return this.#tasks.get(taskId) ?? null + } + + async completeTask(taskId: string): Promise { + const task = this.#tasks.get(taskId) + if (!task) { + return false + } + + task.status = 'COMPLETED' as TaskStatus + this.#pending.delete(taskId) + this.#claimed.delete(taskId) + + return true + } + + async failTask(taskId: string, error: string): Promise { + const task = this.#tasks.get(taskId) + if (!task) { + return false + } + + task.status = 'FAILED' as TaskStatus + task.notes.push({ + id: `error-${Date.now()}`, + content: error, + createdAt: new Date() + }) + + this.#pending.delete(taskId) + this.#claimed.delete(taskId) + + return true + } + + /** + * Get all tasks (useful for testing). + */ + getAllTasks(): TaskDetail[] { + return Array.from(this.#tasks.values()) + } + + /** + * Clear all tasks (useful for testing). + */ + clear(): void { + this.#tasks.clear() + this.#pending.clear() + this.#claimed.clear() + } +} diff --git a/orchestrator/packages/core/src/memory-task-state-store.spec.ts b/orchestrator/packages/core/src/memory-task-state-store.spec.ts new file mode 100644 index 0000000..5644aaf --- /dev/null +++ b/orchestrator/packages/core/src/memory-task-state-store.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import { MemoryTaskStateStore } from './memory-task-state-store.js' + +describe('Memory Task State Store', () => { + describe('Basic operations', () => { + it('should store and retrieve taskState', async () => { + const store = new MemoryTaskStateStore() + + // Given a taskState + const taskState = { + language: { name: 'python' }, + snapshotTests: { 'test.js': 'hash123' } + } + + // When saved + await store.saveTaskState('task-1', taskState) + + // Then it can be retrieved + const retrieved = await store.loadTaskState('task-1') + expect(retrieved).toEqual(taskState) + }) + + it('should return null for non-existent task', async () => { + const store = new MemoryTaskStateStore() + + const result = await store.loadTaskState('nonexistent') + expect(result).toBeNull() + }) + + it('should delete taskState', async () => { + const store = new MemoryTaskStateStore() + + await store.saveTaskState('task-1', { data: 'test' }) + await store.deleteTaskState('task-1') + + const result = await store.loadTaskState('task-1') + expect(result).toBeNull() + }) + + it('should initialize taskState', async () => { + const store = new MemoryTaskStateStore() + + const initialState = { language: 'python' } + const result = await store.initializeTaskState('task-1', initialState) + + expect(result).toBe(true) + + const retrieved = await store.loadTaskState('task-1') + expect(retrieved).toEqual(initialState) + }) + }) + + describe('Concurrency and atomicity', () => { + it('should handle concurrent writes to same task', async () => { + const store = new MemoryTaskStateStore() + + // Given concurrent writes + const write1 = store.saveTaskState('task-1', { version: 1 }) + const write2 = store.saveTaskState('task-1', { version: 2 }) + + // When both complete + await Promise.all([write1, write2]) + + // Then final state is one of the writes (last write wins in memory) + const result = await store.loadTaskState('task-1') + expect(result).toBeDefined() + }) + }) +}) diff --git a/orchestrator/packages/core/src/memory-task-state-store.ts b/orchestrator/packages/core/src/memory-task-state-store.ts new file mode 100644 index 0000000..3715b4d --- /dev/null +++ b/orchestrator/packages/core/src/memory-task-state-store.ts @@ -0,0 +1,59 @@ +import type { TaskStateStore } from './task-state-store.js' + +/** + * In-memory Task State Store implementation. + * Useful for testing and single-process scenarios. + * + * Note: This implementation is not persistent across process restarts. + * For production, use Redis, file-based, or database-backed implementations. + */ +export class MemoryTaskStateStore implements TaskStateStore { + #store: Map> + + constructor() { + this.#store = new Map() + } + + async loadTaskState(taskId: string): Promise | null> { + return this.#store.get(taskId) ?? null + } + + async saveTaskState( + taskId: string, + taskState: Record + ): Promise { + this.#store.set(taskId, { ...taskState }) + return true + } + + async deleteTaskState(taskId: string): Promise { + return this.#store.delete(taskId) + } + + async initializeTaskState( + taskId: string, + initialState: Record + ): Promise { + // Only initialize if not already present + if (!this.#store.has(taskId)) { + this.#store.set(taskId, { ...initialState }) + return true + } + return false + } + + /** + * Clear all stored task states. + * Useful for testing. + */ + clear(): void { + this.#store.clear() + } + + /** + * Get the number of stored task states. + */ + get size(): number { + return this.#store.size + } +} diff --git a/orchestrator/packages/core/src/orchestrator.spec.ts b/orchestrator/packages/core/src/orchestrator.spec.ts new file mode 100644 index 0000000..03ad9f4 --- /dev/null +++ b/orchestrator/packages/core/src/orchestrator.spec.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi } from 'vitest' +import { orchestrate } from './orchestrator.js' +import type { AgentDefinition, HookExecutionContext } from './types.js' +import type { Task, TaskSource, Engine, EngineResult } from '@orchestrator/task-source' + +describe('Orchestrator Lifecycle - FR-14', () => { + describe('US-3: Agent Operator - Dispatch agent on task', () => { + it('should execute full orchestration lifecycle successfully', async () => { + // Given a task is yielded from the task source + const task: Task = { + id: 'task-1', + title: 'Review code', + description: 'Review PR #123', + status: 'OPEN' as const, + tags: ['worker:reviewer'], + claimant: null, + createdAt: new Date(), + updatedAt: new Date(), + priority: 1 + } + + // And the agent is configured in the agent catalog + const agent: AgentDefinition = { + name: 'reviewer', + description: 'Code review agent', + tools: ['readFile', 'edit'], + toolClasses: [], + template: { parameters: { language: { name: 'string' } } }, + hooks: { Start: [], Stop: [] }, + promptBody: 'Review the {{language.name}} code.' + } + + const taskState = { language: { name: 'Python' } } + + const mockTaskSource: TaskSource = { + watchTasks: async function* () { yield task }, + getTaskDetail: vi.fn().mockResolvedValue(null), + completeTask: vi.fn().mockResolvedValue(true), + failTask: vi.fn().mockResolvedValue(true) + } + + const mockEngine: Engine = { + execute: vi.fn().mockResolvedValue({ + success: true, + skipFulfill: false, + lastMessage: 'Review complete', + stats: { + durationMs: 5000, + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 100, + cacheCreationTokens: 50, + totalCostUsd: 0.05, + numTurns: 3 + } + }) + } + + // When the orchestrator receives the task + const result = await orchestrate({ + task, + agent, + taskState, + taskSource: mockTaskSource, + engine: mockEngine, + projectDir: '/test/project' + }) + + // Then Start hooks are executed (none in this case) + // And the reviewer agent is invoked + expect(mockEngine.execute).toHaveBeenCalled() + + // And Stop hooks are executed (none in this case) + // And the task is marked complete + expect(result.outcome).toBe('completed') + expect(mockTaskSource.completeTask).toHaveBeenCalledWith('task-1') + }) + + it('should mark task as failed when taskState validation fails', async () => { + // Given a task's taskState fails agent schema validation + const task: Task = { + id: 'task-2', + title: 'Test', + description: null, + status: 'OPEN' as const, + tags: ['worker:test'], + claimant: null, + createdAt: null, + updatedAt: null, + priority: 1 + } + + const agent: AgentDefinition = { + name: 'test', + description: 'Test agent', + tools: [], + toolClasses: [], + template: { parameters: { language: { name: 'string' } } }, + hooks: { Start: [], Stop: [] }, + promptBody: 'Test' + } + + // Missing required language parameter + const taskState = {} + + const mockTaskSource: TaskSource = { + watchTasks: async function* () { yield task }, + getTaskDetail: vi.fn().mockResolvedValue(null), + completeTask: vi.fn().mockResolvedValue(true), + failTask: vi.fn().mockResolvedValue(true) + } + + const mockEngine: Engine = { + execute: vi.fn().mockResolvedValue({ + success: true, + skipFulfill: false, + lastMessage: 'Done', + stats: null + }) + } + + // When the orchestrator receives the task + const result = await orchestrate({ + task, + agent, + taskState, + taskSource: mockTaskSource, + engine: mockEngine, + projectDir: '/test/project' + }) + + // Then the task is marked as failed + expect(result.outcome).toBe('failed') + expect(mockTaskSource.failTask).toHaveBeenCalledWith('task-2', expect.stringContaining('Missing required parameter')) + expect(mockEngine.execute).not.toHaveBeenCalled() + }) + + it('should trigger follow-up when Stop hook returns exit code 1', async () => { + // Given Stop hooks that report issues (exit code 1) + const agent: AgentDefinition = { + name: 'test', + description: 'Test', + tools: [], + toolClasses: [], + template: { parameters: {} }, + hooks: { + Start: [], + Stop: [{ name: 'lint', scriptPath: '/lint.mjs', timeout: 30000 }] + }, + promptBody: 'Test' + } + + const task: Task = { + id: 'task-3', + title: 'Test', + description: null, + status: 'OPEN' as const, + tags: ['worker:test'], + claimant: null, + createdAt: null, + updatedAt: null, + priority: 1 + } + + const taskState = {} + + const mockTaskSource: TaskSource = { + watchTasks: async function* () { yield task }, + getTaskDetail: vi.fn().mockResolvedValue(null), + completeTask: vi.fn().mockResolvedValue(true), + failTask: vi.fn().mockResolvedValue(true) + } + + const engineResult: EngineResult = { + success: true, + skipFulfill: false, + lastMessage: 'Done', + stats: null + } + + const mockEngine: Engine = { + execute: vi.fn() + .mockResolvedValueOnce(engineResult) + .mockResolvedValueOnce({ + ...engineResult, + lastMessage: 'Fixed lint issues' + }) + } + + const mockExec = vi.fn() + .mockResolvedValueOnce({ exitCode: 1, stdout: 'Lint errors', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + + // When orchestrating + const result = await orchestrate({ + task, + agent, + taskState, + taskSource: mockTaskSource, + engine: mockEngine, + projectDir: '/test/project', + hookExec: mockExec + }) + + // Then follow-up is triggered (engine called twice) + expect(mockEngine.execute).toHaveBeenCalledTimes(2) + expect(result.outcome).toBe('completed') + }) + }) +}) diff --git a/orchestrator/packages/core/src/orchestrator.ts b/orchestrator/packages/core/src/orchestrator.ts new file mode 100644 index 0000000..c7f8ed3 --- /dev/null +++ b/orchestrator/packages/core/src/orchestrator.ts @@ -0,0 +1,198 @@ +import type { AgentDefinition } from './types.js' +import { validateTaskState } from './validator.js' +import { executeHooks, type HookExecutionContext } from './hook-executor.js' +import { renderPrompt } from './handlebars-renderer.js' +import type { Task, TaskSource } from '@orchestrator/task-source' +import type { Engine, EngineContext, EngineResult } from '@orchestrator/engine' + +type OrchestrationResult = { + outcome: 'completed' | 'failed' | 'halted' + telemetry?: { + durationMs: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number + totalCostUsd: number + numTurns: number + } + error?: string +} + +type HookExecFn = ( + opts: { scriptPath: string; stdin: string; timeout: number } +) => Promise<{ exitCode: number; stdout: string; stderr: string }> + +type OrchestrateOptions = { + task: Task + agent: AgentDefinition + taskState: Record + taskSource: TaskSource + engine: Engine + projectDir: string + hookExec?: HookExecFn +} + +/** + * Execute the full orchestration lifecycle for a task. + * FR-14: Orchestration Lifecycle + * US-3: Agent Operator - Dispatch agent on task + */ +export const orchestrate = async ( + options: OrchestrateOptions +): Promise => { + const { task, agent, taskState, taskSource, engine, projectDir, hookExec } = options + + const startTime = Date.now() + let totalTelemetry: EngineResult['stats'] = null + let numTurns = 0 + + // FR-14 step 4: Execute Start hooks with taskState + const hookContext: HookExecutionContext = { + projectDir, + params: taskState, + taskState + } + + const defaultHookExec: HookExecFn = async () => ({ exitCode: 0, stdout: '', stderr: '' }) + + const execFn = hookExec ?? defaultHookExec + + const startHookResults = await executeHooks(agent.hooks.Start, 'Start', hookContext, execFn) + + // FR-14 step 5: If any hook exits with code 2: mark UoW as failed, notify task source + for (const hook of startHookResults) { + if (hook.fatal) { + await taskSource.failTask(task.id, `Start hook ${hook.hookName} failed: ${hook.stderr}`) + return { outcome: 'failed', error: hook.stderr } + } + } + + // FR-14 step 6: Validate taskState against template.parameters + const validation = validateTaskState(taskState, agent.template.parameters) + + if (!validation.valid) { + // FR-14 step 7: If validation fails: mark UoW as failed, notify task source + const error = validation.errors.join('; ') + await taskSource.failTask(task.id, error) + return { outcome: 'failed', error } + } + + // Extract params from taskState for Handlebars rendering + const params = taskState + + // FR-14 step 8: Render Handlebars prompt with taskState values + const renderedPrompt = renderPrompt(agent.promptBody, taskState) + + // FR-14 step 9: Build EngineContext and execute engine + const engineContext: EngineContext = { + taskId: task.id, + workingDir: projectDir, + agentName: agent.name, + verbose: false + } + + // Main execution loop (handles follow-ups) + let maxFollowUps = 10 // Prevent infinite loops + let lastMessage = '' + + while (maxFollowUps-- > 0) { + numTurns++ + + // Execute engine + const engineResult: EngineResult = await engine.execute({ + ...engineContext, + // Pass rendered prompt and any follow-up context + }) + + // Accumulate telemetry + if (engineResult.stats) { + if (!totalTelemetry) { + totalTelemetry = { ...engineResult.stats } + } else { + totalTelemetry.durationMs += engineResult.stats.durationMs + totalTelemetry.inputTokens += engineResult.stats.inputTokens + totalTelemetry.outputTokens += engineResult.stats.outputTokens + totalTelemetry.cacheReadTokens += engineResult.stats.cacheReadTokens + totalTelemetry.cacheCreationTokens += engineResult.stats.cacheCreationTokens + totalTelemetry.totalCostUsd += engineResult.stats.totalCostUsd + totalTelemetry.numTurns += engineResult.stats.numTurns + } + } + + lastMessage = engineResult.lastMessage || lastMessage + + // FR-14 step 11: Execute Stop hooks + const stopHookResults = await executeHooks(agent.hooks.Stop, 'Stop', hookContext, execFn) + + let needsFollowUp = false + let followUpMessage = '' + + // FR-14 step 12: If any Stop hook exits with code 1: loop back to step 9 + for (const hook of stopHookResults) { + if (hook.needsFollowUp) { + needsFollowUp = true + followUpMessage = hook.stdout + break + } + + // FR-14 step 13: If any Stop hook exits with code 2: mark UoW as failed + if (hook.fatal) { + await taskSource.failTask(task.id, `Stop hook ${hook.hookName} failed: ${hook.stderr}`) + return { outcome: 'failed', error: hook.stderr } + } + } + + if (!needsFollowUp) { + break + } + + // FR-14 step 12: loop back to step 9 with follow-up message + // Follow-up: execute engine again with hook output as context + if (engine.sendFollowUp) { + const followUpResult = await engine.sendFollowUp(followUpMessage) + if (followUpResult.stats) { + if (!totalTelemetry) { + totalTelemetry = { ...followUpResult.stats } + } else { + totalTelemetry.durationMs += followUpResult.stats.durationMs + totalTelemetry.inputTokens += followUpResult.stats.inputTokens + totalTelemetry.outputTokens += followUpResult.stats.outputTokens + totalTelemetry.cacheReadTokens += followUpResult.stats.cacheReadTokens + totalTelemetry.cacheCreationTokens += followUpResult.stats.cacheCreationTokens + totalTelemetry.totalCostUsd += followUpResult.stats.totalCostUsd + totalTelemetry.numTurns += followUpResult.stats.numTurns + } + } + numTurns++ + // Continue to next iteration to run Stop hooks again + } else { + // No sendFollowUp method - continue loop to execute again + // Continue to next iteration which will call execute again + } + } + + // FR-14 step 14: Save final taskState to Task State Store (omitted - uses Task State Store plugin) + // FR-14 step 15: Mark task as complete via Task Source + await taskSource.completeTask(task.id) + + // FR-14 step 16: Append completion note with telemetry + const durationMs = Date.now() - startTime + + return { + outcome: 'completed', + telemetry: totalTelemetry ? { + ...totalTelemetry, + durationMs, + numTurns + } : { + durationMs, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + numTurns + } + } +} diff --git a/orchestrator/packages/core/src/repo-installer.spec.ts b/orchestrator/packages/core/src/repo-installer.spec.ts new file mode 100644 index 0000000..474dbd0 --- /dev/null +++ b/orchestrator/packages/core/src/repo-installer.spec.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi } from 'vitest' +import { installRepoScripts } from './repo-installer.js' +import { mkdir, writeFile, readFile, stat } from 'node:fs/promises' + +vi.mock('node:fs/promises') + +describe('Repo Script Installer - US-5', () => { + describe('First run installs repo scripts into working repository', () => { + it('should hard-copy repo scripts to .ai//hooks/.d/.mjs', async () => { + // Given an orchestrator program with built-in agents that have repo scripts + const agents = [ + { + name: 'test-agent', + hooks: { + Start: [ + { name: 'validate-args', scriptPath: 'hooks/Start.d/validate-args.mjs', isRepoScript: true } + ], + Stop: [ + { name: 'check-new-tests', scriptPath: 'hooks/Stop.d/check-new-tests.mjs', isRepoScript: true } + ] + } + } + ] + + const mockScriptContent = 'export default async () => { return { exitCode: 0 } }' + + vi.mocked(readFile).mockResolvedValue(mockScriptContent) + vi.mocked(stat).mockRejectedValue(new Error('File not found')) + vi.mocked(mkdir).mockResolvedValue(undefined) + vi.mocked(writeFile).mockResolvedValue(undefined) + + // When the orchestrator runs against the working repository for the first time + await installRepoScripts('/test/project', agents, '/orchestrator/packages') + + // Then each repo script is hard-copied to .ai//hooks/.d/.mjs + expect(writeFile).toHaveBeenCalledWith( + '/test/project/.ai/test-agent/hooks/Start.d/validate-args.mjs', + mockScriptContent, + 'utf-8' + ) + expect(writeFile).toHaveBeenCalledWith( + '/test/project/.ai/test-agent/hooks/Stop.d/check-new-tests.mjs', + mockScriptContent, + 'utf-8' + ) + }) + + it('should create no symlinks - only hard copies', async () => { + const agents = [ + { + name: 'test-agent', + hooks: { + Start: [{ name: 'test', scriptPath: 'hooks/Start.d/test.mjs', isRepoScript: true }], + Stop: [] + } + } + ] + + vi.mocked(readFile).mockResolvedValue('content') + vi.mocked(stat).mockRejectedValue(new Error('not found')) + + await installRepoScripts('/test/project', agents, '/orchestrator/packages') + + // Verify copy was made (not symlink) + expect(writeFile).toHaveBeenCalled() + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining('.ai'), + expect.any(String), + 'utf-8' + ) + }) + + it('should log each installed path', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const agents = [ + { + name: 'test-agent', + hooks: { + Start: [{ name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }], + Stop: [] + } + } + ] + + vi.mocked(readFile).mockResolvedValue('content') + vi.mocked(stat).mockRejectedValue(new Error('not found')) + + await installRepoScripts('/test/project', agents, '/orchestrator/packages') + + // Then the orchestrator logs each installed path + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('test-agent/hooks/Start.d/validate.mjs') + ) + + consoleSpy.mockRestore() + }) + }) + + describe('Idempotent installation', () => { + it('should not overwrite existing scripts', async () => { + // Given a working repository that has already been initialized + // And a repo script already exists at the expected path + const agents = [ + { + name: 'test-agent', + hooks: { + Start: [{ name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }], + Stop: [] + } + } + ] + + vi.mocked(readFile).mockResolvedValue('new content') + vi.mocked(stat).mockResolvedValue({ isFile: () => true } as any) + + // Clear previous calls + vi.clearAllMocks() + + // When the orchestrator runs again + await installRepoScripts('/test/project', agents, '/orchestrator/packages') + + // Then the existing script is not overwritten + expect(writeFile).not.toHaveBeenCalled() + }) + + it('should log that script is already present', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const agents = [ + { + name: 'test-agent', + hooks: { + Start: [{ name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }], + Stop: [] + } + } + ] + + vi.mocked(readFile).mockResolvedValue('content') + vi.mocked(stat).mockResolvedValue({ isFile: () => true } as any) + + vi.clearAllMocks() + + await installRepoScripts('/test/project', agents, '/orchestrator/packages') + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Already present:') + ) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('test-agent/hooks/Start.d/validate.mjs') + ) + + consoleSpy.mockRestore() + }) + }) + + describe('Framework hooks not installed', () => { + it('should skip hooks where isRepoScript is false', async () => { + const agents = [ + { + name: 'test-agent', + hooks: { + Start: [ + { name: 'validate-args', scriptPath: 'hooks/Start.d/validate-args.mjs', isRepoScript: false } + ], + Stop: [] + } + } + ] + + vi.clearAllMocks() + + await installRepoScripts('/test/project', agents, '/orchestrator/packages') + + // Framework hooks run from the orchestrator's packages/ context + // They should not be installed to the working repo + expect(writeFile).not.toHaveBeenCalled() + }) + }) +}) diff --git a/orchestrator/packages/core/src/repo-installer.ts b/orchestrator/packages/core/src/repo-installer.ts new file mode 100644 index 0000000..f33b242 --- /dev/null +++ b/orchestrator/packages/core/src/repo-installer.ts @@ -0,0 +1,68 @@ +import { mkdir, writeFile, readFile, stat } from 'node:fs/promises' +import { join, resolve } from 'node:path' + +export type AgentWithRepoScripts = { + name: string + hooks: { + Start: Array<{ name: string; scriptPath: string; isRepoScript: boolean }> + Stop: Array<{ name: string; scriptPath: string; isRepoScript: boolean }> + } +} + +/** + * Install repo scripts to the working repository. + * US-5: First run installs repo scripts into the working repository + * FR-8: Working Repository Layout (after install) + * FR-9: Agent Package Layout + * + * @param projectDir - The working repository root + * @param agents - List of agents with their hooks + * @param orchestratorPackagesPath - Path to orchestrator packages/ directory + */ +export const installRepoScripts = async ( + projectDir: string, + agents: AgentWithRepoScripts[], + orchestratorPackagesPath: string +): Promise => { + for (const agent of agents) { + for (const lifecycle of ['Start', 'Stop'] as const) { + const hooks = lifecycle === 'Start' ? agent.hooks.Start : agent.hooks.Stop + + for (const hook of hooks) { + // Skip framework hooks - only install repo scripts + if (!hook.isRepoScript) { + continue + } + + // FR-9: Hook execution order within each .d/ directory: alphabetical by filename + // Target path: .ai//hooks/.d/.mjs + const targetDir = resolve(projectDir, '.ai', agent.name, 'hooks', `${lifecycle}.d`) + const targetPath = resolve(targetDir, `${hook.name}.mjs`) + + // Check if script already exists (US-5: idempotency) + try { + await stat(targetPath) + console.log(`Already present: ${targetPath}`) + continue + } catch { + // File doesn't exist, proceed with installation + } + + // FR-9: Repo scripts are provided in agent package at hooks/.d/.mjs + // Source path: //hooks/.d/.mjs + const sourcePath = resolve(orchestratorPackagesPath, agent.name, hook.scriptPath) + + // Read the source script content + const content = await readFile(sourcePath, 'utf-8') + + // Create target directory + await mkdir(targetDir, { recursive: true }) + + // FR-8: Repo scripts are hard-copied (never symlinked) + await writeFile(targetPath, content, 'utf-8') + + console.log(`Installed: ${targetPath}`) + } + } + } +} diff --git a/orchestrator/packages/core/src/task-state-store.spec.ts b/orchestrator/packages/core/src/task-state-store.spec.ts new file mode 100644 index 0000000..fa747a8 --- /dev/null +++ b/orchestrator/packages/core/src/task-state-store.spec.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest' +import type { TaskStateStore } from './task-state-store.js' + +describe('Task State Store - FR-3', () => { + describe('FR-3: Task State Store Interface', () => { + it('should implement loadTaskState method', async () => { + // US-11: Task State persistence across hook executions + const store: TaskStateStore = { + async loadTaskState(taskId: string) { + if (taskId === 'existing-task') { + return { snapshotTests: { 'test.js': 'hash123' } } + } + return null + }, + async saveTaskState(taskId: string, taskState: Record) { + return taskId === 'existing-task' + }, + async deleteTaskState(taskId: string) { + return true + }, + async initializeTaskState(taskId: string, initialState: Record) { + return true + } + } + + // When a new hook execution begins + const state = await store.loadTaskState('existing-task') + + // Then the Task State Store loads the persisted taskState + expect(state).toEqual({ snapshotTests: { 'test.js': 'hash123' } }) + }) + + it('should implement saveTaskState method', async () => { + const store: TaskStateStore = { + async loadTaskState() { + return null + }, + async saveTaskState(taskId: string, taskState: Record) { + return taskId === 'task-1' + }, + async deleteTaskState(taskId: string) { + return true + }, + async initializeTaskState(taskId: string, initialState: Record) { + return true + } + } + + // Given a Start hook that writes data into taskState + const newState = { snapshotTests: { 'test.js': 'hash123' } } + + // When the Start hook completes + const saved = await store.saveTaskState('task-1', newState) + + // Then the Task State Store persists the updated taskState + expect(saved).toBe(true) + }) + + it('should implement deleteTaskState method', async () => { + const store: TaskStateStore = { + async loadTaskState() { + return null + }, + async saveTaskState() { + return true + }, + async deleteTaskState(taskId: string) { + return taskId === 'task-1' + }, + async initializeTaskState() { + return true + } + } + + const deleted = await store.deleteTaskState('task-1') + expect(deleted).toBe(true) + }) + + it('should implement initializeTaskState method', async () => { + // US-11: Initialize taskState for a new task + const store: TaskStateStore = { + async loadTaskState() { + return null + }, + async saveTaskState() { + return true + }, + async deleteTaskState() { + return true + }, + async initializeTaskState(taskId: string, initialState: Record) { + return taskId !== 'error-task' + } + } + + // Given a task is received for the first time + const initialState = { language: 'python' } + + // When initializeTaskState is called + const initialized = await store.initializeTaskState('task-1', initialState) + + // Then it succeeds + expect(initialized).toBe(true) + }) + }) + + describe('US-11: Task State persistence across hook executions', () => { + it('should allow Stop hook to read data written by Start hook', async () => { + // Given a Start hook that writes taskState.snapshotTests + const store: TaskStateStore = { + async loadTaskState() { + return { snapshotTests: { 'test.js': 'hash123' } } + }, + async saveTaskState() { + return true + }, + async deleteTaskState() { + return true + }, + async initializeTaskState() { + return true + } + } + + // When the Start hook completes + // And the Task State Store persists the updated taskState + // Then the Stop hook can read taskState.snapshotTests in a subsequent execution + const state = await store.loadTaskState('task-1') + expect(state?.snapshotTests).toEqual({ 'test.js': 'hash123' }) + }) + }) +}) diff --git a/orchestrator/packages/core/src/task-state-store.ts b/orchestrator/packages/core/src/task-state-store.ts new file mode 100644 index 0000000..33f58dc --- /dev/null +++ b/orchestrator/packages/core/src/task-state-store.ts @@ -0,0 +1,32 @@ +/** + * FR-3: Task State Store Interface + * US-11: Task State persistence across hook executions + * + * Task State Store implementations MUST be thread-safe and support concurrent access. + * Each operation MUST be atomic. The orchestrator does not implement additional consistency mechanisms. + */ +export type TaskStateStore = { + /** + * Load taskState for a task. + * US-11: When a new hook execution begins, the Task State Store loads the persisted taskState. + */ + loadTaskState: (taskId: string) => Promise | null> + + /** + * Persist taskState for a task. + * US-11: When a hook completes successfully, the save operation persists taskState. + * The save operation is atomic - either entire taskState is persisted or none is. + */ + saveTaskState: (taskId: string, taskState: Record) => Promise + + /** + * Delete taskState for a task. + */ + deleteTaskState: (taskId: string) => Promise + + /** + * Initialize taskState for a new task. + * US-11: When a task is received for the first time, initializeTaskState is called. + */ + initializeTaskState: (taskId: string, initialState: Record) => Promise +} diff --git a/orchestrator/packages/core/src/types.ts b/orchestrator/packages/core/src/types.ts new file mode 100644 index 0000000..4b470c1 --- /dev/null +++ b/orchestrator/packages/core/src/types.ts @@ -0,0 +1,26 @@ +// FR-4: Agent Definition File types + +export type AgentDefinition = { + name: string + description: string + tools: string[] + toolClasses: string[] + template: Template + hooks: Hooks + promptBody: string +} + +export type Template = { + parameters: Record +} + +export type Hooks = { + Start: HookSpec[] + Stop: HookSpec[] +} + +export type HookSpec = { + name: string + scriptPath: string + timeout?: number +} diff --git a/orchestrator/packages/core/src/validator.spec.ts b/orchestrator/packages/core/src/validator.spec.ts new file mode 100644 index 0000000..394f426 --- /dev/null +++ b/orchestrator/packages/core/src/validator.spec.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest' +import { validateTaskState } from './validator.js' + +describe('Template Parameters Validator - US-6', () => { + describe('Required parameter validation', () => { + it('should pass when all required parameters are present and non-empty', () => { + // Given a Level 3 agent with a declared template.parameters schema + const schema = { + language: { name: 'string' }, + testFramework: { name: 'string' }, + testStyle: { name: 'string' } + } + + // And a UoW whose taskState satisfies all required parameters + const taskState = { + language: { name: 'python', prompt: 'Write Python code' }, + testFramework: { name: 'pytest', prompt: 'Use pytest' }, + testStyle: { name: 'gherkin', prompt: 'BDD style' } + } + + // When the orchestrator dispatches + const result = validateTaskState(taskState, schema) + + // Then validation passes + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + }) + + it('should fail when required parameter is missing', () => { + // Given a UoW whose taskState is missing a required parameter + const schema = { + language: { name: 'string' }, + testFramework: { name: 'string' } + } + + const taskState = { + language: { name: 'python' } + // testFramework is missing + } + + // When validate-args runs + const result = validateTaskState(taskState, schema) + + // Then validation fails + expect(result.valid).toBe(false) + expect(result.errors).toContain('Missing required parameter: testFramework') + }) + + it('should fail when required field is empty string', () => { + // Given a UoW taskState where a required field is present but set to empty string + const schema = { + language: { name: 'string' } + } + + const taskState = { + language: { name: '' } + } + + // When validate-args runs + const result = validateTaskState(taskState, schema) + + // Then validation fails as if the field were absent + expect(result.valid).toBe(false) + expect(result.errors).toContain('Missing required parameter: language.name') + }) + }) + + describe('Optional parameter validation (ending with ?)', () => { + it('should pass when optional parameter is absent', () => { + // Given a template parameter declared as optional (name ends with ?) + const schema = { + 'context?': { + prDescription: 'string', + 'additionalNotes?': 'string' + } + } + + // When taskState omits the optional parameter entirely + const taskState = {} + + // Then no validation error is raised + const result = validateTaskState(taskState, schema) + expect(result.valid).toBe(true) + }) + + it('should pass when optional parameter is provided with required sub-fields', () => { + // Given a template parameter declared as optional + const schema = { + 'context?': { + prDescription: 'string', + 'additionalNotes?': 'string' + } + } + + // When taskState provides that optional parameter + const taskState = { + context: { + prDescription: 'Fix bug in auth', + additionalNotes: 'Urgent' + } + } + + // Then all non-? sub-fields must be present and non-empty + const result = validateTaskState(taskState, schema) + expect(result.valid).toBe(true) + }) + + it('should fail when optional parameter is provided but missing required sub-field', () => { + // Given a template parameter that is an optional object + const schema = { + 'context?': { + prDescription: 'string', + 'additionalNotes?': 'string' + } + } + + // And the UoW taskState provides that object + // But the object is missing a required sub-field + const taskState = { + context: { + // prDescription is missing (required) + additionalNotes: 'Some notes' + } + } + + // When validate-args runs + const result = validateTaskState(taskState, schema) + + // Then validation fails identifying the missing sub-field by its dot-notation path + expect(result.valid).toBe(false) + expect(result.errors).toContain('Missing required parameter: context.prDescription') + }) + }) + + describe('Nested parameter validation', () => { + it('should validate nested required parameters', () => { + const schema = { + language: { + name: 'string', + version: 'string' + } + } + + const taskState = { + language: { + name: 'python', + // version is missing + } + } + + const result = validateTaskState(taskState, schema) + expect(result.valid).toBe(false) + expect(result.errors).toContain('Missing required parameter: language.version') + }) + }) +}) diff --git a/orchestrator/packages/core/src/validator.ts b/orchestrator/packages/core/src/validator.ts new file mode 100644 index 0000000..cc0fd8b --- /dev/null +++ b/orchestrator/packages/core/src/validator.ts @@ -0,0 +1,86 @@ +export type ValidationResult = { + valid: boolean + errors: string[] +} + +/** + * Validate taskState against template.parameters schema. + * FR-5: template.parameters Schema Rules + * US-6: Dispatch an agent with a pre-populated unit of work + */ +export const validateTaskState = ( + taskState: Record, + schema: Record +): ValidationResult => { + const errors: string[] = [] + + const validateValue = ( + value: unknown, + schemaNode: unknown, + path: string + ): void => { + // If schemaNode is not an object, it's a scalar type hint - just check existence + if (typeof schemaNode !== 'object' || schemaNode === null) { + if (value === undefined || value === null || value === '') { + errors.push(`Missing required parameter: ${path}`) + } + return + } + + // Schema node is an object - validate recursively + const schemaObj = schemaNode as Record + + // If value is missing/null/empty, check if path is optional + if (value === undefined || value === null || value === '') { + return // Handled by parent check + } + + // Value exists - validate its structure + if (typeof value !== 'object' || value === null) { + // Value is scalar but schema expects object - already caught above + return + } + + const valueObj = value as Record + + // Recursively validate each schema key + for (const [key, subSchema] of Object.entries(schemaObj)) { + const isOptional = key.endsWith('?') + const baseKey = isOptional ? key.slice(0, -1) : key + const fullPath = path ? `${path}.${baseKey}` : baseKey + + // Check if the key exists in value (with or without ? suffix) + const subValue = valueObj[baseKey] ?? valueObj[key] + + if (subValue === undefined || subValue === null || subValue === '') { + if (!isOptional) { + errors.push(`Missing required parameter: ${fullPath}`) + } + } else { + validateValue(subValue, subSchema, fullPath) + } + } + } + + // Validate each top-level schema parameter + for (const [key, schemaNode] of Object.entries(schema)) { + const isOptional = key.endsWith('?') + const baseKey = isOptional ? key.slice(0, -1) : key + + // Check if parameter exists in taskState (with or without ? suffix) + const value = taskState[baseKey] ?? taskState[key] + + if (value === undefined || value === null || value === '') { + if (!isOptional) { + errors.push(`Missing required parameter: ${baseKey}`) + } + } else { + validateValue(value, schemaNode, baseKey) + } + } + + return { + valid: errors.length === 0, + errors + } +} diff --git a/orchestrator/packages/core/tsconfig.json b/orchestrator/packages/core/tsconfig.json new file mode 100644 index 0000000..bec40ab --- /dev/null +++ b/orchestrator/packages/core/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + }, + "references": [ + { + "path": "../task-source" + }, + { + "path": "../engine" + } + ], + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/orchestrator/packages/core/vite.config.ts b/orchestrator/packages/core/vite.config.ts new file mode 100644 index 0000000..6676826 --- /dev/null +++ b/orchestrator/packages/core/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' + +export default defineConfig({ + plugins: [ + dts({ + tsconfigPath: './tsconfig.json', + rollupTypes: true, + }), + ], + build: { + lib: { + entry: './src/index.ts', + name: 'Core', + formats: ['es'], + fileName: 'index', + }, + rollupOptions: { + external: ['@orchestrator/engine', '@orchestrator/task-source', 'gray-matter'], + output: { + globals: { + '@orchestrator/engine': 'Engine', + '@orchestrator/task-source': 'TaskSource', + 'gray-matter': 'grayMatter', + }, + }, + }, + target: 'esnext', + emptyOutDir: true, + }, +}) diff --git a/orchestrator/packages/engine-claude-code/pyproject.toml b/orchestrator/packages/engine-claude-code/pyproject.toml deleted file mode 100644 index 3350962..0000000 --- a/orchestrator/packages/engine-claude-code/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -[project] -name = "engine-claude-code" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [ - "interface-engine", - "claude-agent-sdk", - "watchdog", -] - -[tools.uv.sources] -interface-engine = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/engine_claude_code"] diff --git a/orchestrator/packages/engine-claude-code/src/engine_claude_code/__init__.py b/orchestrator/packages/engine-claude-code/src/engine_claude_code/__init__.py deleted file mode 100644 index e7ac670..0000000 --- a/orchestrator/packages/engine-claude-code/src/engine_claude_code/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from engine_claude_code.config import ClaudeCodeEngineConfig -from engine_claude_code.sdk_runner import ClaudeCodeEngine - -__all__ = ["ClaudeCodeEngine", "ClaudeCodeEngineConfig"] diff --git a/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/__init__.py b/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/__init__.py deleted file mode 100644 index c3babcd..0000000 --- a/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from engine_claude_code.agent_catalog.loader import AgentRegistry -from engine_claude_code.agent_catalog.types import AgentEntry, AgentHooks, HookSpec -from engine_claude_code.agent_catalog.parser import parse_agent_file - -__all__ = ["AgentRegistry", "AgentEntry", "AgentHooks", "HookSpec", "parse_agent_file"] diff --git a/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/loader.py b/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/loader.py deleted file mode 100644 index d534b65..0000000 --- a/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/loader.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Agent registry — loads and caches agent definitions from .md files.""" - -import logging -import threading -from dataclasses import dataclass, field -from pathlib import Path - -from engine_claude_code.agent_catalog.parser import parse_agent_file -from engine_claude_code.agent_catalog.types import AgentEntry - -logger = logging.getLogger(__name__) - -# Default agents directories -CLAUDE_AGENTS_DIR = Path.home() / ".claude" / "agents" - - -@dataclass -class AgentRegistry: - """Registry for agent definitions.""" - - _agents: dict[str, AgentEntry] = field(default_factory=dict) - _lock: threading.RLock = field(default_factory=threading.RLock) - _watcher: object = field(default=None) - _agents_dirs: list[Path] = field(default_factory=list) - - def __init__(self, agents_dirs: list[Path] | None = None) -> None: - """Initialize the registry with custom agents directories. - - Args: - agents_dirs: List of directories to search for agent .md files. - Defaults to [CLAUDE_AGENTS_DIR]. - """ - self._agents_dirs = agents_dirs or [CLAUDE_AGENTS_DIR] - - def load_all(self) -> None: - """Load all agent .md files from the agents directories.""" - loaded = {} - for search_dir in self._agents_dirs: - if not search_dir.exists(): - continue - for path in search_dir.glob("*.md"): - try: - name, entry = parse_agent_file(path) - loaded[name] = entry - logger.debug("Loaded agent %r from %s", name, path) - except Exception as exc: - logger.warning("Failed to load agent from %s: %s", path, exc) - with self._lock: - self._agents = loaded - logger.info("Loaded %d agent(s): %s", len(loaded), list(loaded.keys())) - - def get(self, name: str) -> AgentEntry | None: - """Get an agent entry by name.""" - with self._lock: - return self._agents.get(name) - - def all(self) -> dict[str, AgentEntry]: - """Get all registered agents.""" - with self._lock: - return dict(self._agents) - - def start_watcher(self) -> None: - """Watch agents dirs for changes; reload on modify/create/delete.""" - try: - from watchdog.events import FileSystemEventHandler - from watchdog.observers import Observer - except ImportError: - logger.warning("watchdog not installed; file watching disabled") - return - - registry = self - - class _Handler(FileSystemEventHandler): - def on_any_event(self, event): - if event.is_directory: - return - src = Path(getattr(event, "src_path", "")) - dest = Path(getattr(event, "dest_path", "")) - if src.suffix == ".md" or dest.suffix == ".md": - logger.info("Agent file changed (%s), reloading", event.event_type) - registry.load_all() - - observer = Observer() - for watch_dir in self._agents_dirs: - if watch_dir.exists(): - observer.schedule(_Handler(), str(watch_dir), recursive=False) - observer.daemon = True - observer.start() - self._watcher = observer - logger.info("Watching %s for agent changes", self._agents_dirs) - - def stop_watcher(self) -> None: - """Stop the file watcher if running.""" - if self._watcher is not None: - self._watcher.stop() - self._watcher.join() - self._watcher = None diff --git a/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/parser.py b/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/parser.py deleted file mode 100644 index f2b3520..0000000 --- a/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/parser.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Parse agent markdown files into AgentEntry objects.""" - -import re -from pathlib import Path - -import yaml - -from engine_claude_code.agent_catalog.types import AgentEntry, AgentHooks, HookSpec - -_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) - - -def parse_agent_file(path: Path) -> tuple[str, AgentEntry]: - text = path.read_text(encoding="utf-8") - - m = _FRONTMATTER_RE.match(text) - if not m: - raise ValueError(f"No YAML frontmatter found in {path.name}") - - frontmatter_text = m.group(1) - body = text[m.end() :].strip() - - fields: dict = yaml.safe_load(frontmatter_text) or {} - - name = fields.get("name") or path.stem - description = fields.get("description") or "" - model = fields.get("model") or None - - tools_raw = fields.get("tools") or "" - if isinstance(tools_raw, list): - tools = [t.strip() for t in tools_raw if t] or None - elif tools_raw: - tools = [t.strip() for t in str(tools_raw).split(",") if t.strip()] or None - else: - tools = None - - hooks_block: dict = fields.get("hooks") or {} - rune_start_hooks = _extract_hook_commands(hooks_block.get("RuneStart")) - rune_stop_hooks = _extract_hook_commands(hooks_block.get("RuneStop")) - - from engine_claude_code.agent_catalog.types import AgentDefinition - - return name, AgentEntry( - definition=AgentDefinition( - description=description, - prompt=body, - tools=tools, - model=model, - ), - hooks=AgentHooks( - rune_start=rune_start_hooks, - rune_stop=rune_stop_hooks, - ), - ) - - -def _extract_hook_commands(event_block: object) -> list[HookSpec]: - """Extract HookSpec list from a hooks event block.""" - if not isinstance(event_block, list): - return [] - commands: list[HookSpec] = [] - for matcher_entry in event_block: - if not isinstance(matcher_entry, dict): - continue - for hook in matcher_entry.get("hooks") or []: - if not isinstance(hook, dict): - continue - if hook.get("type") == "command" and hook.get("command"): - commands.append(HookSpec(command=hook["command"])) - return commands diff --git a/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/types.py b/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/types.py deleted file mode 100644 index f14b5fe..0000000 --- a/orchestrator/packages/engine-claude-code/src/engine_claude_code/agent_catalog/types.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Agent catalog value objects.""" - -from dataclasses import dataclass -from typing import NamedTuple - - -class HookSpec(NamedTuple): - """A single hook command entry.""" - - command: str - - -class AgentHooks(NamedTuple): - """Hook commands attached to an agent, keyed by event name.""" - - rune_start: list[HookSpec] - rune_stop: list[HookSpec] - - -@dataclass -class AgentDefinition: - """Agent definition (prompt, tools, model).""" - - description: str - prompt: str - tools: list[str] | None - model: str | None - - -@dataclass -class AgentEntry: - """Bundled agent definition and its rune hooks.""" - - definition: AgentDefinition - hooks: AgentHooks diff --git a/orchestrator/packages/engine-claude-code/src/engine_claude_code/config.py b/orchestrator/packages/engine-claude-code/src/engine_claude_code/config.py deleted file mode 100644 index f9599b4..0000000 --- a/orchestrator/packages/engine-claude-code/src/engine_claude_code/config.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Configuration for Claude Code engine.""" - -from dataclasses import dataclass -from interface_engine.config import BaseEngineConfig - - -@dataclass(frozen=True) -class ClaudeCodeEngineConfig(BaseEngineConfig): - """Configuration for Claude Code CLI engine.""" - - claude_dir: str = "~/.claude" - verbose: bool = False - - @classmethod - def from_dict(cls, data: dict) -> "ClaudeCodeEngineConfig": - """Create config from dictionary (e.g., parsed YAML settings).""" - settings = data.get("settings") or {} - return cls( - settings=data.get("settings", {}), - claude_dir=settings.get("claude_dir", "~/.claude"), - verbose=settings.get("verbose", False), - ) diff --git a/orchestrator/packages/engine-claude-code/src/engine_claude_code/sdk_runner.py b/orchestrator/packages/engine-claude-code/src/engine_claude_code/sdk_runner.py deleted file mode 100644 index f076c5b..0000000 --- a/orchestrator/packages/engine-claude-code/src/engine_claude_code/sdk_runner.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Claude Code Engine — implements Engine interface using Claude Agent SDK.""" - -from __future__ import annotations - -import logging -import os -import sys -import time -from pathlib import Path -from typing import Callable - -from interface_engine import Engine, EngineContext, EngineResult, ExecutionStats - -from engine_claude_code.agent_catalog.types import AgentEntry - -logger = logging.getLogger(__name__) - -# ANSI color codes for worker prefixes -_WORKER_COLORS = [ - "\033[36m", # cyan - "\033[33m", # yellow - "\033[35m", # magenta - "\033[32m", # green - "\033[34m", # blue - "\033[91m", # bright red - "\033[96m", # bright cyan - "\033[93m", # bright yellow -] -_COLOR_RESET = "\033[0m" -_COLOR_BOLD = "\033[1m" - -_worker_color_cache: dict[str, str] = {} -_worker_color_counter = 0 - - -def _worker_color(worker_id: str) -> str: - global _worker_color_counter - if worker_id not in _worker_color_cache: - color = _WORKER_COLORS[_worker_color_counter % len(_WORKER_COLORS)] - _worker_color_cache[worker_id] = color - _worker_color_counter += 1 - return _worker_color_cache[worker_id] - - -def _worker_prefix(worker_id: str) -> str: - color = _worker_color(worker_id) - return f"{_COLOR_BOLD}{color}worker-{worker_id}{_COLOR_RESET}" - - -def _log_verbose(worker_id: str, msg: str, elapsed_ms: int | None = None) -> None: - prefix = _worker_prefix(worker_id) - ts = f"\033[2m+{elapsed_ms}ms\033[0m " if elapsed_ms is not None else "" - print(f"{prefix}: {ts}{msg}", file=sys.stderr) - - -class ClaudeCodeEngine(Engine): - """Claude Code CLI engine implementation.""" - - def __init__( - self, - entry: AgentEntry, - verbose: bool = False, - claude_dir: str = "~/.claude", - client_factory: Callable | None = None, - ) -> None: - self.entry = entry - self.verbose = verbose - self.claude_dir = claude_dir - self._client_factory = client_factory - self._client = None # set during async with - - def supports_agent(self, agent_name: str) -> bool: - """Check if this engine supports the given agent.""" - # This engine supports all agents that use the Claude Agent SDK - return True - - async def execute(self, context: EngineContext, task_data: dict) -> EngineResult: - """Execute a task using the Claude Agent SDK.""" - agent_def = self.entry.definition - task_id = context.task_id - - system_prompt = task_data.get("system_prompt", "") - prompt = task_data.get("prompt", "") - - try: - from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient - except ImportError: - logger.error("claude-agent-sdk not installed") - return EngineResult(success=False, skip_fulfill=False, last_message=None, stats=None) - - claude_dir_expanded = Path(self.claude_dir).expanduser() - original_config_dir = os.environ.get("CLAUDE_CONFIG_DIR") - os.environ["CLAUDE_CONFIG_DIR"] = str(claude_dir_expanded) - - try: - options = ClaudeAgentOptions( - cwd=context.working_dir, - tools=agent_def.tools or [], - allowed_tools=agent_def.tools or [], - permission_mode="dontAsk", - system_prompt=system_prompt, - model=agent_def.model or "sonnet", - ) - - _log_invocation(task_id, agent_def.model, agent_def.tools, self.verbose) - - if self._client_factory is not None: - client_context = self._client_factory() - else: - client_context = ClaudeSDKClient(options=options) - - start_ns = time.monotonic_ns() - - async with client_context as client: - self._client = client - await client.query(prompt) - result = await _drain_messages( - client, task_id, self.entry, self.verbose, start_ns=start_ns - ) - - self._client = None - return result - - finally: - if original_config_dir is not None: - os.environ["CLAUDE_CONFIG_DIR"] = original_config_dir - else: - os.environ.pop("CLAUDE_CONFIG_DIR", None) - - async def send_follow_up(self, message: str) -> EngineResult: - """Send a follow-up message to an active client session.""" - if self._client is None: - raise RuntimeError("send_follow_up called outside of active SDK session") - task_id = "follow-up" - start_ns = time.monotonic_ns() - await self._client.query(message) - return await _drain_messages( - self._client, task_id, self.entry, self.verbose, start_ns=start_ns - ) - - -async def _drain_messages( - client, - task_id: str, - entry: AgentEntry, - verbose: bool, - *, - start_ns: int, -) -> EngineResult: - """Drain messages from client until ResultMessage. Returns EngineResult.""" - try: - from claude_agent_sdk import AssistantMessage, TextBlock, ToolUseBlock - except ImportError: - logger.error("claude-agent-sdk not installed") - return EngineResult(success=False, skip_fulfill=False, last_message=None, stats=None) - - agent_name = entry.definition.description or task_id - last_ns = start_ns - last_assistant_message: str | None = None - - async for message in client.receive_messages(): - now_ns = time.monotonic_ns() - since_last_ms = (now_ns - last_ns) // 1_000_000 - last_ns = now_ns - - message_type_name = type(message).__name__ - - if message_type_name == "AssistantMessage": - text_parts = [] - if hasattr(message, "content"): - for block in message.content: - if hasattr(block, "text") and block.text: - text_parts.append(block.text) - last_assistant_message = "\n".join(text_parts) if text_parts else None - - if verbose: - _log_verbose_message( - task_id, message, AssistantMessage, TextBlock, ToolUseBlock, since_last_ms - ) - - if message_type_name in ("ResultMessage", "MockResultMessage"): - total_ms = (now_ns - start_ns) // 1_000_000 - usage = message.usage or {} - input_tokens = usage.get("input_tokens", 0) - output_tokens = usage.get("output_tokens", 0) - cache_read = usage.get("cache_read_input_tokens", 0) - cache_write = usage.get("cache_creation_input_tokens", 0) - cost = message.total_cost_usd or 0.0 - - if verbose: - token_parts = [f"in={input_tokens}", f"out={output_tokens}"] - if cache_read: - token_parts.append(f"cache_read={cache_read}") - if cache_write: - token_parts.append(f"cache_write={cache_write}") - _log_verbose( - task_id, - f"done turns={message.num_turns} total={total_ms}ms" - f" tokens={' '.join(token_parts)} cost=${cost:.4f}", - ) - else: - logger.info( - "Agent %r completed: turns=%d time=%dms tokens(in=%d out=%d) cost=$%.4f — %s", - agent_name, - message.num_turns, - total_ms, - input_tokens, - output_tokens, - cost, - (message.result or "")[:200], - ) - - stats = ExecutionStats( - duration_ms=total_ms, - input_tokens=input_tokens, - output_tokens=output_tokens, - cache_read_tokens=cache_read, - cache_creation_tokens=cache_write, - total_cost_usd=cost, - num_turns=message.num_turns, - ) - return EngineResult( - success=True, - skip_fulfill=False, - last_message=last_assistant_message, - stats=stats, - ) - - raise RuntimeError(f"Agent {agent_name!r} produced no ResultMessage") - - -def _log_invocation( - task_id: str, model: str | None, tools: list[str] | None, verbose: bool -) -> None: - model = model or "sonnet" - parts = ["claude", f"--model {model}", "--permission-mode dontAsk"] - if tools: - parts.append(f"--tools {','.join(tools)}") - parts.append(f"--allowedTools {','.join(tools)}") - cmd_str = " ".join(parts) - if verbose: - _log_verbose(task_id, f"exec: {cmd_str}", elapsed_ms=0) - else: - logger.info("Starting agent: %s", cmd_str) - - -def _log_verbose_message( - task_id: str, - message: object, - AssistantMessage, - TextBlock, - ToolUseBlock, - elapsed_ms: int, -) -> None: - if isinstance(message, AssistantMessage): - for block in message.content: - if isinstance(block, TextBlock) and block.text.strip(): - first_line = block.text.strip().splitlines()[0][:120] - _log_verbose(task_id, f"text: {first_line}", elapsed_ms=elapsed_ms) - elif isinstance(block, ToolUseBlock): - inp = block.input or {} - inp_summary = ", ".join(f"{k}={str(v)[:60]}" for k, v in list(inp.items())[:3]) - _log_verbose(task_id, f"tool: {block.name}({inp_summary})", elapsed_ms=elapsed_ms) diff --git a/orchestrator/packages/engine-claude-code/tests/test_claude_config_dir.py b/orchestrator/packages/engine-claude-code/tests/test_claude_config_dir.py deleted file mode 100644 index 269bd88..0000000 --- a/orchestrator/packages/engine-claude-code/tests/test_claude_config_dir.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Test that CLAUDE_CONFIG_DIR is properly used by the Claude Agent SDK.""" - -import asyncio -import json -import os -import tempfile -from pathlib import Path - - -async def test_claude_config_dir(): - """Verify CLAUDE_CONFIG_DIR causes SDK to use custom directory for settings/sessions.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - # Create settings.json in custom directory - settings_file = claude_dir / "settings.json" - settings_file.write_text(json.dumps({"custom": "test"})) - - # Set a pre-existing CLAUDE_CONFIG_DIR to verify it gets restored - original_value = "/original/claude/path" - os.environ["CLAUDE_CONFIG_DIR"] = original_value - - try: - from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient - - # Simulate what the engine does - set CLAUDE_CONFIG_DIR to custom dir - claude_dir_for_engine = claude_dir - os.environ["CLAUDE_CONFIG_DIR"] = str(claude_dir_for_engine) - - options = ClaudeAgentOptions( - cwd="/tmp", - permission_mode="dontAsk", - max_turns=1, - ) - - try: - async with ClaudeSDKClient(options=options) as client: - await client.query("Hello") - - # Verify sessions were created in custom directory - sessions_dir = claude_dir / "sessions" - assert sessions_dir.exists(), f"Sessions dir not found at {sessions_dir}" - - session_files = list(sessions_dir.glob("*.json")) - assert len(session_files) > 0, "No session files found" - - print("✓ CLAUDE_CONFIG_DIR works") - print(f" Sessions created in: {sessions_dir}") - print(f" Session files: {len(session_files)}") - finally: - # Simulate engine's cleanup - restore original value - os.environ["CLAUDE_CONFIG_DIR"] = original_value - - # Verify the original value was restored - current_value = os.environ.get("CLAUDE_CONFIG_DIR") - assert current_value == original_value, ( - f"Expected {original_value}, got {current_value}" - ) - print(f"✓ Original CLAUDE_CONFIG_DIR restored: {current_value}") - - finally: - os.environ.pop("CLAUDE_CONFIG_DIR", None) - - -if __name__ == "__main__": - asyncio.run(test_claude_config_dir()) diff --git a/orchestrator/packages/engine/package.json b/orchestrator/packages/engine/package.json new file mode 100644 index 0000000..004375a --- /dev/null +++ b/orchestrator/packages/engine/package.json @@ -0,0 +1,17 @@ +{ + "name": "@orchestrator/engine", + "version": "1.0.0", + "description": "Engine interface and types for Orchestrator Framework", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "vite build" + } +} diff --git a/orchestrator/packages/engine/src/index.ts b/orchestrator/packages/engine/src/index.ts new file mode 100644 index 0000000..11613c8 --- /dev/null +++ b/orchestrator/packages/engine/src/index.ts @@ -0,0 +1,3 @@ +export * from './types.js' +export * from './interface.js' +export * from './test-engine.js' diff --git a/orchestrator/packages/engine/src/interface.spec.ts b/orchestrator/packages/engine/src/interface.spec.ts new file mode 100644 index 0000000..3ea8ee9 --- /dev/null +++ b/orchestrator/packages/engine/src/interface.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest' +import { Engine } from './interface.js' +import { EngineContext, EngineResult } from './types.js' + +describe('Engine Interface', () => { + describe('FR-2: Engine Interface', () => { + it('should require execute method', async () => { + class MockEngine implements Engine { + async execute(context: EngineContext): Promise { + return { + success: true, + skipFulfill: false, + lastMessage: `Executed ${context.agentName}`, + stats: { + durationMs: 1000, + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0.01, + numTurns: 1 + } + } + } + } + + const engine = new MockEngine() + const context: EngineContext = { + taskId: 'task-1', + workingDir: '/test', + agentName: 'test-agent', + verbose: false + } + + const result = await engine.execute(context) + expect(result.success).toBe(true) + expect(result.lastMessage).toContain('test-agent') + }) + + it('should require optional sendFollowUp method', async () => { + class MockEngineWithFollowUp implements Engine { + async execute(): Promise { + return { + success: true, + skipFulfill: false, + lastMessage: 'Initial', + stats: null + } + } + + async sendFollowUp(message: string): Promise { + return { + success: true, + skipFulfill: false, + lastMessage: `Follow-up: ${message}`, + stats: null + } + } + } + + const engine = new MockEngineWithFollowUp() + + // sendFollowUp is optional - check if it exists + if ('sendFollowUp' in engine) { + const result = await engine.sendFollowUp('Fix the lint errors') + expect(result.lastMessage).toContain('Follow-up') + } + }) + + it('should allow engine without sendFollowUp', async () => { + class MockEngine implements Engine { + async execute(): Promise { + return { + success: true, + skipFulfill: false, + lastMessage: 'Done', + stats: null + } + } + } + + const engine: Engine = new MockEngine() + const result = await engine.execute() + + expect(result.success).toBe(true) + // sendFollowUp is optional, so engine doesn't need it + expect('sendFollowUp' in engine).toBe(false) + }) + }) +}) diff --git a/orchestrator/packages/engine/src/interface.ts b/orchestrator/packages/engine/src/interface.ts new file mode 100644 index 0000000..88fa986 --- /dev/null +++ b/orchestrator/packages/engine/src/interface.ts @@ -0,0 +1,10 @@ +import { EngineContext, EngineResult } from './types.js' + +// FR-2: Engine Interface +export type Engine = { + // Execute a task + execute: (context: EngineContext) => Promise + + // Optional method for follow-up execution + sendFollowUp?: (message: string) => Promise +} diff --git a/orchestrator/packages/engine/src/test-engine.spec.ts b/orchestrator/packages/engine/src/test-engine.spec.ts new file mode 100644 index 0000000..30624ce --- /dev/null +++ b/orchestrator/packages/engine/src/test-engine.spec.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest' +import { TestEngine } from './test-engine.js' +import type { EngineContext } from './types.js' + +describe('Test Engine', () => { + describe('Basic execution', () => { + it('should execute and return success result', async () => { + const engine = new TestEngine() + + const context: EngineContext = { + taskId: 'task-1', + workingDir: '/test/project', + agentName: 'test-agent', + verbose: false + } + + const result = await engine.execute(context) + + expect(result.success).toBe(true) + expect(result.lastMessage).toContain('complete') + expect(result.stats).toBeDefined() + expect(result.stats?.durationMs).toBeGreaterThanOrEqual(0) + }) + + it('should include execution telemetry', async () => { + const engine = new TestEngine() + + const context: EngineContext = { + taskId: 'task-1', + workingDir: '/test', + agentName: 'agent', + verbose: false + } + + const result = await engine.execute(context) + + expect(result.stats).toMatchObject({ + durationMs: expect.any(Number), + inputTokens: expect.any(Number), + outputTokens: expect.any(Number), + cacheReadTokens: expect.any(Number), + cacheCreationTokens: expect.any(Number), + totalCostUsd: expect.any(Number), + numTurns: expect.any(Number) + }) + }) + + it('should support follow-up execution', async () => { + const engine = new TestEngine() + + const followUpResult = await engine.sendFollowUp?.('Fix the lint errors') + + expect(followUpResult).toBeDefined() + expect(followUpResult?.success).toBe(true) + expect(followUpResult?.lastMessage).toContain('Follow-up') + }) + }) + + describe('Configurable behavior', () => { + it('should support custom response configuration', async () => { + const engine = new TestEngine({ + success: true, + lastMessage: 'Custom message', + simulateError: false + }) + + const context: EngineContext = { + taskId: 'task-1', + workingDir: '/test', + agentName: 'agent', + verbose: false + } + + const result = await engine.execute(context) + + expect(result.success).toBe(true) + expect(result.lastMessage).toContain('Custom message') + }) + + it('should simulate failures when configured', async () => { + const engine = new TestEngine({ + success: false, + lastMessage: 'Execution failed', + simulateError: false + }) + + const context: EngineContext = { + taskId: 'task-1', + workingDir: '/test', + agentName: 'agent', + verbose: false + } + + const result = await engine.execute(context) + + expect(result.success).toBe(false) + expect(result.lastMessage).toContain('Execution failed') + }) + + it('should simulate delays for realistic timing', async () => { + const engine = new TestEngine({ + simulateDelay: 100 + }) + + const context: EngineContext = { + taskId: 'task-1', + workingDir: '/test', + agentName: 'agent', + verbose: false + } + + const start = Date.now() + await engine.execute(context) + const duration = Date.now() - start + + expect(duration).toBeGreaterThanOrEqual(95) // Allow some timing variance + }) + }) +}) diff --git a/orchestrator/packages/engine/src/test-engine.ts b/orchestrator/packages/engine/src/test-engine.ts new file mode 100644 index 0000000..a3fd624 --- /dev/null +++ b/orchestrator/packages/engine/src/test-engine.ts @@ -0,0 +1,96 @@ +import type { Engine, EngineContext, EngineResult } from './types.js' + +export type TestEngineConfig = { + success?: boolean + lastMessage?: string + simulateError?: boolean + simulateDelay?: number // milliseconds + mockStats?: Partial +} + +/** + * Test Engine implementation for testing and development. + * Simulates engine execution with configurable behavior. + */ +export class TestEngine implements Engine { + #config: TestEngineConfig + + constructor(config: TestEngineConfig = {}) { + this.#config = { + success: true, + lastMessage: 'Test execution complete', + simulateError: false, + simulateDelay: 0, + mockStats: undefined, + ...config + } + } + + async execute(context: EngineContext): Promise { + // Apply simulated delay if configured + if (this.#config.simulateDelay && this.#config.simulateDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, this.#config.simulateDelay)) + } + + // Simulate error if configured + if (this.#config.simulateError) { + throw new Error('Simulated engine error') + } + + const startTime = Date.now() + + // Generate mock telemetry + const stats: EngineResult['stats'] = this.#config.mockStats ?? { + durationMs: this.#config.simulateDelay ?? 10, + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 10, + cacheCreationTokens: 5, + totalCostUsd: 0.005, + numTurns: 1 + } + + // Override duration based on actual execution time + if (!this.#config.mockStats) { + stats.durationMs = Date.now() - startTime + } + + return { + success: this.#config.success ?? true, + skipFulfill: false, + lastMessage: `${this.#config.lastMessage} (task: ${context.taskId}, agent: ${context.agentName})`, + stats + } + } + + async sendFollowUp(message: string): Promise { + // Apply simulated delay if configured + if (this.#config.simulateDelay && this.#config.simulateDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, this.#config.simulateDelay)) + } + + const stats: EngineResult['stats'] = { + durationMs: this.#config.simulateDelay ?? 10, + inputTokens: 50, + outputTokens: 25, + cacheReadTokens: 5, + cacheCreationTokens: 2, + totalCostUsd: 0.0025, + numTurns: 1 + } + + return { + success: this.#config.success ?? true, + skipFulfill: false, + lastMessage: `Follow-up: ${message}`, + stats + } + } + + /** + * Update engine configuration. + */ + setConfig(config: Partial): void { + this.#config = { ...this.#config, ...config } + } +} diff --git a/orchestrator/packages/engine/src/types.spec.ts b/orchestrator/packages/engine/src/types.spec.ts new file mode 100644 index 0000000..4ee8bde --- /dev/null +++ b/orchestrator/packages/engine/src/types.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { EngineResult, ExecutionStats, EngineContext } from './types.js' + +describe('Engine Types', () => { + describe('ExecutionStats', () => { + it('should contain all required telemetry fields', () => { + // FR-2: ExecutionStats MUST contain + const stats: ExecutionStats = { + durationMs: 5000, + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 100, + cacheCreationTokens: 50, + totalCostUsd: 0.05, + numTurns: 3 + } + + expect(stats.durationMs).toBe(5000) + expect(stats.inputTokens).toBe(1000) + expect(stats.outputTokens).toBe(500) + expect(stats.cacheReadTokens).toBe(100) + expect(stats.cacheCreationTokens).toBe(50) + expect(stats.totalCostUsd).toBe(0.05) + expect(stats.numTurns).toBe(3) + }) + }) + + describe('EngineResult', () => { + it('should contain success, skipFulfill, lastMessage, and stats', () => { + // FR-2: EngineResult MUST contain + const result: EngineResult = { + success: true, + skipFulfill: false, + lastMessage: 'Task completed', + stats: { + durationMs: 5000, + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0.03, + numTurns: 1 + } + } + + expect(result.success).toBe(true) + expect(result.skipFulfill).toBe(false) + expect(result.lastMessage).toBe('Task completed') + expect(result.stats).toBeDefined() + }) + + it('should allow null stats and lastMessage', () => { + const result: EngineResult = { + success: false, + skipFulfill: false, + lastMessage: null, + stats: null + } + + expect(result.success).toBe(false) + expect(result.lastMessage).toBeNull() + expect(result.stats).toBeNull() + }) + }) + + describe('EngineContext', () => { + it('should contain taskId, workingDir, agentName, and verbose', () => { + // FR-2: EngineContext MUST contain + const context: EngineContext = { + taskId: 'task-123', + workingDir: '/home/user/project', + agentName: 'reviewer', + verbose: true + } + + expect(context.taskId).toBe('task-123') + expect(context.workingDir).toBe('/home/user/project') + expect(context.agentName).toBe('reviewer') + expect(context.verbose).toBe(true) + }) + }) +}) diff --git a/orchestrator/packages/engine/src/types.ts b/orchestrator/packages/engine/src/types.ts new file mode 100644 index 0000000..ea1e3fb --- /dev/null +++ b/orchestrator/packages/engine/src/types.ts @@ -0,0 +1,26 @@ +// FR-2: EngineContext MUST contain +export type EngineContext = { + taskId: string + workingDir: string + agentName: string + verbose: boolean +} + +// FR-2: EngineResult MUST contain +export type EngineResult = { + success: boolean + skipFulfill: boolean + lastMessage: string | null + stats: ExecutionStats | null +} + +// FR-2: ExecutionStats MUST contain +export type ExecutionStats = { + durationMs: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number + totalCostUsd: number + numTurns: number +} diff --git a/orchestrator/packages/engine/tsconfig.json b/orchestrator/packages/engine/tsconfig.json new file mode 100644 index 0000000..49cc6d1 --- /dev/null +++ b/orchestrator/packages/engine/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/orchestrator/packages/engine/vite.config.ts b/orchestrator/packages/engine/vite.config.ts new file mode 100644 index 0000000..ce48f13 --- /dev/null +++ b/orchestrator/packages/engine/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' + +export default defineConfig({ + plugins: [ + dts({ + tsconfigPath: './tsconfig.json', + rollupTypes: true, + }), + ], + build: { + lib: { + entry: './src/index.ts', + name: 'Engine', + formats: ['es'], + fileName: 'index', + }, + rollupOptions: { + external: [], + output: { + globals: {}, + }, + }, + target: 'esnext', + emptyOutDir: true, + }, +}) diff --git a/orchestrator/packages/interface-engine/pyproject.toml b/orchestrator/packages/interface-engine/pyproject.toml deleted file mode 100644 index edee118..0000000 --- a/orchestrator/packages/interface-engine/pyproject.toml +++ /dev/null @@ -1,12 +0,0 @@ -[project] -name = "interface-engine" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/interface_engine"] diff --git a/orchestrator/packages/interface-engine/src/interface_engine/__init__.py b/orchestrator/packages/interface-engine/src/interface_engine/__init__.py deleted file mode 100644 index a66ef2a..0000000 --- a/orchestrator/packages/interface-engine/src/interface_engine/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from interface_engine.base import Engine -from interface_engine.config import BaseEngineConfig -from interface_engine.types import EngineContext, EngineResult, ExecutionStats - -__all__ = ["Engine", "EngineContext", "EngineResult", "ExecutionStats", "BaseEngineConfig"] diff --git a/orchestrator/packages/interface-engine/src/interface_engine/base.py b/orchestrator/packages/interface-engine/src/interface_engine/base.py deleted file mode 100644 index d526d5e..0000000 --- a/orchestrator/packages/interface-engine/src/interface_engine/base.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Abstract engine interface for task execution engines.""" - -from abc import ABC, abstractmethod -from interface_engine.types import EngineContext, EngineResult - - -class Engine(ABC): - """Abstract interface for task execution engines. - - An engine takes a task (rune) and executes it using some underlying - execution mechanism (e.g., Claude Code CLI, generic agent, etc.). - """ - - @abstractmethod - async def execute(self, context: EngineContext, task_data: dict) -> EngineResult: - """Execute a task and return the result. - - Args: - context: Execution context (task_id, working_dir, agent_name, verbose) - task_data: Raw task data (rune detail, description, etc.) - - Returns: - EngineResult with success status, skip_fulfill flag, and stats - """ - pass - - @abstractmethod - def supports_agent(self, agent_name: str) -> bool: - """Check if this engine supports the given agent. - - Args: - agent_name: Name of the agent to check - - Returns: - True if this engine can execute the agent - """ - pass diff --git a/orchestrator/packages/interface-engine/src/interface_engine/config.py b/orchestrator/packages/interface-engine/src/interface_engine/config.py deleted file mode 100644 index 0db5a8f..0000000 --- a/orchestrator/packages/interface-engine/src/interface_engine/config.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Base configuration types for engines.""" - -from dataclasses import dataclass, field -from typing import Any - - -@dataclass(frozen=True) -class BaseEngineConfig: - """Base configuration for engines. - - Subclasses should extend this with plugin-specific settings. - """ - - settings: dict[str, Any] = field(default_factory=dict) diff --git a/orchestrator/packages/interface-engine/src/interface_engine/types.py b/orchestrator/packages/interface-engine/src/interface_engine/types.py deleted file mode 100644 index 89c85e2..0000000 --- a/orchestrator/packages/interface-engine/src/interface_engine/types.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Core types for the engine interface.""" - -from dataclasses import dataclass -from typing import Optional - - -@dataclass(frozen=True) -class EngineContext: - """Context for engine execution.""" - - task_id: str - working_dir: str - agent_name: str - verbose: bool = False - - -@dataclass(frozen=True) -class ExecutionStats: - """Telemetry from a single engine execution.""" - - duration_ms: int - input_tokens: int - output_tokens: int - cache_read_tokens: int - cache_creation_tokens: int - total_cost_usd: float - num_turns: int - - def __add__(self, other: "ExecutionStats") -> "ExecutionStats": - """Accumulate stats across multiple executions.""" - return ExecutionStats( - duration_ms=self.duration_ms + other.duration_ms, - input_tokens=self.input_tokens + other.input_tokens, - output_tokens=self.output_tokens + other.output_tokens, - cache_read_tokens=self.cache_read_tokens + other.cache_read_tokens, - cache_creation_tokens=self.cache_creation_tokens + other.cache_creation_tokens, - total_cost_usd=self.total_cost_usd + other.total_cost_usd, - num_turns=self.num_turns + other.num_turns, - ) - - -@dataclass(frozen=True) -class EngineResult: - """Result from engine execution.""" - - success: bool - skip_fulfill: bool - last_message: Optional[str] - stats: Optional[ExecutionStats] diff --git a/orchestrator/packages/interface-tasks/pyproject.toml b/orchestrator/packages/interface-tasks/pyproject.toml deleted file mode 100644 index f1149b3..0000000 --- a/orchestrator/packages/interface-tasks/pyproject.toml +++ /dev/null @@ -1,12 +0,0 @@ -[project] -name = "interface-tasks" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/interface_tasks"] diff --git a/orchestrator/packages/interface-tasks/src/interface_tasks/__init__.py b/orchestrator/packages/interface-tasks/src/interface_tasks/__init__.py deleted file mode 100644 index 9464931..0000000 --- a/orchestrator/packages/interface-tasks/src/interface_tasks/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from interface_tasks.base import TaskSource -from interface_tasks.config import BaseTaskSourceConfig -from interface_tasks.types import Task, TaskDetail, TaskStatus - -__all__ = ["TaskSource", "Task", "TaskDetail", "TaskStatus", "BaseTaskSourceConfig"] diff --git a/orchestrator/packages/interface-tasks/src/interface_tasks/base.py b/orchestrator/packages/interface-tasks/src/interface_tasks/base.py deleted file mode 100644 index f9a9d47..0000000 --- a/orchestrator/packages/interface-tasks/src/interface_tasks/base.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Abstract task source interface for task providers.""" - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, AsyncIterator - -if TYPE_CHECKING: - from interface_tasks.types import Task, TaskDetail - - -class TaskSource(ABC): - """Abstract interface for task sources. - - A task source provides tasks (runes) to the orchestrator, - handles claiming/unclaiming, and reports completion. - """ - - @abstractmethod - async def watch_tasks(self) -> AsyncIterator["Task"]: - """Watch for ready tasks and yield them as they become available. - - Yields: - Task objects that are ready for execution - """ - pass - - @abstractmethod - async def get_task_detail(self, task_id: str) -> "TaskDetail": - """Get detailed information about a task. - - Args: - task_id: Unique task identifier - - Returns: - TaskDetail with full task information - """ - pass - - @abstractmethod - async def claim_task(self, task_id: str, claimant: str) -> bool: - """Claim a task for execution. - - Args: - task_id: Unique task identifier - claimant: Identifier for the claimant (e.g., worker ID) - - Returns: - True if task was successfully claimed - """ - pass - - @abstractmethod - async def unclaim_task(self, task_id: str) -> bool: - """Unclaim a task. - - Args: - task_id: Unique task identifier - - Returns: - True if task was successfully unclaimed - """ - pass - - @abstractmethod - async def complete_task(self, task_id: str) -> bool: - """Mark a task as completed. - - Args: - task_id: Unique task identifier - - Returns: - True if task was successfully marked complete - """ - pass diff --git a/orchestrator/packages/interface-tasks/src/interface_tasks/config.py b/orchestrator/packages/interface-tasks/src/interface_tasks/config.py deleted file mode 100644 index d19f413..0000000 --- a/orchestrator/packages/interface-tasks/src/interface_tasks/config.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Base configuration types for task sources.""" - -from dataclasses import dataclass, field -from typing import Any - - -@dataclass(frozen=True) -class BaseTaskSourceConfig: - """Base configuration for task sources. - - Subclasses should extend this with plugin-specific settings. - """ - - settings: dict[str, Any] = field(default_factory=dict) diff --git a/orchestrator/packages/interface-tasks/src/interface_tasks/types.py b/orchestrator/packages/interface-tasks/src/interface_tasks/types.py deleted file mode 100644 index 9bf91cc..0000000 --- a/orchestrator/packages/interface-tasks/src/interface_tasks/types.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Core types for the task source interface.""" - -from dataclasses import dataclass -from enum import Enum -from typing import Optional - - -class TaskStatus(str, Enum): - """Status of a task.""" - - OPEN = "open" - IN_PROGRESS = "in_progress" - COMPLETED = "completed" - FAILED = "failed" - CANCELLED = "cancelled" - - -@dataclass(frozen=True) -class Task: - """A task to be executed.""" - - id: str - title: str - status: TaskStatus - tags: list[str] - claimant: Optional[str] - raw_data: dict - - -@dataclass(frozen=True) -class DependencyRef: - """Reference to a dependency task.""" - - target_id: str - relationship: str - - -@dataclass(frozen=True) -class NoteEntry: - """Note attached to a task.""" - - text: str - - -@dataclass(frozen=True) -class ACEntry: - """Acceptance criteria entry.""" - - text: str - - -@dataclass(frozen=True) -class RetroEntry: - """Retrospective entry.""" - - text: str - - -@dataclass(frozen=True) -class TaskDetail: - """Detailed task information.""" - - task: Task - description: Optional[str] - dependencies: list[DependencyRef] - notes: list[NoteEntry] - acceptance_criteria: list[ACEntry] - retro: list[RetroEntry] diff --git a/orchestrator/packages/orchestrator/pyproject.toml b/orchestrator/packages/orchestrator/pyproject.toml deleted file mode 100644 index c65393b..0000000 --- a/orchestrator/packages/orchestrator/pyproject.toml +++ /dev/null @@ -1,20 +0,0 @@ -[project] -name = "orchestrator" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [ - "interface-engine", - "interface-tasks", - "pyyaml", -] - -[tools.uv.sources] -interface-engine = { workspace = true } -interface-tasks = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/orchestrator"] diff --git a/orchestrator/packages/orchestrator/src/orchestrator/__init__.py b/orchestrator/packages/orchestrator/src/orchestrator/__init__.py deleted file mode 100644 index 823dc31..0000000 --- a/orchestrator/packages/orchestrator/src/orchestrator/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from orchestrator.core.orchestrator import RuneOrchestrator - -__all__ = ["RuneOrchestrator"] diff --git a/orchestrator/packages/orchestrator/src/orchestrator/cli/__init__.py b/orchestrator/packages/orchestrator/src/orchestrator/cli/__init__.py deleted file mode 100644 index 1a0ad2e..0000000 --- a/orchestrator/packages/orchestrator/src/orchestrator/cli/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from orchestrator.cli.agent_entry import agent_main -from orchestrator.cli.config import find_project_root, is_verbose - -__all__ = ["agent_main", "find_project_root", "is_verbose"] diff --git a/orchestrator/packages/orchestrator/src/orchestrator/cli/agent_entry.py b/orchestrator/packages/orchestrator/src/orchestrator/cli/agent_entry.py deleted file mode 100644 index fc291f4..0000000 --- a/orchestrator/packages/orchestrator/src/orchestrator/cli/agent_entry.py +++ /dev/null @@ -1,95 +0,0 @@ -"""CLI entry point for agent.py — thin coordinator, exits with correct codes.""" - -from typing import TYPE_CHECKING, IO - -import json -import logging -import os - -if TYPE_CHECKING: - from orchestrator.core.orchestrator import AgentEntry - -logger = logging.getLogger(__name__) - - -def agent_main(argv: list[str], stdin_stream: IO) -> int: - """Parse args + stdin, build orchestrator, run, return exit code.""" - if len(argv) < 2: - print("Usage: agent.py ", file=__import__("sys").stderr) - return 1 - - agent_name = argv[1] - - try: - raw = json.load(stdin_stream) - except json.JSONDecodeError as exc: - logger.error("Invalid JSON on stdin: %s", exc) - return 1 - - from orchestrator.cli.config import find_project_root, is_verbose - from orchestrator.core.domain import RuneContext - from orchestrator.core.hook_runner import HookRunner - from orchestrator.core.orchestrator import RuneOrchestrator - from orchestrator.core.reporting import BifrostAPIClient - - rune_data = raw.get("rune") or {} - cwd = raw.get("cwd") or find_project_root() - context = RuneContext( - rune_id=rune_data.get("id", ""), - title=rune_data.get("title", ""), - description=rune_data.get("description"), - cwd=cwd, - tags=rune_data.get("tags", []), - raw_detail=rune_data, - ) - - # For now, use a simple agent loader - # TODO: integrate with agent catalog from engine-claude-code - entry = _load_agent_entry(agent_name, cwd) - if entry is None: - logger.error("Unknown agent: %r", agent_name) - return 1 - - if not entry.model: - logger.error("Agent %r has no model declared", agent_name) - return 1 - - verbose = is_verbose() - api_url = os.environ.get("BIFROST_API_URL", "http://localhost:8000") - - hook_runner = HookRunner(project_dir=cwd) - # TODO: initialize engine from engine-claude-code - api_client = BifrostAPIClient(base_url=api_url) - - orchestrator = RuneOrchestrator( - context=context, - entry=entry, - hook_runner=hook_runner, - engine=None, # TODO: pass engine instance - api_client=api_client, - verbose=verbose, - ) - - import anyio - - result = anyio.run(orchestrator.run) - - if not result.success: - return 1 - if result.skip_fulfill: - return -2 - return 0 - - -def _load_agent_entry(agent_name: str, cwd: str) -> AgentEntry | None: - """Load agent entry from agent catalog. - - TODO: This is a placeholder. Integrate with engine-claude-code agent catalog. - """ - # Simple placeholder - in real implementation, this would use the agent catalog - return AgentEntry( - name=agent_name, - model="sonnet", - prompt="You are a helpful assistant.", - tools=["Read", "Grep"], - ) diff --git a/orchestrator/packages/orchestrator/src/orchestrator/cli/config.py b/orchestrator/packages/orchestrator/src/orchestrator/cli/config.py deleted file mode 100644 index 5e98b5a..0000000 --- a/orchestrator/packages/orchestrator/src/orchestrator/cli/config.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Configuration helpers — project root discovery and logging verbosity.""" - -import os -from pathlib import Path - - -def find_project_root() -> str: - """Walk up from cwd to find .bifrost.yaml or .git.""" - path = Path(os.getcwd()) - for candidate in [path, *path.parents]: - if (candidate / ".bifrost.yaml").exists() or (candidate / ".git").exists(): - return str(candidate) - return str(path) - - -def is_verbose() -> bool: - """Read orchestrate.logging from .bifrost.yaml in project root.""" - try: - import yaml - - root = find_project_root() - config_path = Path(root) / ".bifrost.yaml" - if not config_path.exists(): - return False - config = yaml.safe_load(config_path.read_text()) - orchestrate = config.get("orchestrate") or {} - return orchestrate.get("logging") == "verbose" - except Exception: - return False diff --git a/orchestrator/packages/orchestrator/src/orchestrator/core/__init__.py b/orchestrator/packages/orchestrator/src/orchestrator/core/__init__.py deleted file mode 100644 index 1a87cee..0000000 --- a/orchestrator/packages/orchestrator/src/orchestrator/core/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -from orchestrator.core.config import ( - EngineConfig, - OrchestratorConfig, - TaskSourceConfig, - load_config, -) -from orchestrator.core.domain import ( - OrchestrationResult, - RuneContext, -) -from orchestrator.core.factory import create_engine, create_task_source -from orchestrator.core.hook_runner import HookRunner -from orchestrator.core.reporting import BifrostAPIClient - -__all__ = [ - "OrchestrationResult", - "RuneContext", - "HookRunner", - "BifrostAPIClient", - "EngineConfig", - "TaskSourceConfig", - "OrchestratorConfig", - "load_config", - "create_engine", - "create_task_source", -] diff --git a/orchestrator/packages/orchestrator/src/orchestrator/core/config.py b/orchestrator/packages/orchestrator/src/orchestrator/core/config.py deleted file mode 100644 index 394e998..0000000 --- a/orchestrator/packages/orchestrator/src/orchestrator/core/config.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Configuration management from .bifrost.yaml.""" - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - - -@dataclass(frozen=True) -class TaskSourceConfig: - """Configuration for task sources.""" - - type: str # e.g., "bifrost", "github" - settings: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class EngineConfig: - """Configuration for engines.""" - - type: str # e.g., "claude-code", "generic" - settings: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class OrchestratorConfig: - """Full orchestrator configuration from .bifrost.yaml.""" - - task_source: TaskSourceConfig - engine: EngineConfig - concurrency: int = 1 - claimant: str | None = None - dispatcher: str | None = None - - -def load_config(project_dir: str | None = None) -> OrchestratorConfig: - """Load orchestrator configuration from .bifrost.yaml. - - Args: - project_dir: Project directory to search for .bifrost.yaml. - Defaults to current working directory. - - Returns: - OrchestratorConfig with defaults for missing values - """ - import yaml - - if project_dir is None: - project_dir = _find_project_root() - - config_path = Path(project_dir) / ".bifrost.yaml" - if not config_path.exists(): - return _default_config() - - try: - data = yaml.safe_load(config_path.read_text()) or {} - except Exception: - return _default_config() - - orchestrate = data.get("orchestrate") or {} - - # Parse task_source config - task_source_raw = orchestrate.get("task_source") or {} - task_source = TaskSourceConfig( - type=task_source_raw.get("type", "bifrost"), - settings=task_source_raw.get("settings") or {}, - ) - - # Parse engine config - engine_raw = orchestrate.get("engine") or {} - engine = EngineConfig( - type=engine_raw.get("type", "claude-code"), - settings=engine_raw.get("settings") or {}, - ) - - return OrchestratorConfig( - task_source=task_source, - engine=engine, - concurrency=orchestrate.get("concurrency", 1), - claimant=orchestrate.get("claimant"), - dispatcher=orchestrate.get("dispatcher"), - ) - - -def _find_project_root() -> str: - """Walk up from cwd to find .bifrost.yaml or .git.""" - import os - - path = Path(os.getcwd()) - for candidate in [path, *path.parents]: - if (candidate / ".bifrost.yaml").exists() or (candidate / ".git").exists(): - return str(candidate) - return str(path) - - -def _default_config() -> OrchestratorConfig: - """Return default configuration when no .bifrost.yaml is found.""" - return OrchestratorConfig( - task_source=TaskSourceConfig(type="bifrost", settings={}), - engine=EngineConfig(type="claude-code", settings={}), - concurrency=1, - claimant=None, - dispatcher=None, - ) diff --git a/orchestrator/packages/orchestrator/src/orchestrator/core/domain.py b/orchestrator/packages/orchestrator/src/orchestrator/core/domain.py deleted file mode 100644 index 1784381..0000000 --- a/orchestrator/packages/orchestrator/src/orchestrator/core/domain.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Core domain value objects for the orchestrator.""" - -from dataclasses import dataclass -from enum import Enum - -from interface_engine.types import EngineResult - - -class RuneStopVerdict(Enum): - SUCCESS = "success" - SKIP_FULFILL = "skip_fulfill" - BLOCKING_FAILURE = "blocking_failure" - FOLLOW_UP = "follow_up" - - -@dataclass(frozen=True) -class RuneContext: - """Rune + execution environment.""" - - rune_id: str - title: str - description: str | None - cwd: str - tags: list[str] - raw_detail: dict # Raw rune detail from task source - - -@dataclass(frozen=True) -class OrchestrationResult: - """Terminal result of a full rune orchestration run.""" - - success: bool - skip_fulfill: bool - engine_result: EngineResult | None diff --git a/orchestrator/packages/orchestrator/src/orchestrator/core/factory.py b/orchestrator/packages/orchestrator/src/orchestrator/core/factory.py deleted file mode 100644 index a081109..0000000 --- a/orchestrator/packages/orchestrator/src/orchestrator/core/factory.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Factory functions for creating task sources and engines from configuration.""" - -from interface_tasks import TaskSource -from interface_engine import Engine - -from orchestrator.core.config import TaskSourceConfig, EngineConfig - - -def create_task_source(config: TaskSourceConfig) -> TaskSource: - """Create a task source from configuration. - - Args: - config: TaskSourceConfig with type and settings - - Returns: - TaskSource instance - - Raises: - ValueError: if task source type is unknown - """ - if config.type == "bifrost": - from tasks_bifrost import BifrostTaskSourceConfig, BifrostTaskSource - - bifrost_config = BifrostTaskSourceConfig.from_dict( - { - "type": config.type, - "settings": config.settings, - } - ) - - return BifrostTaskSource( - base_url=bifrost_config.base_url, - timeout=bifrost_config.timeout, - poll_interval=bifrost_config.poll_interval, - ) - else: - raise ValueError(f"Unknown task source type: {config.type}") - - -def create_engine(config: EngineConfig, agent_entry) -> Engine: - """Create an engine from configuration. - - Args: - config: EngineConfig with type and settings - agent_entry: AgentEntry from the agent catalog (contains model, tools, prompt) - - Returns: - Engine instance - - Raises: - ValueError: if engine type is unknown - """ - if config.type == "claude-code": - from engine_claude_code import ClaudeCodeEngineConfig, ClaudeCodeEngine - - engine_config = ClaudeCodeEngineConfig.from_dict( - { - "type": config.type, - "settings": config.settings, - } - ) - - return ClaudeCodeEngine( - entry=agent_entry, - verbose=engine_config.verbose, - claude_dir=engine_config.claude_dir, - ) - else: - raise ValueError(f"Unknown engine type: {config.type}") diff --git a/orchestrator/packages/orchestrator/src/orchestrator/core/hook_runner.py b/orchestrator/packages/orchestrator/src/orchestrator/core/hook_runner.py deleted file mode 100644 index d432075..0000000 --- a/orchestrator/packages/orchestrator/src/orchestrator/core/hook_runner.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Hook execution — runs shell subprocess hooks and translates exit codes to typed outcomes.""" - -from __future__ import annotations - -import json -import logging -import os -import subprocess -from dataclasses import dataclass -from enum import Enum -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from orchestrator.core.domain import RuneContext - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class HookSpec: - """Hook specification.""" - - command: str - - -@dataclass(frozen=True) -class RuneStartResult: - extra_system_prompt: str - skip_agent: bool - error: bool - - -class RuneStopOutcome(Enum): - SUCCESS = "success" - SKIP_FULFILL = "skip_fulfill" # exit -2 - FOLLOW_UP = "follow_up" # exit 1 - BLOCKING_FAILURE = "blocking" # exit 2 - - -@dataclass(frozen=True) -class RuneStopHookResult: - outcome: RuneStopOutcome - message: str | None = None # populated when FOLLOW_UP - - -class HookRunner: - def __init__(self, project_dir: str) -> None: - self.project_dir = project_dir - - def run_rune_start( - self, - hooks: list[HookSpec], - context: RuneContext, - ) -> RuneStartResult: - """Run all RuneStart hooks in order. - - Returns RuneStartResult with extra_system_prompt accumulated from stdout. - Sets skip_agent=True on exit -2, error=True on positive exit code. - """ - rune_dict = _context_to_dict(context) - parts: list[str] = [] - - for hook in hooks: - try: - result = self._run_command(hook.command, rune_dict, None) - reason = ( - (result.stderr.strip() or result.stdout.strip()) - if result.returncode != 0 - else "" - ) - _log_hook("RuneStart", hook.command, result.returncode, reason) - - if result.returncode == -2: - if result.stdout.strip(): - parts.append(result.stdout.strip()) - return RuneStartResult( - extra_system_prompt="\n\n".join(parts), - skip_agent=True, - error=False, - ) - - if result.returncode > 0: - logger.error("RuneStart hook failed with exit code %d", result.returncode) - if result.stdout.strip(): - logger.error("RuneStart hook stdout:\n%s", result.stdout) - if result.stderr.strip(): - logger.error("RuneStart hook stderr:\n%s", result.stderr) - if result.stdout.strip(): - parts.append(result.stdout.strip()) - return RuneStartResult( - extra_system_prompt="\n\n".join(parts), - skip_agent=False, - error=True, - ) - - if result.stdout.strip(): - parts.append(result.stdout.strip()) - except Exception as exc: - logger.warning("hook:RuneStart command=%s failed: %s", hook.command, exc) - - return RuneStartResult( - extra_system_prompt="\n\n".join(parts), - skip_agent=False, - error=False, - ) - - def run_rune_stop_once( - self, - hooks: list[HookSpec], - context: RuneContext, - last_agent_message: str | None, - ) -> list[RuneStopHookResult]: - """Run all RuneStop hooks once in order. - - Stops early on FOLLOW_UP or BLOCKING_FAILURE. - Caller is responsible for the retry loop. - """ - rune_dict = _context_to_dict(context) - results: list[RuneStopHookResult] = [] - - for hook in hooks: - try: - result = self._run_command(hook.command, rune_dict, last_agent_message) - except Exception as exc: - logger.warning("hook:RuneStop command=%s failed: %s", hook.command, exc) - continue - - hook_output = result.stdout.strip() or result.stderr.strip() - - if result.returncode == 0: - _log_hook("RuneStop", hook.command, 0) - results.append(RuneStopHookResult(outcome=RuneStopOutcome.SUCCESS)) - - elif result.returncode == -2: - _log_hook("RuneStop", hook.command, -2, hook_output) - results.append(RuneStopHookResult(outcome=RuneStopOutcome.SKIP_FULFILL)) - - elif result.returncode == 1: - _log_hook("RuneStop", hook.command, 1, hook_output) - follow_up_msg = ( - "A post-completion hook reported an issue and provided " - f"additional context. Please review and address it:\n\n{hook_output}" - ) - results.append( - RuneStopHookResult( - outcome=RuneStopOutcome.FOLLOW_UP, - message=follow_up_msg, - ) - ) - break # caller restarts from first hook after follow-up - - elif result.returncode == 2: - _log_hook("RuneStop", hook.command, 2, hook_output) - results.append(RuneStopHookResult(outcome=RuneStopOutcome.BLOCKING_FAILURE)) - break - - else: - _log_hook("RuneStop", hook.command, result.returncode, hook_output) - - return results - - def _run_command( - self, - command: str, - rune_dict: dict, - last_agent_message: str | None, - ) -> subprocess.CompletedProcess: - env = os.environ.copy() - env["CLAUDE_PROJECT_DIR"] = self.project_dir - - hook_input = json.dumps( - { - "rune": rune_dict, - "last_agent_message": last_agent_message, - "cwd": self.project_dir, - } - ) - - return subprocess.run( - command, - shell=True, - input=hook_input, - capture_output=True, - text=True, - env=env, - cwd=self.project_dir, - ) - - -def _context_to_dict(context: RuneContext) -> dict: - """Convert a RuneContext to a plain dict for JSON serialization.""" - return { - "id": context.rune_id, - "title": context.title, - "description": context.description, - "tags": context.tags, - } - - -def _log_hook(event: str, command: str, returncode: int, reason: str = "") -> None: - if returncode == 0: - logger.info("hook:%s command=%s result=0", event, command) - else: - truncated = reason[:100] + ("..." if len(reason) > 100 else "") - logger.warning( - "hook:%s command=%s result=%d reason=%s", event, command, returncode, truncated - ) diff --git a/orchestrator/packages/orchestrator/src/orchestrator/core/orchestrator.py b/orchestrator/packages/orchestrator/src/orchestrator/core/orchestrator.py deleted file mode 100644 index 4cf76db..0000000 --- a/orchestrator/packages/orchestrator/src/orchestrator/core/orchestrator.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Rune orchestration — coordinates task lifecycle with hooks and engine execution.""" - -from __future__ import annotations - -import logging - -from interface_engine.types import EngineContext, EngineResult, ExecutionStats - -from orchestrator.core.domain import OrchestrationResult, RuneContext, RuneStopVerdict -from orchestrator.core.hook_runner import HookRunner, RuneStopOutcome -from orchestrator.core.reporting import BifrostAPIClient - -logger = logging.getLogger(__name__) - - -class AgentEntry: - """Agent definition for engine execution.""" - - def __init__( - self, - name: str, - model: str, - prompt: str, - tools: list[str] | None = None, - rune_start_hooks: list[HookSpec] | None = None, - rune_stop_hooks: list[HookSpec] | None = None, - ) -> None: - self.name = name - self.model = model - self.prompt = prompt - self.tools = tools or [] - self.rune_start_hooks = rune_start_hooks or [] - self.rune_stop_hooks = rune_stop_hooks or [] - - -class HookSpec: - """Hook specification for backward compatibility.""" - - def __init__(self, command: str) -> None: - self.command = command - - -class RuneOrchestrator: - """Coordinates task lifecycle: hooks → engine → follow-up loop.""" - - def __init__( - self, - context: RuneContext, - entry: AgentEntry, - hook_runner: HookRunner, - engine, - api_client: BifrostAPIClient | None = None, - verbose: bool = False, - ) -> None: - self.context = context - self.entry = entry - self.hook_runner = hook_runner - self.engine = engine - self.api_client = api_client - self.verbose = verbose - - async def run(self) -> OrchestrationResult: - agent_name = self.entry.name - rune_id = self.context.rune_id - - logger.info("Running agent %r for task %s in %s", agent_name, rune_id, self.context.cwd) - - # --- RuneStart hooks --- - start_result = self.hook_runner.run_rune_start(self.entry.rune_start_hooks, self.context) - - if start_result.error: - logger.error("RuneStart hook reported error, exiting with failure") - return OrchestrationResult(success=False, skip_fulfill=False, engine_result=None) - - if start_result.skip_agent: - logger.info("RuneStart hook signaled skip agent (-2), exiting successfully") - return OrchestrationResult(success=True, skip_fulfill=False, engine_result=None) - - system_prompt = self.entry.prompt - if start_result.extra_system_prompt: - system_prompt = system_prompt + "\n\n" + start_result.extra_system_prompt - - prompt = _build_task_prompt(self.context) - - # --- Engine execution with follow-up loop --- - engine_ctx = EngineContext( - task_id=rune_id, - working_dir=self.context.cwd, - agent_name=agent_name, - verbose=self.verbose, - ) - - task_data = { - "system_prompt": system_prompt, - "prompt": prompt, - "raw_detail": self.context.raw_detail, - } - - engine_result: EngineResult = await self.engine.execute(engine_ctx, task_data) - - # --- RuneStop hooks with follow-up retry loop --- - last_message = engine_result.last_message - cumulative_stats = engine_result.stats - skip_fulfill = engine_result.skip_fulfill - - while True: - hook_results = self.hook_runner.run_rune_stop_once( - self.entry.rune_stop_hooks, - self.context, - last_message, - ) - - verdict, follow_up_msg, hook_skip_fulfill = _interpret_hook_results(hook_results) - - if hook_skip_fulfill: - skip_fulfill = True - - if verdict == RuneStopVerdict.BLOCKING_FAILURE: - logger.error("RuneStop hook blocking failure for task %s", rune_id) - return OrchestrationResult( - success=False, skip_fulfill=False, engine_result=cumulative_stats - ) - - if verdict == RuneStopVerdict.FOLLOW_UP: - # Check if engine supports follow-up - if hasattr(self.engine, "send_follow_up"): - follow_up_result = await self.engine.send_follow_up(follow_up_msg) - last_message = follow_up_result.last_message - if cumulative_stats and follow_up_result.stats: - cumulative_stats = ExecutionStats( - duration_ms=cumulative_stats.duration_ms - + follow_up_result.stats.duration_ms, - input_tokens=cumulative_stats.input_tokens - + follow_up_result.stats.input_tokens, - output_tokens=cumulative_stats.output_tokens - + follow_up_result.stats.output_tokens, - cache_read_tokens=cumulative_stats.cache_read_tokens - + follow_up_result.stats.cache_read_tokens, - cache_creation_tokens=cumulative_stats.cache_creation_tokens - + follow_up_result.stats.cache_creation_tokens, - total_cost_usd=cumulative_stats.total_cost_usd - + follow_up_result.stats.total_cost_usd, - num_turns=cumulative_stats.num_turns + follow_up_result.stats.num_turns, - ) - continue - else: - logger.error("Engine does not support follow-up for task %s", rune_id) - return OrchestrationResult( - success=False, skip_fulfill=False, engine_result=cumulative_stats - ) - - break # SUCCESS or SKIP_FULFILL - - # --- Post completion note --- - if self.api_client and cumulative_stats: - try: - self.api_client.append_completion_note(rune_id, cumulative_stats) - except Exception as exc: - logger.warning("Failed to append completion note: %s", exc) - - return OrchestrationResult( - success=True, skip_fulfill=skip_fulfill, engine_result=cumulative_stats - ) - - -def _build_task_prompt(context: RuneContext) -> str: - lines = [ - f"Task ID: {context.rune_id}", - f"Title: {context.title}", - ] - if context.description: - lines += ["", "Description:", context.description] - if context.tags: - lines += ["", "Tags:", ", ".join(context.tags)] - return "\n".join(lines) - - -def _interpret_hook_results( - hook_results: list, -) -> tuple[RuneStopVerdict, str | None, bool]: - """Reduce a list of RuneStopHookResult to a single verdict. - - Returns (verdict, follow_up_message, skip_fulfill). - """ - skip_fulfill = False - for result in hook_results: - if result.outcome == RuneStopOutcome.SKIP_FULFILL: - skip_fulfill = True - elif result.outcome == RuneStopOutcome.BLOCKING_FAILURE: - return RuneStopVerdict.BLOCKING_FAILURE, None, skip_fulfill - elif result.outcome == RuneStopOutcome.FOLLOW_UP: - return RuneStopVerdict.FOLLOW_UP, result.message, skip_fulfill - return RuneStopVerdict.SUCCESS, None, skip_fulfill diff --git a/orchestrator/packages/orchestrator/src/orchestrator/core/reporting.py b/orchestrator/packages/orchestrator/src/orchestrator/core/reporting.py deleted file mode 100644 index 7ff3d8f..0000000 --- a/orchestrator/packages/orchestrator/src/orchestrator/core/reporting.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Completion note formatting and Bifrost API posting.""" - -from __future__ import annotations - -import logging - -from interface_engine.types import ExecutionStats - -logger = logging.getLogger(__name__) - - -def format_completion_note(stats: ExecutionStats | dict) -> str: - """Format a human-readable completion note from execution statistics.""" - if isinstance(stats, dict): - stats = ExecutionStats( - duration_ms=stats.get("duration_ms", 0), - input_tokens=stats.get("input_tokens", 0), - output_tokens=stats.get("output_tokens", 0), - cache_read_tokens=stats.get("cache_read_tokens", 0), - cache_creation_tokens=stats.get("cache_creation_tokens", 0), - total_cost_usd=stats.get("total_cost_usd", 0.0), - num_turns=stats.get("num_turns", 0), - ) - duration_ms = stats.duration_ms - duration_s = duration_ms / 1000.0 - - if duration_s < 1: - duration_str = f"{duration_ms:.0f}ms" - elif duration_s < 60: - duration_str = f"{duration_s:.1f}s" - elif duration_s < 3600: - duration_str = f"{duration_s / 60:.1f}m" - else: - duration_str = f"{duration_s / 3600.0:.1f}h" - - num_turns = stats.num_turns - input_str = f"{stats.input_tokens:,}" - output_str = f"{stats.output_tokens:,}" - cost_str = f"${stats.total_cost_usd:.4f}" - - parts = [ - f"(orchestrator) Completed in {duration_str} over {num_turns} turn{'s' if num_turns != 1 else ''}.", - f"Tokens: {input_str} input, {output_str} output.", - ] - - cache_parts = [] - if stats.cache_read_tokens > 0: - cache_parts.append(f"{stats.cache_read_tokens:,} cache read") - if stats.cache_creation_tokens > 0: - cache_parts.append(f"{stats.cache_creation_tokens:,} cache creation") - if cache_parts: - parts.append(f"Cache: {', '.join(cache_parts)}.") - - parts.append(f"Cost: {cost_str}.") - return " ".join(parts) - - -class BifrostAPIClient: - def __init__(self, base_url: str) -> None: - self.base_url = base_url.rstrip("/") - - def append_completion_note(self, rune_id: str, stats: ExecutionStats) -> None: - """Post a formatted completion note to the Bifrost API.""" - note_text = format_completion_note(stats) - url = f"{self.base_url}/api/add-note" - payload = {"rune_id": rune_id, "text": note_text} - try: - import requests - - response = requests.post(url, json=payload, timeout=30) - response.raise_for_status() - except Exception as exc: - logger.warning("Failed to POST completion note to %s: %s", url, exc) diff --git a/orchestrator/packages/task-source/package.json b/orchestrator/packages/task-source/package.json new file mode 100644 index 0000000..ec53347 --- /dev/null +++ b/orchestrator/packages/task-source/package.json @@ -0,0 +1,17 @@ +{ + "name": "@orchestrator/task-source", + "version": "1.0.0", + "description": "Task Source interface and types for Orchestrator Framework", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "vite build" + } +} diff --git a/orchestrator/packages/task-source/src/api-task-source.spec.ts b/orchestrator/packages/task-source/src/api-task-source.spec.ts new file mode 100644 index 0000000..1de4ec6 --- /dev/null +++ b/orchestrator/packages/task-source/src/api-task-source.spec.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { APITaskSource } from './api-task-source.js' +import type { Task } from '@orchestrator/task-source' + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch as any + +describe('API Task Source', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('US-2: Task Source emits available tasks', () => { + it('should poll API and yield available tasks', async () => { + // Given a task is available for processing + const mockTask: Task = { + id: 'task-1', + title: 'Test Task', + description: 'Test Description', + status: 'OPEN' as const, + tags: ['worker:reviewer'], + claimant: null, + createdAt: new Date(), + updatedAt: new Date(), + priority: 1 + } + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => [mockTask] + } as Response) + + const source = new APITaskSource({ + baseUrl: 'https://api.example.com', + pollInterval: 100 + }) + + // When the orchestrator polls the task source + const tasks: Task[] = [] + for await (const t of source.watchTasks()) { + tasks.push(t) + break + } + + // Then the task source yields the task via its async iterator + expect(tasks).toHaveLength(1) + expect(tasks[0].id).toBe('task-1') + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/tasks?status=OPEN', + expect.any(Object) + ) + }) + + it('should respect poll interval', async () => { + const source = new APITaskSource({ + baseUrl: 'https://api.example.com', + pollInterval: 50 + }) + + // Return empty tasks array + mockFetch.mockResolvedValue({ + ok: true, + json: async () => [] + } as Response) + + // Close after a short time + setTimeout(() => source.close(), 100) + + // Start polling + const pollPromise = (async () => { + for await (const _task of source.watchTasks()) { + // Won't be reached since no tasks + } + })() + + await pollPromise + + // Should have attempted polls + expect(mockFetch).toHaveBeenCalled() + }) + }) + + describe('Task lifecycle methods', () => { + it('should get task detail', async () => { + const mockTaskDetail = { + id: 'task-1', + title: 'Test', + description: 'Description', + status: 'OPEN' as const, + tags: [], + claimant: null, + createdAt: new Date(), + updatedAt: new Date(), + priority: 1, + dependencies: [], + notes: [], + acceptanceCriteria: [], + retro: [] + } + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockTaskDetail + } as Response) + + const source = new APITaskSource({ + baseUrl: 'https://api.example.com' + }) + + const detail = await source.getTaskDetail('task-1') + + expect(detail).toEqual(mockTaskDetail) + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/tasks/task-1', + expect.any(Object) + ) + }) + + it('should complete task', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }) + } as Response) + + const source = new APITaskSource({ + baseUrl: 'https://api.example.com' + }) + + const result = await source.completeTask('task-1') + + expect(result).toBe(true) + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/tasks/task-1/complete', + expect.objectContaining({ + method: 'POST' + }) + ) + }) + + it('should fail task', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }) + } as Response) + + const source = new APITaskSource({ + baseUrl: 'https://api.example.com' + }) + + const result = await source.failTask('task-1', 'Test error') + + expect(result).toBe(true) + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/tasks/task-1/fail', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('Test error') + }) + ) + }) + }) + + describe('Error handling', () => { + it('should handle API errors gracefully', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500 + } as Response) + + const source = new APITaskSource({ + baseUrl: 'https://api.example.com', + pollInterval: 50 + }) + + // Close after a short time + setTimeout(() => source.close(), 150) + + // Should not throw, should continue polling + const tasks: Task[] = [] + for await (const task of source.watchTasks()) { + tasks.push(task) + } + + // Should have attempted polls despite errors + expect(mockFetch).toHaveBeenCalled() + }) + + it('should include telemetry in completion notes', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }) + } as Response) + + const source = new APITaskSource({ + baseUrl: 'https://api.example.com' + }) + + // When completing task with telemetry + await source.completeTask('task-1') + + // Then request is made + expect(mockFetch).toHaveBeenCalled() + }) + }) +}) diff --git a/orchestrator/packages/task-source/src/api-task-source.ts b/orchestrator/packages/task-source/src/api-task-source.ts new file mode 100644 index 0000000..721bf23 --- /dev/null +++ b/orchestrator/packages/task-source/src/api-task-source.ts @@ -0,0 +1,158 @@ +import type { TaskSource, Task, TaskDetail, TaskStatus } from './types.js' + +export type APITaskSourceConfig = { + baseUrl: string + pollInterval?: number // milliseconds, default 10000 + headers?: Record + timeout?: number // request timeout in milliseconds, default 30000 +} + +/** + * API-based Task Source implementation. + * Polls an HTTP API endpoint for available tasks. + * + * US-2: Task Source emits available tasks via async iterator + * FR-1: Task Source Interface + */ +export class APITaskSource implements TaskSource { + #config: APITaskSourceConfig + #abortController: AbortController | null = null + + constructor(config: APITaskSourceConfig) { + this.#config = { + pollInterval: 10000, + timeout: 30000, + ...config + } + } + + /** + * Yield available tasks via async iterator. + * US-2: Task Source emits available tasks via async iterator + * + * This method polls the API endpoint at the configured interval. + * The API endpoint is responsible for coordination (claiming, locking). + */ + async *watchTasks(): AsyncGenerator { + this.#abortController = new AbortController() + + while (!this.#abortController.signal.aborted) { + try { + // FR-1: Yield available tasks from API + const tasks = await this.#fetchTasks() + + for (const task of tasks) { + yield task + } + } catch (error) { + // Log error but continue polling (FR-15: reconnection) + console.error('Error polling task source:', error) + } + + // Wait before next poll + await this.#delay(this.#config.pollInterval!) + } + } + + /** + * Fetch tasks from the API. + */ + async #fetchTasks(): Promise { + const url = `${this.#config.baseUrl}/tasks?status=OPEN` + + const response = await fetch(url, { + signal: this.#abortController?.signal, + headers: this.#config.headers + }) + + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`) + } + + const tasks = await response.json() + return tasks as Task[] + } + + /** + * Delay helper. + */ + #delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + + /** + * Retrieve full task details. + * FR-1: getTaskDetail method + */ + async getTaskDetail(taskId: string): Promise { + const url = `${this.#config.baseUrl}/tasks/${taskId}` + + const response = await fetch(url, { + signal: this.#abortController?.signal, + headers: this.#config.headers + }) + + if (!response.ok) { + if (response.status === 404) { + return null + } + throw new Error(`API error: ${response.status} ${response.statusText}`) + } + + return (await response.json()) as TaskDetail + } + + /** + * Mark task as fulfilled. + * FR-1: completeTask method + */ + async completeTask(taskId: string): Promise { + const url = `${this.#config.baseUrl}/tasks/${taskId}/complete` + + const response = await fetch(url, { + method: 'POST', + signal: this.#abortController?.signal, + headers: { + ...this.#config.headers, + 'Content-Type': 'application/json' + } + }) + + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`) + } + + return true + } + + /** + * Mark task as failed. + * FR-1: failTask method + */ + async failTask(taskId: string, error: string): Promise { + const url = `${this.#config.baseUrl}/tasks/${taskId}/fail` + + const response = await fetch(url, { + method: 'POST', + signal: this.#abortController?.signal, + headers: { + ...this.#config.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error }) + }) + + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`) + } + + return true + } + + /** + * Stop polling tasks. + */ + close(): void { + this.#abortController?.abort() + } +} diff --git a/orchestrator/packages/task-source/src/index.ts b/orchestrator/packages/task-source/src/index.ts new file mode 100644 index 0000000..5941a39 --- /dev/null +++ b/orchestrator/packages/task-source/src/index.ts @@ -0,0 +1,3 @@ +export * from './types.js' +export * from './interface.js' +export * from './api-task-source.js' diff --git a/orchestrator/packages/task-source/src/interface.spec.ts b/orchestrator/packages/task-source/src/interface.spec.ts new file mode 100644 index 0000000..f4a0fc8 --- /dev/null +++ b/orchestrator/packages/task-source/src/interface.spec.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest' +import { TaskSource } from './interface.js' +import { TaskStatus } from './types.js' + +describe('TaskSource Interface', () => { + describe('FR-1: Task Source Interface', () => { + it('should require watchTasks method returning AsyncIterator', async () => { + // US-2: Task Source emits available tasks via async iterator + class MockTaskSource implements TaskSource { + async *watchTasks(): AsyncGenerator { + yield { + id: 'task-1', + title: 'Test', + description: null, + status: TaskStatus.OPEN, + tags: ['worker:test'], + claimant: null, + createdAt: new Date(), + updatedAt: new Date(), + priority: 1 + } + } + + async getTaskDetail(_taskId: string) { + throw new Error('Not implemented') + } + + async completeTask(_taskId: string) { + return true + } + + async failTask(_taskId: string, _error: string) { + return true + } + } + + const source = new MockTaskSource() + const tasks = source.watchTasks() + + for await (const task of tasks) { + expect(task.id).toBe('task-1') + expect(task.status).toBe(TaskStatus.OPEN) + break + } + }) + + it('should require getTaskDetail method', async () => { + const source: TaskSource = { + async *watchTasks() { + yield { + id: 'task-1', + title: 'Test', + description: null, + status: TaskStatus.OPEN, + tags: [], + claimant: null, + createdAt: new Date(), + updatedAt: new Date(), + priority: 1 + } + }, + async getTaskDetail(taskId: string) { + return { + id: taskId, + title: 'Detail Test', + description: 'Description', + status: TaskStatus.OPEN, + tags: [], + claimant: null, + createdAt: new Date(), + updatedAt: new Date(), + priority: 1, + dependencies: [], + notes: [], + acceptanceCriteria: [], + retro: [] + } + }, + async completeTask(_taskId: string) { + return true + }, + async failTask(_taskId: string, _error: string) { + return true + } + } + + const detail = await source.getTaskDetail('task-1') + expect(detail?.id).toBe('task-1') + expect(detail?.dependencies).toEqual([]) + }) + + it('should require completeTask method', async () => { + const source: TaskSource = { + async *watchTasks() { + yield { + id: 'task-1', + title: 'Test', + description: null, + status: TaskStatus.OPEN, + tags: [], + claimant: null, + createdAt: new Date(), + updatedAt: new Date(), + priority: 1 + } + }, + async getTaskDetail(_taskId: string) { + return null + }, + async completeTask(taskId: string) { + return taskId === 'task-1' + }, + async failTask(_taskId: string, _error: string) { + return false + } + } + + const result = await source.completeTask('task-1') + expect(result).toBe(true) + }) + + it('should require failTask method', async () => { + const source: TaskSource = { + async *watchTasks() { + yield { + id: 'task-1', + title: 'Test', + description: null, + status: TaskStatus.OPEN, + tags: [], + claimant: null, + createdAt: new Date(), + updatedAt: new Date(), + priority: 1 + } + }, + async getTaskDetail(_taskId: string) { + return null + }, + async completeTask(_taskId: string) { + return false + }, + async failTask(taskId: string, error: string) { + return taskId === 'task-1' && error.length > 0 + } + } + + const result = await source.failTask('task-1', 'Test error') + expect(result).toBe(true) + }) + }) +}) diff --git a/orchestrator/packages/task-source/src/interface.ts b/orchestrator/packages/task-source/src/interface.ts new file mode 100644 index 0000000..2519896 --- /dev/null +++ b/orchestrator/packages/task-source/src/interface.ts @@ -0,0 +1,16 @@ +import { Task, TaskDetail } from './types.js' + +// FR-1: Task Source Interface +export type TaskSource = { + // Yield available tasks. Task source responsible for coordination. + watchTasks: () => AsyncGenerator + + // Retrieve full task details + getTaskDetail: (taskId: string) => Promise + + // Mark task as fulfilled + completeTask: (taskId: string) => Promise + + // Mark task as failed + failTask: (taskId: string, error: string) => Promise +} diff --git a/orchestrator/packages/task-source/src/types.spec.ts b/orchestrator/packages/task-source/src/types.spec.ts new file mode 100644 index 0000000..9850b65 --- /dev/null +++ b/orchestrator/packages/task-source/src/types.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { TaskStatus, Task, TaskDetail } from './types.js' + +describe('TaskSource Types', () => { + describe('TaskStatus enum', () => { + it('should have all required status values', () => { + // FR-1: Task Status enum values + expect(TaskStatus.OPEN).toBe('OPEN') + expect(TaskStatus.IN_PROGRESS).toBe('IN_PROGRESS') + expect(TaskStatus.COMPLETED).toBe('COMPLETED') + expect(TaskStatus.FAILED).toBe('FAILED') + expect(TaskStatus.CANCELLED).toBe('CANCELLED') + }) + }) + + describe('Task type', () => { + it('should create a valid Task with required fields', () => { + const task: Task = { + id: 'task-123', + title: 'Test Task', + description: 'Test Description', + status: TaskStatus.OPEN, + tags: ['worker:reviewer'], + claimant: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + priority: 1 + } + + expect(task.id).toBe('task-123') + expect(task.status).toBe(TaskStatus.OPEN) + expect(task.tags).toContain('worker:reviewer') + }) + }) + + describe('TaskDetail type', () => { + it('should extend Task with additional fields', () => { + const detail: TaskDetail = { + id: 'task-123', + title: 'Test Task', + description: 'Test Description', + status: TaskStatus.OPEN, + tags: ['worker:reviewer'], + claimant: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + priority: 1, + dependencies: [], + notes: [], + acceptanceCriteria: [], + retro: [] + } + + expect(detail.dependencies).toEqual([]) + expect(detail.notes).toEqual([]) + }) + }) +}) diff --git a/orchestrator/packages/task-source/src/types.ts b/orchestrator/packages/task-source/src/types.ts new file mode 100644 index 0000000..c1df70f --- /dev/null +++ b/orchestrator/packages/task-source/src/types.ts @@ -0,0 +1,54 @@ +// FR-1: Task Status enum values +export const TaskStatus = { + OPEN: 'OPEN', + IN_PROGRESS: 'IN_PROGRESS', + COMPLETED: 'COMPLETED', + FAILED: 'FAILED', + CANCELLED: 'CANCELLED' +} as const + +export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus] + +// FR-1: Task aggregate +export type Task = { + id: string + title: string + description: string | null + status: TaskStatus + tags: string[] + claimant: string | null + createdAt: Date | null + updatedAt: Date | null + priority: number +} + +// FR-1: TaskDetail extends Task +export type TaskDetail = Task & { + dependencies: DependencyRef[] + notes: NoteEntry[] + acceptanceCriteria: ACEntry[] + retro: RetroEntry[] +} + +export type DependencyRef = { + taskId: string + type: string +} + +export type NoteEntry = { + id: string + content: string + createdAt: Date +} + +export type ACEntry = { + id: string + criteria: string + satisfied: boolean +} + +export type RetroEntry = { + id: string + content: string + createdAt: Date +} diff --git a/orchestrator/packages/task-source/tsconfig.json b/orchestrator/packages/task-source/tsconfig.json new file mode 100644 index 0000000..49cc6d1 --- /dev/null +++ b/orchestrator/packages/task-source/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/orchestrator/packages/task-source/vite.config.ts b/orchestrator/packages/task-source/vite.config.ts new file mode 100644 index 0000000..7b59ef3 --- /dev/null +++ b/orchestrator/packages/task-source/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' + +export default defineConfig({ + plugins: [ + dts({ + tsconfigPath: './tsconfig.json', + rollupTypes: true, + }), + ], + build: { + lib: { + entry: './src/index.ts', + name: 'TaskSource', + formats: ['es'], + fileName: 'index', + }, + rollupOptions: { + external: [], + output: { + globals: {}, + }, + }, + target: 'esnext', + emptyOutDir: true, + }, +}) diff --git a/orchestrator/packages/tasks-bifrost/pyproject.toml b/orchestrator/packages/tasks-bifrost/pyproject.toml deleted file mode 100644 index b95a0b6..0000000 --- a/orchestrator/packages/tasks-bifrost/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "tasks-bifrost" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [ - "interface-tasks", - "requests", -] - -[tools.uv.sources] -interface-tasks = { workspace = true } - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/tasks_bifrost"] diff --git a/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/__init__.py b/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/__init__.py deleted file mode 100644 index 57a8091..0000000 --- a/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from tasks_bifrost.config import BifrostTaskSourceConfig -from tasks_bifrost.task_source import BifrostTaskSource - -__all__ = ["BifrostTaskSource", "BifrostTaskSourceConfig"] diff --git a/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/api_client.py b/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/api_client.py deleted file mode 100644 index 4473fe7..0000000 --- a/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/api_client.py +++ /dev/null @@ -1,154 +0,0 @@ -"""HTTP client for the Bifrost API.""" - -import logging - -logger = logging.getLogger(__name__) - - -class BifrostAPIClient: - """HTTP client for Bifrost API operations.""" - - def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 30) -> None: - """Initialize the API client. - - Args: - base_url: Base URL of the Bifrost API server - timeout: Request timeout in seconds - """ - self.base_url = base_url.rstrip("/") - self.timeout = timeout - - def fetch_ready_runes(self) -> list[dict]: - """Fetch runes that are ready for execution. - - Returns: - List of rune dictionaries - """ - import requests - - params = { - "status": "open", - "blocked": "false", - "is_saga": "false", - } - - url = f"{self.base_url}/runes" - try: - response = requests.get(url, params=params, timeout=self.timeout) - response.raise_for_status() - return response.json() - except requests.RequestException as exc: - logger.error("Failed to fetch ready runes: %s", exc) - return [] - - def fetch_rune_detail(self, rune_id: str) -> dict | None: - """Fetch detailed information about a specific rune. - - Args: - rune_id: Unique rune identifier - - Returns: - Rune detail dictionary or None if not found - """ - import requests - - url = f"{self.base_url}/rune" - params = {"id": rune_id} - - try: - response = requests.get(url, params=params, timeout=self.timeout) - response.raise_for_status() - return response.json() - except requests.RequestException as exc: - logger.error("Failed to fetch rune detail for %s: %s", rune_id, exc) - return None - - def claim_rune(self, rune_id: str, claimant: str) -> bool: - """Claim a rune for execution. - - Args: - rune_id: Unique rune identifier - claimant: Identifier for the claimant - - Returns: - True if claim was successful - """ - import requests - - url = f"{self.base_url}/claim-rune" - payload = {"id": rune_id, "claimant": claimant} - - try: - response = requests.post(url, json=payload, timeout=self.timeout) - response.raise_for_status() - return True - except requests.RequestException as exc: - logger.error("Failed to claim rune %s: %s", rune_id, exc) - return False - - def unclaim_rune(self, rune_id: str) -> bool: - """Unclaim a rune. - - Args: - rune_id: Unique rune identifier - - Returns: - True if unclaim was successful - """ - import requests - - url = f"{self.base_url}/unclaim-rune" - payload = {"id": rune_id} - - try: - response = requests.post(url, json=payload, timeout=self.timeout) - response.raise_for_status() - return True - except requests.RequestException as exc: - logger.error("Failed to unclaim rune %s: %s", rune_id, exc) - return False - - def fulfill_rune(self, rune_id: str) -> bool: - """Mark a rune as fulfilled. - - Args: - rune_id: Unique rune identifier - - Returns: - True if fulfill was successful - """ - import requests - - url = f"{self.base_url}/fulfill-rune" - payload = {"id": rune_id} - - try: - response = requests.post(url, json=payload, timeout=self.timeout) - response.raise_for_status() - return True - except requests.RequestException as exc: - logger.error("Failed to fulfill rune %s: %s", rune_id, exc) - return False - - def add_note(self, rune_id: str, text: str) -> bool: - """Add a note to a rune. - - Args: - rune_id: Unique rune identifier - text: Note text content - - Returns: - True if note was added successfully - """ - import requests - - url = f"{self.base_url}/add-note" - payload = {"rune_id": rune_id, "text": text} - - try: - response = requests.post(url, json=payload, timeout=self.timeout) - response.raise_for_status() - return True - except requests.RequestException as exc: - logger.error("Failed to add note to rune %s: %s", rune_id, exc) - return False diff --git a/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/config.py b/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/config.py deleted file mode 100644 index 3effc16..0000000 --- a/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/config.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Configuration for Bifrost task source.""" - -from dataclasses import dataclass -from interface_tasks.config import BaseTaskSourceConfig - - -@dataclass(frozen=True) -class BifrostTaskSourceConfig(BaseTaskSourceConfig): - """Configuration for Bifrost API task source.""" - - base_url: str = "http://localhost:8000" - timeout: int = 30 - poll_interval: int = 10 - - @classmethod - def from_dict(cls, data: dict) -> "BifrostTaskSourceConfig": - """Create config from dictionary (e.g., parsed YAML settings).""" - settings = data.get("settings") or {} - return cls( - settings=data.get("settings", {}), - base_url=settings.get("base_url", "http://localhost:8000"), - timeout=settings.get("timeout", 30), - poll_interval=settings.get("poll_interval", 10), - ) diff --git a/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/models.py b/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/models.py deleted file mode 100644 index af3a18a..0000000 --- a/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/models.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Bifrost-specific task models.""" - -from dataclasses import dataclass -from datetime import datetime -from typing import Any - -from interface_tasks.types import Task, TaskDetail, TaskStatus - - -@dataclass(frozen=True) -class BifrostTask(Task): - """Bifrost-specific task implementation.""" - - created_at: datetime | None - updated_at: datetime | None - priority: int - - @staticmethod - def from_api(data: dict[str, Any]) -> "BifrostTask": - """Create a BifrostTask from API response data.""" - return BifrostTask( - id=data.get("id", ""), - title=data.get("title", ""), - status=TaskStatus(data.get("status", "open")), - tags=data.get("tags", []), - claimant=data.get("claimant"), - raw_data=data, - created_at=_parse_datetime(data.get("created_at")), - updated_at=_parse_datetime(data.get("updated_at")), - priority=data.get("priority", 0), - ) - - -@dataclass(frozen=True) -class BifrostTaskDetail(TaskDetail): - """Bifrost-specific task detail implementation.""" - - @staticmethod - def from_api(data: dict[str, Any]) -> "BifrostTaskDetail": - """Create a BifrostTaskDetail from API response data.""" - from interface_tasks.types import DependencyRef, NoteEntry, ACEntry, RetroEntry - - task = BifrostTask.from_api(data) - - dependencies = [] - for dep in data.get("dependencies", []): - dependencies.append( - DependencyRef( - target_id=dep.get("target_id", ""), - relationship=dep.get("relationship", ""), - ) - ) - - notes = [] - for note in data.get("notes", []): - notes.append(NoteEntry(text=note.get("text", ""))) - - acceptance_criteria = [] - for ac in data.get("acceptance_criteria", []): - acceptance_criteria.append(ACEntry(text=ac.get("text", ""))) - - retro = [] - for retro_item in data.get("retro", []): - retro.append(RetroEntry(text=retro_item.get("text", ""))) - - return BifrostTaskDetail( - task=task, - description=data.get("description"), - dependencies=dependencies, - notes=notes, - acceptance_criteria=acceptance_criteria, - retro=retro, - ) - - -def _parse_datetime(dt_str: str | None) -> datetime | None: - """Parse an ISO datetime string.""" - if not dt_str: - return None - try: - return datetime.fromisoformat(dt_str.replace("Z", "+00:00")) - except (ValueError, AttributeError): - return None diff --git a/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/task_source.py b/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/task_source.py deleted file mode 100644 index 505c856..0000000 --- a/orchestrator/packages/tasks-bifrost/src/tasks_bifrost/task_source.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Bifrost task source implementation.""" - -import asyncio -import logging -from typing import AsyncIterator - -from interface_tasks import TaskSource - -from tasks_bifrost.api_client import BifrostAPIClient -from tasks_bifrost.models import BifrostTask, BifrostTaskDetail - -logger = logging.getLogger(__name__) - - -class BifrostTaskSource(TaskSource): - """Task source that polls the Bifrost API for ready runes.""" - - def __init__( - self, - base_url: str = "http://localhost:8000", - timeout: int = 30, - poll_interval: int = 10, - ) -> None: - """Initialize the Bifrost task source. - - Args: - base_url: Base URL of the Bifrost API server - timeout: Request timeout in seconds - poll_interval: Seconds between poll attempts (default 10) - """ - self.api_client = BifrostAPIClient(base_url=base_url, timeout=timeout) - self.poll_interval = poll_interval - self._seen_runes: set[str] = set() - - async def watch_tasks(self) -> AsyncIterator[BifrostTask]: - """Watch for ready tasks and yield them as they become available. - - Yields: - BifrostTask objects that are ready for execution - """ - while True: - try: - # Run the blocking API call in a thread pool - loop = asyncio.get_event_loop() - runes_data = await loop.run_in_executor( - None, self.api_client.fetch_ready_runes, self.saga - ) - - for rune_data in runes_data: - rune_id = rune_data.get("id") - if not rune_id: - continue - - # Skip if already claimed by someone else - if rune_data.get("claimant"): - continue - - # Skip if we've already yielded this rune - if rune_id in self._seen_runes: - continue - - self._seen_runes.add(rune_id) - task = BifrostTask.from_api(rune_data) - logger.info("Found ready task: %s", task.id) - yield task - - except Exception as exc: - logger.error("Error polling for ready tasks: %s", exc) - - await asyncio.sleep(self.poll_interval) - - async def get_task_detail(self, task_id: str) -> BifrostTaskDetail: - """Get detailed information about a task. - - Args: - task_id: Unique task identifier - - Returns: - BifrostTaskDetail with full task information - """ - loop = asyncio.get_event_loop() - detail_data = await loop.run_in_executor(None, self.api_client.fetch_rune_detail, task_id) - - if not detail_data: - raise ValueError(f"Task {task_id} not found") - - return BifrostTaskDetail.from_api(detail_data) - - async def claim_task(self, task_id: str, claimant: str) -> bool: - """Claim a task for execution. - - Args: - task_id: Unique task identifier - claimant: Identifier for the claimant - - Returns: - True if task was successfully claimed - """ - loop = asyncio.get_event_loop() - return await loop.run_in_executor(None, self.api_client.claim_rune, task_id, claimant) - - async def unclaim_task(self, task_id: str) -> bool: - """Unclaim a task. - - Args: - task_id: Unique task identifier - - Returns: - True if task was successfully unclaimed - """ - loop = asyncio.get_event_loop() - result = await loop.run_in_executor(None, self.api_client.unclaim_rune, task_id) - # Remove from seen set so we can pick it up again - self._seen_runes.discard(task_id) - return result - - async def complete_task(self, task_id: str) -> bool: - """Mark a task as completed. - - Args: - task_id: Unique task identifier - - Returns: - True if task was successfully marked complete - """ - loop = asyncio.get_event_loop() - result = await loop.run_in_executor(None, self.api_client.fulfill_rune, task_id) - # Remove from seen set - self._seen_runes.discard(task_id) - return result diff --git a/orchestrator/pyproject.toml b/orchestrator/pyproject.toml deleted file mode 100644 index 66b53f3..0000000 --- a/orchestrator/pyproject.toml +++ /dev/null @@ -1,42 +0,0 @@ -[project] -name = "orchestrator-workspace" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [ - "orchestrator", - "interface-engine", - "interface-tasks", - "engine-claude-code", - "tasks-bifrost", - "claude-agent-sdk", - "requests", - "pyyaml", - "watchdog", - "anyio", -] - -[tool.uv.workspace] -members = ["packages/*"] - -[tool.uv.sources] -orchestrator = { workspace = true } -interface-engine = { workspace = true } -interface-tasks = { workspace = true } -engine-claude-code = { workspace = true } -tasks-bifrost = { workspace = true } - -[dependency-groups] -dev = [ - "pytest>=7.0", - "pytest-asyncio>=0.21", - "pytest-mock>=3.10", - "ruff>=0.15.10", - "mypy>=1.0", -] - -[tool.ruff] -line-length = 100 -target-version = "py312" - -[tool.pytest.ini_options] -asyncio_mode = "auto" diff --git a/orchestrator/scripts/agent.py b/orchestrator/scripts/agent.py deleted file mode 100644 index 30b82c3..0000000 --- a/orchestrator/scripts/agent.py +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env python3 -""" -Agent entry point script. - -Orchestrates task execution: hooks → engine → follow-up loop. -Exits with code 0 (success), -2 (skip fulfill), or 1 (failure). -""" - -import json -import logging -import os -import sys - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -def main() -> int: - """Main entry point for agent execution.""" - if len(sys.argv) < 2: - print("Usage: agent.py ", file=sys.stderr) - return 1 - - agent_name = sys.argv[1] - - try: - raw = json.load(sys.stdin) - except json.JSONDecodeError as exc: - logger.error("Invalid JSON on stdin: %s", exc) - return 1 - - # Import orchestrator components - from engine_claude_code.agent_catalog.loader import AgentRegistry - from orchestrator.cli.config import find_project_root, is_verbose - from orchestrator.core.domain import RuneContext - from orchestrator.core.hook_runner import HookRunner, HookSpec - from orchestrator.core.orchestrator import AgentEntry, RuneOrchestrator - from orchestrator.core.reporting import BifrostAPIClient - from engine_claude_code.sdk_runner import ClaudeCodeEngine - - rune_data = raw.get("rune") or {} - cwd = raw.get("cwd") or find_project_root() - - context = RuneContext( - rune_id=rune_data.get("id", ""), - title=rune_data.get("title", ""), - description=rune_data.get("description"), - cwd=cwd, - tags=rune_data.get("tags", []), - raw_detail=rune_data, - ) - - # Load agent from catalog - registry = AgentRegistry() - registry.load_all() - - catalog_entry = registry.get(agent_name) - if catalog_entry is None: - logger.error( - "Unknown agent: %r (available: %s)", - agent_name, - list(registry.all().keys()), - ) - return 1 - - if not catalog_entry.definition.model: - logger.error( - "Agent %r has no model declared; add 'model:' to its frontmatter", - agent_name, - ) - return 1 - - # Convert catalog entry to orchestrator AgentEntry - hook_specs = HookSpec - - entry = AgentEntry( - name=agent_name, - model=catalog_entry.definition.model, - prompt=catalog_entry.definition.prompt, - tools=catalog_entry.definition.tools, - rune_start_hooks=[hook_specs(command=h.command) for h in catalog_entry.hooks.rune_start], - rune_stop_hooks=[hook_specs(command=h.command) for h in catalog_entry.hooks.rune_stop], - ) - - verbose = is_verbose() - api_url = os.environ.get("BIFROST_API_URL", "http://localhost:8000") - - hook_runner = HookRunner(project_dir=cwd) - engine = ClaudeCodeEngine(entry=catalog_entry, verbose=verbose) - api_client = BifrostAPIClient(base_url=api_url) - - orchestrator = RuneOrchestrator( - context=context, - entry=entry, - hook_runner=hook_runner, - engine=engine, - api_client=api_client, - verbose=verbose, - ) - - import anyio - - result = anyio.run(orchestrator.run) - - if not result.success: - return 1 - if result.skip_fulfill: - return -2 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/orchestrator/scripts/dispatcher.py b/orchestrator/scripts/dispatcher.py deleted file mode 100644 index b7af2af..0000000 --- a/orchestrator/scripts/dispatcher.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -""" -Bifrost dispatcher script. - -Reads DispatchInput JSON from stdin, extracts the worker: tag, -and writes a DispatchResult JSON to stdout pointing to agent.py. - -Exit 0 always. Empty command = skip (unclaim) the rune. -""" - -import json -import sys -from pathlib import Path - - -def main() -> None: - if "--list-agents" in sys.argv: - _list_agents() - return - - try: - dispatch_input = json.load(sys.stdin) - except json.JSONDecodeError as exc: - print(f"dispatcher: invalid JSON on stdin: {exc}", file=sys.stderr) - sys.exit(1) - - # Extract rune and cwd from DispatchInput { rune, cwd } - rune = dispatch_input.get("rune", {}) - cwd = dispatch_input.get("cwd", "") - - tags: list[str] = rune.get("tags") or [] - agent_name: str | None = None - for tag in tags: - if tag.startswith("worker:"): - agent_name = tag[len("worker:") :] - break - - if not agent_name: - # No worker tag — tell CLI to skip (unclaim) this rune - _emit({"command": "", "args": [], "stdin": "", "env": {}}) - return - - script_dir = Path(__file__).parent - agent_script = script_dir / "agent.py" - - # Pass { rune, cwd } to agent - agent_input = { - "rune": rune, - "cwd": cwd, - } - - # Use uv to run the agent script - _emit( - { - "command": "uv", - "args": ["run", "--directory", str(script_dir.parent), str(agent_script), agent_name], - "stdin": json.dumps(agent_input), - "env": {}, - } - ) - - -def _list_agents() -> None: - """List available agents from the agent catalog.""" - from engine_claude_code.agent_catalog.loader import AgentRegistry - - registry = AgentRegistry() - registry.load_all() - agents = registry.all() - - if not agents: - print("No agents found.", file=sys.stderr) - return - - for name, entry in sorted(agents.items()): - defn = entry.definition - hooks = entry.hooks - print(f" {name}") - if defn.description: - print(f" description: {defn.description}") - if defn.model: - print(f" model: {defn.model}") - if defn.tools: - print(f" tools: {', '.join(defn.tools)}") - if hooks.rune_start: - print(f" rune_start_hooks: {', '.join(str(p) for p in hooks.rune_start)}") - if hooks.rune_stop: - print(f" rune_stop_hooks: {', '.join(str(p) for p in hooks.rune_stop)}") - - -def _emit(result: dict) -> None: - print(json.dumps(result)) - - -if __name__ == "__main__": - main() diff --git a/orchestrator/tsconfig.base.json b/orchestrator/tsconfig.base.json new file mode 100644 index 0000000..22a206b --- /dev/null +++ b/orchestrator/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "lib": ["ESNext"], + "types": ["node"], + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true + }, + "exclude": ["node_modules", "dist"] +} diff --git a/orchestrator/tsconfig.json b/orchestrator/tsconfig.json new file mode 100644 index 0000000..846ae6e --- /dev/null +++ b/orchestrator/tsconfig.json @@ -0,0 +1,16 @@ +{ + "references": [ + { + "path": "./packages/cli" + }, + { + "path": "./packages/core" + }, + { + "path": "./packages/engine" + }, + { + "path": "./packages/task-source" + } + ] +} \ No newline at end of file diff --git a/orchestrator/uv.lock b/orchestrator/uv.lock deleted file mode 100644 index a3a79d2..0000000 --- a/orchestrator/uv.lock +++ /dev/null @@ -1,1136 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.12" -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version < '3.15'", -] - -[manifest] -members = [ - "engine-claude-code", - "interface-engine", - "interface-tasks", - "orchestrator", - "orchestrator-workspace", - "tasks-bifrost", -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, -] - -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "certifi" -version = "2026.4.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, -] - -[[package]] -name = "claude-agent-sdk" -version = "0.1.72" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "mcp" }, - { name = "sniffio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5d/b7/ce35a79f4891797ef60b5cf437453bf22e3e420f5946007312c9e144f5e0/claude_agent_sdk-0.1.72.tar.gz", hash = "sha256:e737f919301d5fc65a3ebc6f438911b73e237b16fa9fa3061420e79727cfa307", size = 241286, upload-time = "2026-05-01T02:17:34.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/de/098dd15dec8ce3c5ed9c9c2415a290fb5c24ad9cd1214cfc3e20c3be0342/claude_agent_sdk-0.1.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a16ee041db0734e8f82d1fca7bd7c122227c0d8c150a301b365ab3f0a83a27b", size = 63695454, upload-time = "2026-05-01T02:17:38.468Z" }, - { url = "https://files.pythonhosted.org/packages/55/de/bea82fdd65244bab9cf6aa3492c2b6cc5d97213836730febd89c0a342975/claude_agent_sdk-0.1.72-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:4b3c630b5b07cd4f3d57631d833539b77045937093ec4975f47ee29de6b9a9df", size = 65539313, upload-time = "2026-05-01T02:17:43.129Z" }, - { url = "https://files.pythonhosted.org/packages/1c/26/78e45a08c4acb262b8f99f6885fb5b96ccf32f27be008610403b86cc3da8/claude_agent_sdk-0.1.72-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:6fd7c6cbab95928ceb39fe82f413bd124e83dc5ed9bf63b6dffb9dc2b83f67bc", size = 76872182, upload-time = "2026-05-01T02:17:48.616Z" }, - { url = "https://files.pythonhosted.org/packages/58/6d/9641f9a3119adec03d33cb40be8a194801cde0c15b3c497f07e36b38dab1/claude_agent_sdk-0.1.72-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:e2cf7beb573b608832cf2c644b330e51b79cfdab85286f064cf92f8f63c66382", size = 77032401, upload-time = "2026-05-01T02:17:54.322Z" }, - { url = "https://files.pythonhosted.org/packages/88/a1/35eeda6bac78c5c236c0f3c36fb8634503514ff78ada4e46b77397922ed7/claude_agent_sdk-0.1.72-py3-none-win_amd64.whl", hash = "sha256:9159ac974b496bb50d05027f49b44b76a595f1b4df3bfdef1136b56c95fc956d", size = 78421027, upload-time = "2026-05-01T02:18:00.614Z" }, -] - -[[package]] -name = "click" -version = "8.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "cryptography" -version = "47.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, - { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, -] - -[[package]] -name = "engine-claude-code" -version = "0.1.0" -source = { editable = "packages/engine-claude-code" } -dependencies = [ - { name = "claude-agent-sdk" }, - { name = "interface-engine" }, - { name = "watchdog" }, -] - -[package.metadata] -requires-dist = [ - { name = "claude-agent-sdk" }, - { name = "interface-engine", editable = "packages/interface-engine" }, - { name = "watchdog" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, -] - -[[package]] -name = "idna" -version = "3.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "interface-engine" -version = "0.1.0" -source = { editable = "packages/interface-engine" } - -[[package]] -name = "interface-tasks" -version = "0.1.0" -source = { editable = "packages/interface-tasks" } - -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - -[[package]] -name = "librt" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" }, - { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" }, - { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" }, - { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" }, - { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" }, - { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, - { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, - { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, - { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, - { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, - { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, - { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, - { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, - { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, - { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, - { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, - { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, - { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, -] - -[[package]] -name = "mcp" -version = "1.27.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, -] - -[[package]] -name = "mypy" -version = "1.20.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, - { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, - { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, - { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, - { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, - { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, - { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, - { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "orchestrator" -version = "0.1.0" -source = { editable = "packages/orchestrator" } -dependencies = [ - { name = "interface-engine" }, - { name = "interface-tasks" }, - { name = "pyyaml" }, -] - -[package.metadata] -requires-dist = [ - { name = "interface-engine", editable = "packages/interface-engine" }, - { name = "interface-tasks", editable = "packages/interface-tasks" }, - { name = "pyyaml" }, -] - -[[package]] -name = "orchestrator-workspace" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "anyio" }, - { name = "claude-agent-sdk" }, - { name = "engine-claude-code" }, - { name = "interface-engine" }, - { name = "interface-tasks" }, - { name = "orchestrator" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "tasks-bifrost" }, - { name = "watchdog" }, -] - -[package.dev-dependencies] -dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-mock" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio" }, - { name = "claude-agent-sdk" }, - { name = "engine-claude-code", editable = "packages/engine-claude-code" }, - { name = "interface-engine", editable = "packages/interface-engine" }, - { name = "interface-tasks", editable = "packages/interface-tasks" }, - { name = "orchestrator", editable = "packages/orchestrator" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "tasks-bifrost", editable = "packages/tasks-bifrost" }, - { name = "watchdog" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "mypy", specifier = ">=1.0" }, - { name = "pytest", specifier = ">=7.0" }, - { name = "pytest-asyncio", specifier = ">=0.21" }, - { name = "pytest-mock", specifier = ">=3.10" }, - { name = "ruff", specifier = ">=0.15.10" }, -] - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - -[[package]] -name = "pydantic" -version = "2.13.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, - { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, - { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, - { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, - { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, - { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, - { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, - { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, - { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, - { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, - { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, - { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, - { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, - { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, - { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, - { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, - { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pyjwt" -version = "2.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, -] - -[[package]] -name = "pytest-mock" -version = "3.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.27" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, -] - -[[package]] -name = "pywin32" -version = "311" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - -[[package]] -name = "requests" -version = "2.33.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, -] - -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, -] - -[[package]] -name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "sse-starlette" -version = "3.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/9a/f35932a8c0eb6b2287b66fa65a0321df8c84e4e355a659c1841a37c39fdb/sse_starlette-3.4.1.tar.gz", hash = "sha256:f780bebcf6c8997fe514e3bd8e8c648d8284976b391c8bed0bcb1f611632b555", size = 35127, upload-time = "2026-04-26T13:32:32.292Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/07/45c21ed03d708c477367305726b89919b020a3a2a01f72aaf5ad941caf35/sse_starlette-3.4.1-py3-none-any.whl", hash = "sha256:6b43cf21f1d574d582a6e1b0cfbde1c94dc86a32a701a7168c99c4475c6bd1d0", size = 16487, upload-time = "2026-04-26T13:32:30.819Z" }, -] - -[[package]] -name = "starlette" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, -] - -[[package]] -name = "tasks-bifrost" -version = "0.1.0" -source = { editable = "packages/tasks-bifrost" } -dependencies = [ - { name = "interface-tasks" }, - { name = "requests" }, -] - -[package.metadata] -requires-dist = [ - { name = "interface-tasks", editable = "packages/interface-tasks" }, - { name = "requests" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.46.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, -] - -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, -] diff --git a/orchestrator/vitest.config.ts b/orchestrator/vitest.config.ts new file mode 100644 index 0000000..d87fc4a --- /dev/null +++ b/orchestrator/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}) From dbb06b20e343d4ce3f59695d48061ae66c191afc Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Thu, 7 May 2026 16:42:24 -0500 Subject: [PATCH 04/33] bifrost plugin --- task-source-prd.md | 785 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 785 insertions(+) create mode 100644 task-source-prd.md diff --git a/task-source-prd.md b/task-source-prd.md new file mode 100644 index 0000000..53a4106 --- /dev/null +++ b/task-source-prd.md @@ -0,0 +1,785 @@ +# Bifrost Task Source PRD + +**Status:** Draft +**Authors:** Eric Siebeneich +**Date:** 2026-05-07 +**Version:** 1.0 + +--- + +## Product Description, Problem, and Goal + +### Product Description + +The **Bifrost Task Source** is a plugin implementation of the Orchestrator Framework's `TaskSource` interface that connects to a **Bifrost** server—the event-sourced rune management service. It enables orchestrator instances to discover, claim, and fulfill **runes** (work items) stored in Bifrost, providing seamless integration between AI agent orchestration and the Bifrost task management system. + +**Key Terms:** + +- **Bifrost**: Event-sourced rune (work item) management service for AI agents +- **Rune**: A work item in Bifrost (issue, task, bug, feature, etc.) +- **Saga**: An epic; a collection of related runes in Bifrost +- **Realm**: A tenant namespace in Bifrost for organizing runes with credentials +- **Task Source**: Orchestrator Framework plugin that discovers, claims, and fulfills tasks from external systems +- **Claimant**: Identifier for the agent instance currently working on a task +- **Rune State**: The lifecycle state of a rune (draft, forged, open, claimed, fulfilled, sealed) +- **PAT**: Personal Access Token used for authentication with Bifrost +- **Orchestrator**: TypeScript-based distributed task execution system that coordinates AI agents +- **taskState**: Free-form object containing all context for a single task execution + +### Problem + +Sarah has deployed the Orchestrator Framework to automate code maintenance across her monorepo. Her team uses Bifrost for rune management—developers create runes for features, bugs, and refactors. She wants her AI agents to automatically pick up and work on these runes, but she faces three problems: + +1. **No integration**: The orchestrator's default API task source doesn't understand Bifrost's rune lifecycle, state model, or event-sourced architecture +2. **No coordination**: Multiple orchestrator instances might claim the same rune, causing duplicate work and race conditions +3. **No state synchronization**: When an agent completes work, the rune state in Bifrost isn't automatically updated—developers must manually mark runes as fulfilled + +Marcus, Sarah's teammate, has written custom scripts to poll Bifrost's HTTP API and dispatch work to agents. These scripts are fragile—they don't handle reconnection, don't respect Bifrost's claim semantics, and error handling is ad-hoc. When the Bifrost server is temporarily unavailable, the scripts crash and lose track of which runes were being processed. When two script instances run simultaneously, they both claim the same rune, wasting compute resources and creating conflicting code changes. + +Sarah spends time manually deconflicting agent work, restarting crashed scripts, and syncing rune states back to Bifrost. She can't reliably scale her automation because the integration layer is a house of cards. + +### Goal + +With the Bifrost Task Source plugin, Sarah configures her orchestrator instances to connect directly to her Bifrost server. The plugin: + +1. Authenticates using a PAT with realm-scoped permissions +2. Polls for runes in the `open` state that are ready for work +3. Uses Bifrost's native claim API to ensure exclusive ownership (no duplicate work) +4. Maps Bifrost runes to orchestrator Tasks with full metadata (tags, dependencies, notes) +5. Executes agents via the orchestrator framework +6. Automatically updates rune state to `fulfilled` on success, `open` on recoverable errors +7. Handles Bifrost server unavailability with exponential backoff reconnection +8. Emits structured telemetry for every operation (claim, execution, completion) + +Marcus removes his fragile scripts. The orchestrator instances now coordinate seamlessly through Bifrost's claim semantics. When a rune is completed, its state is automatically updated—developers see real-time progress in the Bifrost UI. Sarah can deploy multiple orchestrator instances knowing they won't duplicate work. The system is reliable, observable, and requires no custom glue code. + +--- + +## User Stories / Use Cases + +### US-1: Configure orchestrator to use Bifrost Task Source + +**As a** platform engineer +**I want** to configure the orchestrator to use the Bifrost Task Source with connection details and credentials +**So that** orchestrator instances can connect to my Bifrost server + +**Acceptance Criteria:** + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_source.type is "bifrost" +And orchestrate.task_source.settings.baseUrl is "https://bifrost.example.com" +And orchestrate.task_source.settings.realm is "my-project" +And orchestrate.task_source.settings.pat is a valid PAT +When the orchestrator loads configuration +Then a BifrostTaskSource is created with the specified baseUrl, realm, and PAT +And the BifrostTaskSource connects to the Bifrost server +``` + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_source.settings.pollInterval is 30 +When the orchestrator loads configuration +Then the BifrostTaskSource polls for ready runes every 30 seconds +``` + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_source.settings.claimant is "orchestrator-prod-1" +When the orchestrator loads configuration +Then the BifrostTaskSource uses "orchestrator-prod-1" as the claimant identifier for all rune claims +``` + +### US-2: Poll for ready runes and claim them + +**As an** orchestrator instance +**I want** to poll Bifrost for runes in the `open` state that are ready for work +**So that** I can claim and execute them + +**Acceptance Criteria:** + +``` +Given the Bifrost Task Source is configured +And one or more runes exist in the configured realm with state "open" +And all rune dependencies are satisfied (no blocking runes) +When the poll interval elapses +Then the Bifrost Task Source queries Bifrost for ready runes +And each ready rune is yielded via the async iterator +``` + +``` +Given a ready rune is yielded from the async iterator +When the orchestrator receives the rune +Then the Bifrost Task Source claims the rune via Bifrost's claim API +And the rune state in Bifrost transitions to "claimed" +And the claimant field is set to the configured claimant identifier +``` + +``` +Given two orchestrator instances poll simultaneously +And the same rune is ready for work +When both instances attempt to claim the rune +Then only one instance successfully claims the rune +And the other instance receives a conflict error +And the conflict error is logged +And the unsuccessful instance does not process the rune +``` + +``` +Given the Bifrost server is unreachable +When the Bifrost Task Source attempts to poll +Then the poll attempt is logged as failed +And the Task Source waits for the configured poll interval before retrying +``` + +### US-3: Map Bifrost runes to orchestrator Tasks + +**As the** orchestrator framework +**I want** Bifrost runes to be mapped to the Task interface with all relevant metadata +**So that** agents can access rune context + +**Acceptance Criteria:** + +``` +Given a claimed Bifrost rune with title "Fix login bug" +And the rune has description "Users cannot authenticate" +And the rune has tags ["bug", "auth", "priority:high"] +And the rune has priority 2 +When the rune is mapped to an orchestrator Task +Then Task.id is the rune ID +And Task.title is "Fix login bug" +And Task.description is "Users cannot authenticate" +And Task.tags is ["bug", "auth", "priority:high", "worker:implementer"] (worker tag added if not present) +And Task.priority is 2 +And Task.status is "IN_PROGRESS" +And Task.claimant is the configured claimant identifier +``` + +``` +Given a Bifrost rune with acceptance criteria +And the rune has dependencies on other runes +And the rune has notes from developers +When the rune is mapped to an orchestrator TaskDetail +Then TaskDetail.acceptanceCriteria contains all AC entries +And TaskDetail.dependencies contains all DependencyRef entries +And TaskDetail.notes contains all NoteEntry entries +``` + +``` +Given a Bifrost rune with branch tracking enabled +And the rune is associated with branch "feature/fix-login" +When the rune is mapped to an orchestrator Task +Then Task.metadata contains the branch name +And agents can access the branch information via taskState +``` + +### US-4: Complete rune on successful agent execution + +**As the** orchestrator framework +**I want** to mark a rune as fulfilled when the agent completes successfully +**So that** developers see the updated state in Bifrost + +**Acceptance Criteria:** + +``` +Given a rune has been claimed and processed by an agent +And the agent execution completed successfully +When the orchestrator calls completeTask(taskId) +Then the Bifrost Task Source calls Bifrost's fulfill API +And the rune state transitions to "fulfilled" +And a completion note is added with execution telemetry (duration, tokens used, cost) +And the method returns true +``` + +``` +Given a rune has been claimed and processed +And the agent execution failed with a recoverable error +When the orchestrator calls failTask(taskId, error) +Then the Bifrost Task Source calls Bifrost's unclaim API or reopen equivalent +And the rune state transitions to "open" +And an error note is added with the failure reason +And the method returns true +``` + +``` +Given the Bifrost server is unreachable when completing a task +When the orchestrator calls completeTask(taskId) +Then the method throws an error +And the orchestrator logs the failure +And the orchestrator marks the task as failed in its own records +``` + +### US-5: Handle Bifrost server unavailability + +**As an** orchestrator operator +**I want** the Bifrost Task Source to handle server unavailability gracefully +**So that** temporary outages don't crash the orchestrator + +**Acceptance Criteria:** + +``` +Given the Bifrost Task Source is polling for tasks +And the Bifrost server becomes unreachable +When a poll attempt fails +Then the error is logged with server URL and timestamp +And the Task Source waits for the configured poll interval +And the Task Source retries polling +``` + +``` +Given the Bifrost Task Source async iterator is active +And a network error occurs during iteration +When the error is detected +Then the async iterator does not terminate +And the error is logged +And polling continues on the next interval +``` + +``` +Given the Bifrost server has been unavailable for 5 minutes +And the server becomes available again +When the next poll occurs +Then the Bifrost Task Source successfully connects +And polling resumes normally +And a reconnection log entry is written +``` + +### US-6: Filter runes by agent worker tags + +**As an** orchestrator operator +**I want** to filter runes based on worker tags so that specialized agents only receive relevant work +**So that** implementer agents don't receive tester-tagged runes + +**Acceptance Criteria:** + +``` +Given a Bifrost Task Source configured with workerTagFilter: ["implementer", "reviewer"] +And a rune has tags ["bug", "worker:tester"] +When the Bifrost Task Source polls for ready runes +Then the rune with "worker:tester" is not yielded +``` + +``` +Given a Bifrost Task Source configured with workerTagFilter: ["implementer"] +And a rune has tags ["feature", "worker:implementer"] +When the Bifrost Task Source polls for ready runes +Then the rune is yielded +``` + +``` +Given a Bifrost Task Source without workerTagFilter configured +And a rune has any worker tag +When the Bifrost Task Source polls for ready runes +Then all ready runes are yielded regardless of worker tag +``` + +### US-7: Emit telemetry for all operations + +**As a** platform engineer +**I want** detailed telemetry emitted for all Bifrost Task Source operations +**So that** I can monitor integration health and debug issues + +**Acceptance Criteria:** + +``` +Given the Bifrost Task Source successfully polls for ready runes +When the poll completes +Then a log entry is emitted with: poll timestamp, number of ready runes found, realm name +``` + +``` +Given the Bifrost Task Source claims a rune +When the claim API call succeeds +Then a log entry is emitted with: rune ID, claimant, timestamp +``` + +``` +Given the Bifrost Task Source fails to claim a rune due to conflict +When the conflict error is received +Then a log entry is emitted with: rune ID, conflict reason, current claimant +``` + +``` +Given the Bifrost Task Source completes a rune +When the fulfill API call succeeds +Then a log entry is emitted with: rune ID, fulfillment timestamp, execution telemetry +``` + +--- + +## Functional Requirements + +### FR-1: Configuration Schema + +The Bifrost Task Source MUST support the following configuration: + +```yaml +orchestrate: + task_source: + type: "bifrost" + settings: + baseUrl: string # Bifrost server URL (e.g., "https://bifrost.example.com") + realm: string # Realm name to poll for runes + pat: string # Personal Access Token for authentication + claimant: string # Claimant identifier for this orchestrator instance + pollInterval: number # Polling interval in milliseconds (default: 10000) + timeout: number # Request timeout in milliseconds (default: 30000) + workerTagFilter: string[] # Optional list of worker tags to filter (default: no filter) +``` + +### FR-2: BifrostTaskSource Implementation + +The Bifrost Task Source MUST implement the `TaskSource` interface: + +```typescript +export type TaskSource = { + watchTasks: () => AsyncGenerator + getTaskDetail: (taskId: string) => Promise + completeTask: (taskId: string) => Promise + failTask: (taskId: string, error: string) => Promise +} +``` + +### FR-3: Rune to Task Mapping + +The Bifrost Task Source MUST map Bifrost runes to orchestrator Tasks with the following field mappings: + +| Bifrost Field | Task Field | Notes | +|---------------|------------|-------| +| `id` | `id` | Direct mapping | +| `title` | `title` | Direct mapping | +| `description` | `description` | Direct mapping | +| `state` | `status` | Mapped: "claimed" → "IN_PROGRESS" | +| `tags` | `tags` | Direct mapping, plus worker tag inference | +| `claimant` | `claimant` | Direct mapping | +| `created_at` | `createdAt` | Date parsing | +| `updated_at` | `updatedAt` | Date parsing | +| `priority` | `priority` | Direct mapping | +| `branch` | `metadata.branch` | Stored in metadata object | +| N/A | `tags[]` | "worker:X" added if not present (inferred from agent catalog) | + +### FR-4: Ready Rune Query + +The Bifrost Task Source MUST query for runes that meet ALL of the following criteria: + +- `state == "open"` (rune is ready to be claimed) +- All dependency runes in the `blocks` relationship are in state `fulfilled` or `sealed` +- No circular dependencies exist (enforced by Bifrost) +- If `workerTagFilter` is configured, rune has a tag matching one of the filter values + +The query MUST use Bifrost's `bf ready` equivalent API endpoint. + +### FR-5: Claim Semantics + +The Bifrost Task Source MUST use Bifrost's native claim API to atomically claim runes. The claim operation MUST: + +- Be atomic (no race conditions between multiple orchestrator instances) +- Set the rune state to `claimed` +- Set the rune's claimant field to the configured claimant identifier +- Return an error if the rune is already claimed by another claimant + +On claim conflict, the Bifrost Task Source MUST log the conflict and NOT yield the rune to the orchestrator. + +### FR-6: Completion and Failure Handling + +On successful agent execution, the Bifrost Task Source MUST: + +- Call Bifrost's fulfill API (equivalent to `bf fulfill`) +- Transition the rune state to `fulfilled` +- Add a completion note containing execution telemetry + +On failed agent execution, the Bifrost Task Source MUST: + +- Determine if the error is recoverable (exit code 1) or fatal (exit code 2) +- For recoverable errors: call Bifrost's unclaim/reopen API to return the rune to `open` state +- For fatal errors: leave the rune in `claimed` state and add an error note +- Add a note with the error message + +### FR-7: Authentication + +The Bifrost Task Source MUST authenticate using Bearer token authentication: + +- Include the PAT in the `Authorization` header as `Bearer ` +- Include the realm name in the `X-Bifrost-Realm` header +- Handle 401/403 responses by logging authentication failures + +### FR-8: Reconnection Behavior + +If the Bifrost server becomes unreachable, the Bifrost Task Source MUST: + +- Log the connection error with timestamp and server URL +- Continue polling at the configured interval (no exponential backoff for v1) +- Successfully reconnect when the server becomes available +- Log successful reconnection + +The `watchTasks()` async iterator MUST NOT terminate on network errors. + +### FR-9: Worker Tag Inference + +If a Bifrost rune does NOT have a `worker:*` tag, the Bifrost Task Source MUST: + +- Examine the rune's other tags to infer an appropriate worker tag +- Use the following inference rules: + - Tags containing `test` → `worker:tester` + - Tags containing `review` → `worker:reviewer` + - Tags containing `debug` or `fix` or `bug` → `worker:debugger` + - Default → `worker:implementer` +- Add the inferred worker tag to the Task.tags array + +This inference allows existing runes without worker tags to be routed appropriately. + +### FR-10: Branch Metadata + +If a Bifrost rune has an associated Git branch, the Bifrost Task Source MUST: + +- Include the branch name in `Task.metadata.branch` +- Agents can access this via `taskState.metadata.branch` for checkout operations + +### FR-11: Dependency Mapping + +Bifrost dependency relationships MUST be mapped to orchestrator TaskDetail: + +| Bifrost Relationship | TaskDetail.dependencies[] | +|----------------------|---------------------------| +| `blocks` | `{ taskId, type: "blocks" }` | +| `relates_to` | `{ taskId, type: "relates_to" }` | +| `duplicates` | `{ taskId, type: "duplicates" }` | +| `supersedes` | `{ taskId, type: "supersedes" }` | +| `replies_to` | `{ taskId, type: "replies_to" }` | + +### FR-12: Notes and Retro Mapping + +Bifrost notes and retro entries MUST be mapped to TaskDetail: + +- Notes → `TaskDetail.notes[]` with `{ id, content, createdAt }` +- Retro entries → `TaskDetail.retro[]` with `{ id, content, createdAt }` + +--- + +## Non-Functional Requirements + +### NFR-1: Performance + +- Polling interval MUST be configurable (default 10 seconds) +- API request timeout MUST be configurable (default 30 seconds) +- Rune to Task mapping MUST complete in under 10ms per rune +- Bifrost API calls MUST complete within the configured timeout + +### NFR-2: Reliability + +- The Bifrost Task Source MUST handle Bifrost server unavailability without crashing +- The Bifrost Task Source MUST log all failures without terminating the async iterator +- Claim conflicts MUST be handled gracefully (no duplicate work) +- Network errors MUST be logged and retried on next poll + +### NFR-3: Monitoring and Observability + +- All Bifrost API calls MUST be logged with: endpoint, status code, duration +- All claim operations MUST be logged with: rune ID, claimant, success/failure +- All fulfill/unclaim operations MUST be logged with: rune ID, result +- Poll operations MUST be logged with: realm, ready rune count +- Reconnection events MUST be logged + +### NFR-4: Concurrency + +- Multiple orchestrator instances MUST be able to poll simultaneously +- Bifrost's claim API ensures only one instance claims each rune +- No additional locking is required in the Bifrost Task Source + +### NFR-5: Error Handling + +- Authentication failures (401/403) MUST be logged and polling must continue +- Invalid PAT MUST be detected and logged on startup +- Realm not found MUST be logged and polling must continue +- Malformed API responses MUST be logged and the rune must be skipped + +### NFR-6: Security + +- PATs MUST be transmitted via HTTPS only +- PATs MUST NOT be logged under any circumstances +- The Bifrost Task Source MUST validate SSL certificates + +### NFR-7: Compatibility + +- The Bifrost Task Source MUST be compatible with Bifrost server version ≥ 1.0 +- The Bifrost Task Source MUST handle API version changes gracefully for minor version bumps + +--- + +## Data & Storage + +### Commands + +**PollReadyRunes** +- `realm: string`, `claimant: string`, `workerTagFilter: string[] | null` +- Occurs when: Poll interval elapses + +**ClaimRune** +- `runeId: string`, `claimant: string` +- Occurs when: Ready rune is identified + +**MapRuneToTask** +- `rune: BifrostRune`, `inferredWorkerTag: string | null` +- Occurs when: Claimed rune is yielded to orchestrator + +**FulfillRune** +- `runeId: string`, `telemetry: ExecutionStats` +- Occurs when: Agent completes successfully + +**UnclaimRune** +- `runeId: string`, `error: string` +- Occurs when: Agent fails with recoverable error + +**AddRuneNote** +- `runeId: string`, `content: string` +- Occurs when: Completion or error note is added + +### Events + +**ReadyRunesPolled** +- `realm: string`, `readyRuneCount: number`, `pollTimestamp: ISO8601` + +**RuneClaimed** +- `runeId: string`, `claimant: string`, `claimedAt: ISO8601` + +**RuneClaimConflict** +- `runeId: string`, `attemptedBy: string`, `currentClaimant: string`, `conflictAt: ISO8601` + +**RuneFulfilled** +- `runeId: string`, `claimant: string`, `telemetry: ExecutionStats`, `fulfilledAt: ISO8601` + +**RuneUnclaimed** +- `runeId: string`, `reason: string`, `unclaimedAt: ISO8601` + +**BifrostApiCallFailed** +- `endpoint: string`, `statusCode: number`, `error: string`, `timestamp: ISO8601` + +**BifrostServerReconnecting** +- `baseUrl: string`, `lastError: string`, `reattemptDelay: number`, `reattemptAt: ISO8601` + +**BifrostServerReconnected** +- `baseUrl: string`, `downtimeDuration: number`, `reconnectedAt: ISO8601` + +### Aggregates + +**BifrostRune** +```typescript +type BifrostRune = { + id: string + title: string + description: string | null + state: RuneState + tags: string[] + claimant: string | null + createdAt: Date + updatedAt: Date + priority: number + branch: string | null + realmId: string +} + +type RuneState = "draft" | "forged" | "open" | "claimed" | "fulfilled" | "sealed" +``` + +**BifrostRuneDetail** +```typescript +type BifrostRuneDetail = BifrostRune & { + dependencies: BifrostDependency[] + notes: BifrostNote[] + acceptanceCriteria: BifrostAC[] + retro: BifrostRetroEntry[] +} + +type BifrostDependency = { + taskId: string + type: "blocks" | "relates_to" | "duplicates" | "supersedes" | "replies_to" +} + +type BifrostNote = { + id: string + content: string + createdAt: Date +} + +type BifrostAC = { + id: string + criteria: string + satisfied: boolean +} + +type BifrostRetroEntry = { + id: string + content: string + createdAt: Date +} +``` + +**BifrostTaskSourceConfig** +```typescript +type BifrostTaskSourceConfig = { + baseUrl: string + realm: string + pat: string + claimant: string + pollInterval: number + timeout: number + workerTagFilter: string[] | null +} +``` + +### Query Projections + +**ReadyRunesQuery** +- Question: Which runes in this realm are ready to be claimed? +- Projection: List of `BifrostRune` filtered by state="open", satisfied dependencies, realm +- Used by: Poll operation + +**RuneDetailQuery** +- Question: What is the full detail for a specific rune? +- Projection: `BifrostRuneDetail` by rune ID +- Used by: getTaskDetail, agent context + +**ClaimantStatusQuery** +- Question: Which runes are currently claimed by this orchestrator instance? +- Projection: List of `BifrostRune` filtered by claimant and state="claimed" +- Used by: Status reporting, recovery + +### Data Retention + +- Bifrost Task Source does NOT persist data locally +- All rune state is stored in Bifrost server +- Event logs for telemetry follow orchestrator framework retention policy (90 days) + +--- + +## Out of Scope + +- Real-time push notifications (polling only, no webhooks in v1) +- Custom claim semantics (uses Bifrost's built-in claim API) +- Multi-realm polling (one task source instance = one realm) +- Rune creation from the orchestrator (runes are created externally) +- Saga-level orchestration (individual runes only) +- Automatic retry on failure (manual retry only via Bifrost UI or CLI) +- Bifrost server administration (no realm/account management from task source) +- Migration tools (no import from other task systems) +- Advanced scheduling (FIFO polling based on Bifrost's ready query order) +- Custom priority handling (Bifrost priority is informational only) +- Branch creation (branch must already exist or be created externally) +- Dependency resolution visualization (no graph generation) + +--- + +## Dependencies and Assumptions + +### Dependencies + +| Dependency | Purpose | Version | +|---|---|---|---| +| Bifrost Server | Rune management backend | ≥ 1.0 | +| Orchestrator Framework | Core orchestration system | 1.0 | +| TypeScript Runtime | Task source execution | ≥ 24 | +| Node.js fetch API | HTTP requests to Bifrost | Built-in | + +### Assumptions + +1. Bifrost server is accessible via HTTPS from the orchestrator runtime environment +2. A valid PAT with at least `member` role in the target realm is available +3. Bifrost server's claim API is atomic and prevents duplicate claims +4. The realm specified in configuration exists +5. Network connectivity allows periodic polling at the configured interval +6. Bifrost server's `bf ready` equivalent API endpoint exists and returns ready runes +7. Bifrost server supports fulfill and unclaim/reopen API endpoints +8. Time synchronization between orchestrator and Bifrost server is adequate (clock skew < 1 minute) +9. The orchestrator framework provides the TaskSource interface contract +10. Agent catalog provides available worker tags for inference + +### External System Assumptions + +- **Bifrost Server**: Provides HTTP API for rune CRUD operations, claim/unclaim, fulfill, and ready query. API is versioned and backward-compatible for minor versions. Supports PAT authentication and realm-scoped queries. +- **Orchestrator Framework**: Provides TaskSource interface, defines Task and TaskDetail types, handles agent dispatch, and manages hook execution. + +--- + +## Open Questions + +### OQ-1: Worker tag inference vs. explicit requirement + +**Ambiguity**: Should the Bifrost Task Source infer worker tags for runes that don't have them, or should such runes be rejected? + +**Assumption**: Infer worker tags based on existing tags using the heuristic rules specified in FR-9. + +**Ideal Solution**: Infer worker tags for backward compatibility with existing runes. This allows existing Bifrost projects without worker tags to work with the orchestrator without manual data migration. + +**Alternatives**: +1. **Reject runes without worker tags**: Fails fast and forces explicit tagging, but breaks existing workflows and requires manual migration of all runes. +2. **Default all runes to `worker:implementer`**: Simpler than inference but may misroute specialized work (test runes would go to implementers). + +**Comparison**: Inference provides the best balance of backward compatibility and correct routing. Rejection breaks existing workflows. Defaulting to implementer causes misrouting. The heuristic approach in FR-9 covers common tagging patterns while allowing explicit tags to override. + +--- + +### OQ-2: Recoverable vs. fatal error handling + +**Ambiguity**: When an agent fails, how should the task source determine if the error is recoverable (return rune to pool) vs. fatal (leave claimed)? + +**Assumption**: Use the orchestrator's hook exit code convention: exit code 1 = recoverable (follow-up possible), exit code 2 = fatal. + +**Ideal Solution**: Respect the orchestrator's hook exit code contract. Exit code 1 indicates a recoverable issue that might be resolved by another agent or same agent with follow-up. Exit code 2 indicates a fatal error that requires human intervention. + +**Alternatives**: +1. **Always return runes to pool on failure**: Maximizes retry but may retry hopeless failures indefinitely. +2. **Never return runes to pool**: Minimizes noise but requires manual intervention for all failures. +3. **Add explicit error classification in API**: Requires Bifrost server changes and additional configuration. + +**Comparison**: Using the existing exit code convention requires no new configuration or Bifrost changes. Always returning to pool creates retry loops. Never returning creates operational overhead. The exit code approach balances automation with appropriate stopping conditions. + +--- + +### OQ-3: Polling interval default + +**Ambiguity**: What should the default polling interval be? Too frequent wastes resources; too infrequent increases latency. + +**Assumption**: Default to 10 seconds, matching the orchestrator framework's default. + +**Ideal Solution**: Use 10 seconds as default, make it configurable. This provides a good balance between responsiveness and resource usage for most workloads. + +**Alternatives**: +1. **Default to 30 seconds**: Reduces API load but increases latency for new runes. +2. **Default to 5 seconds**: Reduces latency but increases API load significantly. +3. **Adaptive polling based on rune availability**: More complex but optimizes for both scenarios. + +**Comparison**: 10 seconds is a proven default in the orchestrator framework. 30 seconds adds noticeable delay. 5 seconds creates excessive load for large deployments. Adaptive polling is complex and error-prone for v1. + +--- + +### OQ-4: Handling Bifrost API version changes + +**Ambiguity**: How should the task source handle Bifrost API version changes, especially field renames or endpoint changes? + +**Assumption**: Assume backward compatibility for minor Bifrost version bumps. Major version bumps will require task source updates. + +**Ideal Solution**: Document the minimum supported Bifrost version (≥ 1.0). Assume semantic versioning—minor updates are backward-compatible, major updates may require task source updates. + +**Alternatives**: +1. **Runtime API version detection**: Query Bifrost for its version and adapt behavior dynamically. Adds complexity and testing burden. +2. **Strict version pinning**: Only work with a single Bifrost version. Prevents upgrades. +3. **Feature detection per endpoint**: Try each endpoint and fall back on failure. Fragile and unpredictable. + +**Comparison**: Semantic versioning is standard practice. Runtime detection is over-engineering for v1. Strict pinning prevents valid upgrades. Feature detection is fragile. Document minimum version and assume semver. + +--- + +### OQ-5: PAT rotation and credential management + +**Ambiguity**: How should the task source handle PAT expiration or rotation during operation? + +**Assumption**: PAT rotation requires orchestrator restart with updated configuration. The task source does not implement automatic credential rotation. + +**Ideal Solution**: Document that PAT changes require orchestrator restart. A future version could watch for credential changes and hot-reload, but v1 uses restart-based rotation. + +**Alternatives**: +1. **Automatic credential refresh**: Watch for credential file changes and reload without restart. Adds complexity. +2. **Multiple PAT support with failover**: Configure primary and fallback PATs. Adds operational complexity. +3. **Delegate to external credential management**: Use a secrets manager. Requires additional infrastructure. + +**Comparison**: Restart-based rotation is simple and reliable for v1. Automatic refresh adds complexity. Multiple PATs adds operational burden. External secrets manager is over-engineering for initial release. Support restart-based rotation and consider automatic refresh for v2 based on user feedback. From 9d70c7e930907534679377f488e5c80db884f9bc Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Thu, 7 May 2026 16:54:37 -0500 Subject: [PATCH 05/33] v2 --- task-source-prd.md => task-source-prd-v1.md | 79 ++ task-source-prd-v2.md | 1012 +++++++++++++++++++ 2 files changed, 1091 insertions(+) rename task-source-prd.md => task-source-prd-v1.md (89%) create mode 100644 task-source-prd-v2.md diff --git a/task-source-prd.md b/task-source-prd-v1.md similarity index 89% rename from task-source-prd.md rename to task-source-prd-v1.md index 53a4106..676cc09 100644 --- a/task-source-prd.md +++ b/task-source-prd-v1.md @@ -700,6 +700,85 @@ type BifrostTaskSourceConfig = { --- +## Decision Records + +This section records decisions made during the development of the Bifrost Task Source. Each decision includes the question, the answer, and the rationale. + +### DR-1: Rune filtering in task source + +**Question**: Should the task source filter runes by worker tag, or should it yield all ready runes and let the orchestrator handle filtering? + +**Decision**: No. The task source should not do any rune filtering. If it's ready, it goes in the async generator. + +**Rationale**: Filtering is the orchestrator's responsibility, not the task source's. The task source's job is to provide ready runes from the backend system. The orchestrator framework has agent routing mechanisms that determine which worker should handle which task. Adding filtering to the task source creates unnecessary complexity and coupling between the task source and agent catalog. + +**Impact**: +- Remove `workerTagFilter` from configuration schema +- Remove US-6 (Filter runes by agent worker tags) +- Update FR-4 to remove worker tag filtering criteria +- Remove FR-9 (Worker Tag Inference) + +--- + +### DR-2: Task source responsibility for completion/failure handling + +**Question**: Is the task source responsible for determining whether an error is recoverable vs fatal and managing rune state accordingly? + +**Decision**: No. The task source is not responsible for this. The orchestrator either fulfills or fails it via `completeTask()` or `failTask()`. + +**Rationale**: The task source's job is to provide the interface for completion and failure. Determining the nature of the error and the appropriate response is the orchestrator's concern. The task source simply calls the appropriate Bifrost API based on which orchestrator method is invoked. + +**Impact**: +- Remove error classification logic from FR-6 +- Simplify FR-6 to: call fulfill API on `completeTask()`, call unclaim API on `failTask()` +- Remove exit code interpretation from task source + +--- + +### DR-3: Polling interval default + +**Question**: What should the default polling interval be? + +**Decision**: 10 seconds. + +**Rationale**: Matches the orchestrator framework's default. Provides good balance between responsiveness and resource usage. + +**Impact**: Already specified in v1. No change needed. + +--- + +### DR-4: Bifrost API version compatibility + +**Question**: How should the task source handle Bifrost API version changes? + +**Decision**: Don't worry about versioning. Assume it's all the correct versions. + +**Rationale**: The task source and Bifrost server are developed together. Version compatibility is managed at the deployment level, not in the task source code. + +**Impact**: +- Remove version compatibility requirements from NFR-7 +- Remove OQ-4 from v2 + +--- + +### DR-5: Credential source and management + +**Question**: Where should the task source read credentials from? + +**Decision**: Read the server URL and realm from the `.bifrost.yaml` of the working repository. Read the credentials (PAT) for that repo from `~/.config/bifrost/credentials.yaml`. + +**Rationale**: This follows the Bifrost CLI's credential management pattern. The `.bifrost.yaml` in the working repo specifies which server and realm to use. The credentials file maps those to PATs. This avoids hardcoding PATs in orchestrator config and supports multiple realms/credentials. + +**Impact**: +- Remove `pat` from `.orchestrator.yaml` configuration +- Add logic to read `.bifrost.yaml` from `projectDir` +- Add logic to read `~/.config/bifrost/credentials.yaml` +- Update configuration schema to only include optional overrides +- Update FR-1 configuration schema +- Add credential resolution to FR-7 (Authentication) + +--- + ## Open Questions ### OQ-1: Worker tag inference vs. explicit requirement diff --git a/task-source-prd-v2.md b/task-source-prd-v2.md new file mode 100644 index 0000000..7f4489f --- /dev/null +++ b/task-source-prd-v2.md @@ -0,0 +1,1012 @@ +# Bifrost Task Source PRD + +**Status:** Draft +**Authors:** Eric Siebeneich +**Date:** 2026-05-07 +**Version:** 2.0 + +--- + +## Product Description, Problem, and Goal + +### Product Description + +The **Bifrost Task Source** is a plugin implementation of the Orchestrator Framework's `TaskSource` interface that connects to a **Bifrost** server—the event-sourced rune management service. It enables orchestrator instances to discover, claim, and fulfill **runes** (work items) stored in Bifrost, providing seamless integration between AI agent orchestration and the Bifrost task management system. + +**Key Terms:** + +- **Bifrost**: Event-sourced rune (work item) management service for AI agents +- **Rune**: A work item in Bifrost (issue, task, bug, feature, etc.) +- **Saga**: An epic; a collection of related runes in Bifrost +- **Realm**: A tenant namespace in Bifrost for organizing runes with credentials +- **Task Source**: Orchestrator Framework plugin that discovers, claims, and fulfills tasks from external systems +- **Claimant**: Identifier for the agent instance currently working on a task +- **Rune State**: The lifecycle state of a rune (draft, forged, open, claimed, fulfilled, sealed) +- **PAT**: Personal Access Token used for authentication with Bifrost +- **Orchestrator**: TypeScript-based distributed task execution system that coordinates AI agents +- **taskState**: Free-form object containing all context for a single task execution +- **projectDir**: Git root of the working repository, resolved automatically by the orchestrator + +### Problem + +Sarah has deployed the Orchestrator Framework to automate code maintenance across her monorepo. Her team uses Bifrost for rune management—developers create runes for features, bugs, and refactors. She wants her AI agents to automatically pick up and work on these runes, but she faces three problems: + +1. **No integration**: The orchestrator's default API task source doesn't understand Bifrost's rune lifecycle, state model, or event-sourced architecture +2. **No coordination**: Multiple orchestrator instances might claim the same rune, causing duplicate work and race conditions +3. **No state synchronization**: When an agent completes work, the rune state in Bifrost isn't automatically updated—developers must manually mark runes as fulfilled + +Marcus, Sarah's teammate, has written custom scripts to poll Bifrost's HTTP API and dispatch work to agents. These scripts are fragile—they don't handle reconnection, don't respect Bifrost's claim semantics, and error handling is ad-hoc. When the Bifrost server is temporarily unavailable, the scripts crash and lose track of which runes were being processed. When two script instances run simultaneously, they both claim the same rune, wasting compute resources and creating conflicting code changes. + +Sarah spends time manually deconflicting agent work, restarting crashed scripts, and syncing rune states back to Bifrost. She can't reliably scale her automation because the integration layer is a house of cards. + +### Goal + +With the Bifrost Task Source plugin, Sarah configures her orchestrator instances to use the Bifrost Task Source. The plugin: + +1. Reads server URL and realm from `.bifrost.yaml` in the working repository +2. Reads PAT credentials from `~/.config/bifrost/credentials.yaml` +3. Polls for runes in the `open` state that are ready for work +4. Uses Bifrost's native claim API to ensure exclusive ownership (no duplicate work) +5. Yields all ready runes via async iterator (no filtering) +6. Maps Bifrost runes to orchestrator Tasks with full metadata (tags, dependencies, notes) +7. Executes agents via the orchestrator framework +8. Calls fulfill or unclaim API based on orchestrator's `completeTask()` or `failTask()` calls +9. Handles Bifrost server unavailability with retry on next poll interval +10. Emits structured telemetry for every operation (claim, execution, completion) + +Marcus removes his fragile scripts. The orchestrator instances now coordinate seamlessly through Bifrost's claim semantics. When a rune is completed, its state is automatically updated—developers see real-time progress in the Bifrost UI. Sarah can deploy multiple orchestrator instances knowing they won't duplicate work. The system is reliable, observable, and requires no custom glue code. + +--- + +## User Stories / Use Cases + +### US-1: Configure orchestrator to use Bifrost Task Source + +**As a** platform engineer +**I want** to configure the orchestrator to use the Bifrost Task Source +**So that** orchestrator instances can connect to my Bifrost server using the working repo's Bifrost configuration + +**Acceptance Criteria:** + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_source.type is "bifrost" +And the working repository has a .bifrost.yaml file +And .bifrost.yaml contains realm: "my-project" +And ~/.config/bifrost/credentials.yaml contains a PAT for the server +When the orchestrator loads configuration +Then a BifrostTaskSource is created using the realm from .bifrost.yaml +And the BifrostTaskSource reads credentials from ~/.config/bifrost/credentials.yaml +And the BifrostTaskSource connects to the Bifrost server +``` + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_source.settings.pollInterval is 30 +When the orchestrator loads configuration +Then the BifrostTaskSource polls for ready runes every 30 seconds +``` + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_source.settings.claimant is "orchestrator-prod-1" +When the orchestrator loads configuration +Then the BifrostTaskSource uses "orchestrator-prod-1" as the claimant identifier for all rune claims +``` + +``` +Given .orchestrator.yaml contains optional overrides +And orchestrate.task_source.settings.baseUrl is "https://custom.bifrost.com" +And .bifrost.yaml contains a different baseUrl +When the orchestrator loads configuration +Then the BifrostTaskSource uses the override from .orchestrator.yaml +``` + +### US-2: Poll for ready runes and claim them + +**As an** orchestrator instance +**I want** to poll Bifrost for runes in the `open` state that are ready for work +**So that** I can claim and execute them + +**Acceptance Criteria:** + +``` +Given the Bifrost Task Source is configured +And one or more runes exist in the configured realm with state "open" +And all rune dependencies are satisfied (no blocking runes) +When the poll interval elapses +Then the Bifrost Task Source queries Bifrost for ready runes +And each ready rune is yielded via the async iterator +``` + +``` +Given a ready rune is yielded from the async iterator +When the orchestrator receives the rune +Then the Bifrost Task Source claims the rune via Bifrost's claim API +And the rune state in Bifrost transitions to "claimed" +And the claimant field is set to the configured claimant identifier +``` + +``` +Given two orchestrator instances poll simultaneously +And the same rune is ready for work +When both instances attempt to claim the rune +Then only one instance successfully claims the rune +And the other instance receives a conflict error +And the conflict error is logged +And the unsuccessful instance does not process the rune +``` + +``` +Given the Bifrost server is unreachable +When the Bifrost Task Source attempts to poll +Then the poll attempt is logged as failed +And the Task Source waits for the configured poll interval before retrying +``` + +### US-3: Map Bifrost runes to orchestrator Tasks + +**As the** orchestrator framework +**I want** Bifrost runes to be mapped to the Task interface with all relevant metadata +**So that** agents can access rune context + +**Acceptance Criteria:** + +``` +Given a claimed Bifrost rune with title "Fix login bug" +And the rune has description "Users cannot authenticate" +And the rune has tags ["bug", "auth", "priority:high"] +And the rune has priority 2 +When the rune is mapped to an orchestrator Task +Then Task.id is the rune ID +And Task.title is "Fix login bug" +And Task.description is "Users cannot authenticate" +And Task.tags is ["bug", "auth", "priority:high"] +And Task.priority is 2 +And Task.status is "IN_PROGRESS" +And Task.claimant is the configured claimant identifier +``` + +``` +Given a Bifrost rune with acceptance criteria +And the rune has dependencies on other runes +And the rune has notes from developers +When the rune is mapped to an orchestrator TaskDetail +Then TaskDetail.acceptanceCriteria contains all AC entries +And TaskDetail.dependencies contains all DependencyRef entries +And TaskDetail.notes contains all NoteEntry entries +``` + +``` +Given a Bifrost rune with branch tracking enabled +And the rune is associated with branch "feature/fix-login" +When the rune is mapped to an orchestrator Task +Then Task.metadata contains the branch name +And agents can access the branch information via taskState +``` + +### US-4: Complete or fail rune based on orchestrator call + +**As the** orchestrator framework +**I want** to mark a rune as fulfilled or failed based on the orchestrator's completion method +**So that** rune state in Bifrost reflects the actual outcome + +**Acceptance Criteria:** + +``` +Given a rune has been claimed and processed by an agent +And the agent execution completed successfully +When the orchestrator calls completeTask(taskId) +Then the Bifrost Task Source calls Bifrost's fulfill API +And the rune state transitions to "fulfilled" +And the method returns true +``` + +``` +Given a rune has been claimed and processed +And the agent execution failed +When the orchestrator calls failTask(taskId, error) +Then the Bifrost Task Source calls Bifrost's unclaim API +And the rune state transitions to "open" +And the method returns true +``` + +``` +Given the Bifrost server is unreachable when completing a task +When the orchestrator calls completeTask(taskId) +Then the method throws an error +And the orchestrator logs the failure +And the orchestrator marks the task as failed in its own records +``` + +### US-5: Handle Bifrost server unavailability + +**As an** orchestrator operator +**I want** the Bifrost Task Source to handle server unavailability gracefully +**So that** temporary outages don't crash the orchestrator + +**Acceptance Criteria:** + +``` +Given the Bifrost Task Source is polling for tasks +And the Bifrost server becomes unreachable +When a poll attempt fails +Then the error is logged with server URL and timestamp +And the Task Source waits for the configured poll interval +And the Task Source retries polling +``` + +``` +Given the Bifrost Task Source async iterator is active +And a network error occurs during iteration +When the error is detected +Then the async iterator does not terminate +And the error is logged +And polling continues on the next interval +``` + +``` +Given the Bifrost server has been unavailable for 5 minutes +And the server becomes available again +When the next poll occurs +Then the Bifrost Task Source successfully connects +And polling resumes normally +And a reconnection log entry is written +``` + +### US-6: Emit telemetry for all operations + +**As a** platform engineer +**I want** detailed telemetry emitted for all Bifrost Task Source operations +**So that** I can monitor integration health and debug issues + +**Acceptance Criteria:** + +``` +Given the Bifrost Task Source successfully polls for ready runes +When the poll completes +Then a log entry is emitted with: poll timestamp, number of ready runes found, realm name +``` + +``` +Given the Bifrost Task Source claims a rune +When the claim API call succeeds +Then a log entry is emitted with: rune ID, claimant, timestamp +``` + +``` +Given the Bifrost Task Source fails to claim a rune due to conflict +When the conflict error is received +Then a log entry is emitted with: rune ID, conflict reason, current claimant +``` + +``` +Given the Bifrost Task Source completes or fails a rune +When the fulfill/unclaim API call succeeds +Then a log entry is emitted with: rune ID, operation, timestamp +``` + +--- + +## Functional Requirements + +### FR-1: Configuration Schema + +The Bifrost Task Source MUST support the following configuration in `.orchestrator.yaml`: + +```yaml +orchestrate: + task_source: + type: "bifrost" + settings: + # Optional overrides for values from .bifrost.yaml + baseUrl: string # Override Bifrost server URL + realm: string # Override realm name + claimant: string # Claimant identifier (required) + pollInterval: number # Polling interval in milliseconds (default: 10000) + timeout: number # Request timeout in milliseconds (default: 30000) +``` + +The task source MUST read from: +- `.bifrost.yaml` in `projectDir` for default `baseUrl` and `realm` +- `~/.config/bifrost/credentials.yaml` for PAT authentication + +Configuration priority: `.orchestrator.yaml` settings override `.bifrost.yaml` values. + +**.bifrost.yaml resolution behavior**: +- If `.bifrost.yaml` doesn't exist in `projectDir`: throw an error at task source construction +- If `.bifrost.yaml` exists but is missing `baseUrl` or `realm` fields: throw an error at task source construction +- If both `.orchestrator.yaml` overrides are provided and `.bifrost.yaml` is missing: use the overrides (this is valid) +- If only one of `baseUrl` or `realm` is overridden and the other is missing from `.bifrost.yaml`: throw an error + +**Configuration validation**: +- All validation occurs at task source construction time +- Invalid values (negative pollInterval, empty claimant, etc.) throw an error +- The task source does not start polling if configuration is invalid + +### FR-2: BifrostTaskSource Implementation + +The Bifrost Task Source MUST implement the `TaskSource` interface: + +```typescript +export type TaskSource = { + watchTasks: () => AsyncGenerator + getTaskDetail: (taskId: string) => Promise + completeTask: (taskId: string) => Promise + failTask: (taskId: string, error: string) => Promise +} +``` + +The BifrostTaskSource constructor MUST accept: + +```typescript +constructor(config: BifrostTaskSourceConfig, projectDir: string) +``` + +The `projectDir` parameter is the git root of the working repository, used to locate `.bifrost.yaml`. This is provided by the orchestrator framework at task source instantiation. + +### FR-3: Rune to Task Mapping + +The Bifrost Task Source MUST map Bifrost runes to orchestrator Tasks with the following field mappings: + +| Bifrost Field | Task Field | Notes | +|---------------|------------|-------| +| `id` | `id` | Direct mapping | +| `title` | `title` | Direct mapping | +| `description` | `description` | Direct mapping | +| `state` | `status` | Mapped: "claimed" → "IN_PROGRESS" | +| `tags` | `tags` | Direct mapping, no modification | +| `claimant` | `claimant` | Direct mapping | +| `created_at` | `createdAt` | Date parsing | +| `updated_at` | `updatedAt` | Date parsing | +| `priority` | `priority` | Direct mapping | +| `branch` | `metadata.branch` | Stored in metadata object | + +### FR-4: Ready Rune Query + +The Bifrost Task Source MUST query for runes that meet ALL of the following criteria: + +- `state == "open"` (rune is ready to be claimed) +- All dependency runes in the `blocks` relationship are in state `fulfilled` or `sealed` +- No circular dependencies exist (enforced by Bifrost) + +The query MUST use Bifrost's `bf ready` equivalent API endpoint. + +The task source MUST NOT filter runes by any criteria including worker tags. All ready runes are yielded to the orchestrator. + +### FR-5: Claim Semantics + +The Bifrost Task Source MUST use Bifrost's native claim API to atomically claim runes. The claim operation MUST: + +- Be atomic (no race conditions between multiple orchestrator instances) +- Set the rune state to `claimed` +- Set the rune's claimant field to the configured claimant identifier +- Return an error if the rune is already claimed by another claimant + +On claim conflict (409 Conflict response from Bifrost API): +- Log the conflict with rune ID, attempted claimant, and current claimant +- NOT yield the rune to the orchestrator (skip it silently in the async iterator) +- Continue polling for other ready runes +- The conflict is logged but does not terminate the async iterator + +### FR-6: Completion and Failure Handling + +The Bifrost Task Source MUST provide methods for the orchestrator to signal completion or failure: + +- `completeTask(taskId)`: Calls Bifrost's fulfill API. Transitions rune to `fulfilled` state. +- `failTask(taskId, error)`: Calls Bifrost's unclaim API with the error message as the reason. Transitions rune back to `open` state. + +The `error` parameter in `failTask` MUST be passed as the `reason` field in the unclaim API request body. The error message is also logged. + +The task source MUST NOT classify errors or determine whether errors are recoverable. That is the orchestrator's responsibility. + +**Return value semantics**: +- Returns `true` if the Bifrost API acknowledged the operation +- Returns `false` if the rune was not in the expected state (e.g., fulfill on already-fulfilled rune) +- Throws an error if the Bifrost server is unreachable or the request times out + +### FR-7: Authentication and Credentials + +The Bifrost Task Source MUST authenticate using Bearer token authentication: + +- Read credentials from `~/.config/bifrost/credentials.yaml` +- Include the PAT in the `Authorization` header as `Bearer ` +- Include the realm name in the `X-Bifrost-Realm` header +- Handle 401/403 responses by logging authentication failures + +The credentials file format matches the Bifrost CLI format: + +```yaml +# ~/.config/bifrost/credentials.yaml +credentials: + - server: "https://bifrost.example.com" + realm: "my-project" + token: "the-pat-token" +``` + +Credentials are resolved by matching `server` and `realm` from `.bifrost.yaml` (or overrides) to the entries in the credentials file. + +**Credential resolution failure behavior**: +- If `credentials.yaml` doesn't exist: throw an error at task source construction +- If no matching credential entry is found: throw an error at task source construction +- If the file is malformed (invalid YAML): throw an error at task source construction +- The error MUST clearly indicate which file or credential lookup failed + +### FR-8: Reconnection Behavior + +If the Bifrost server becomes unreachable, the Bifrost Task Source MUST: + +- Log the connection error with timestamp and server URL +- Continue polling at the configured interval (no exponential backoff for v1) +- Successfully reconnect when the server becomes available +- Log successful reconnection + +The `watchTasks()` async iterator MUST NOT terminate on network errors. + +### FR-9: Branch Metadata + +If a Bifrost rune has an associated Git branch, the Bifrost Task Source MUST: + +- Include the branch name in `Task.metadata.branch` +- Agents can access this via `taskState.metadata.branch` for checkout operations + +### FR-10: Dependency Mapping + +Bifrost dependency relationships MUST be mapped to orchestrator TaskDetail: + +| Bifrost Relationship | TaskDetail.dependencies[] | +|----------------------|---------------------------| +| `blocks` | `{ taskId, type: "blocks" }` | +| `relates_to` | `{ taskId, type: "relates_to" }` | +| `duplicates` | `{ taskId, type: "duplicates" }` | +| `supersedes` | `{ taskId, type: "supersedes" }` | +| `replies_to` | `{ taskId, type: "replies_to" }` | + +### FR-11: Notes and Retro Mapping + +Bifrost notes and retro entries MUST be mapped to TaskDetail: + +- Notes → `TaskDetail.notes[]` with `{ id, content, createdAt }` +- Retro entries → `TaskDetail.retro[]` with `{ id, content, createdAt }` + +--- + +## Non-Functional Requirements + +### NFR-1: Performance + +- Polling interval default: 10 seconds (configurable) +- API request timeout default: 30 seconds (configurable) +- Rune to Task mapping MUST complete in under 10ms per rune +- Bifrost API calls MUST complete within the configured timeout + +### NFR-2: Reliability + +- The Bifrost Task Source MUST handle Bifrost server unavailability without crashing +- The Bifrost Task Source MUST log all failures without terminating the async iterator +- Claim conflicts MUST be handled gracefully (no duplicate work) +- Network errors MUST be logged and retried on next poll + +### NFR-3: Monitoring and Observability + +- All Bifrost API calls MUST be logged with: endpoint, status code, duration +- All claim operations MUST be logged with: rune ID, claimant, success/failure +- All fulfill/unclaim operations MUST be logged with: rune ID, result +- Poll operations MUST be logged with: realm, ready rune count +- Reconnection events MUST be logged + +### NFR-4: Concurrency + +- Multiple orchestrator instances MUST be able to poll simultaneously +- Bifrost's claim API ensures only one instance claims each rune +- No additional locking is required in the Bifrost Task Source + +### NFR-5: Error Handling + +- Authentication failures (401/403) MUST be logged and polling must continue +- Invalid credential configuration MUST be detected and logged on startup +- Realm not found MUST be logged and polling must continue +- Malformed API responses MUST be logged and the rune must be skipped + +### NFR-6: Security + +- PATs MUST be transmitted via HTTPS only +- PATs MUST NOT be logged under any circumstances +- The Bifrost Task Source MUST validate SSL certificates +- Credential file permissions SHOULD be restricted (user-readable only) + +--- + +## Data & Storage + +### Commands + +**PollReadyRunes** +- `realm: string`, `claimant: string` +- Occurs when: Poll interval elapses + +**ClaimRune** +- `runeId: string`, `claimant: string` +- Occurs when: Ready rune is identified + +**MapRuneToTask** +- `rune: BifrostRune` +- Occurs when: Claimed rune is yielded to orchestrator + +**FulfillRune** +- `runeId: string` +- Occurs when: Orchestrator calls completeTask() + +**UnclaimRune** +- `runeId: string`, `error: string` +- Occurs when: Orchestrator calls failTask() + +### Events + +**ReadyRunesPolled** +- `realm: string`, `readyRuneCount: number`, `pollTimestamp: ISO8601` + +**RuneClaimed** +- `runeId: string`, `claimant: string`, `claimedAt: ISO8601` + +**RuneClaimConflict** +- `runeId: string`, `attemptedBy: string`, `currentClaimant: string`, `conflictAt: ISO8601` + +**RuneFulfilled** +- `runeId: string`, `claimant: string`, `fulfilledAt: ISO8601` + +**RuneUnclaimed** +- `runeId: string`, `reason: string`, `unclaimedAt: ISO8601` + +**BifrostApiCallFailed** +- `endpoint: string`, `statusCode: number`, `error: string`, `timestamp: ISO8601` + +**BifrostServerReconnecting** +- `baseUrl: string`, `lastError: string`, `reattemptDelay: number`, `reattemptAt: ISO8601` + +**BifrostServerReconnected** +- `baseUrl: string`, `downtimeDuration: number`, `reconnectedAt: ISO8601` + +**CredentialResolutionFailed** +- `server: string`, `realm: string`, `reason: string`, `timestamp: ISO8601` + +### Aggregates + +**BifrostRune** +```typescript +type BifrostRune = { + id: string + title: string + description: string | null + state: RuneState + tags: string[] + claimant: string | null + createdAt: Date + updatedAt: Date + priority: number + branch: string | null + realmId: string +} + +type RuneState = "draft" | "forged" | "open" | "claimed" | "fulfilled" | "sealed" +``` + +**BifrostRuneDetail** +```typescript +type BifrostRuneDetail = BifrostRune & { + dependencies: BifrostDependency[] + notes: BifrostNote[] + acceptanceCriteria: BifrostAC[] + retro: BifrostRetroEntry[] +} + +type BifrostDependency = { + taskId: string + type: "blocks" | "relates_to" | "duplicates" | "supersedes" | "replies_to" +} + +type BifrostNote = { + id: string + content: string + createdAt: Date +} + +type BifrostAC = { + id: string + criteria: string + satisfied: boolean +} + +type BifrostRetroEntry = { + id: string + content: string + createdAt: Date +} +``` + +**BifrostTaskSourceConfig** +```typescript +type BifrostTaskSourceConfig = { + baseUrl?: string // Optional override + realm?: string // Optional override + claimant: string // Required + pollInterval: number // Default: 10000 + timeout: number // Default: 30000 +} +``` + +**BifrostRepoConfig** (from `.bifrost.yaml`) +```typescript +type BifrostRepoConfig = { + baseUrl: string + realm: string +} +``` + +**BifrostCredentialsFile** (from `~/.config/bifrost/credentials.yaml`) +```typescript +type BifrostCredentialsFile = { + credentials: Array<{ + server: string + realm: string + token: string + }> +} +``` + +### Query Projections + +**ReadyRunesQuery** +- Question: Which runes in this realm are ready to be claimed? +- Projection: List of `BifrostRune` filtered by state="open", satisfied dependencies, realm +- Used by: Poll operation + +**RuneDetailQuery** +- Question: What is the full detail for a specific rune? +- Projection: `BifrostRuneDetail` by rune ID +- Used by: getTaskDetail, agent context + +**ClaimantStatusQuery** +- Question: Which runes are currently claimed by this orchestrator instance? +- Projection: List of `BifrostRune` filtered by claimant and state="claimed" +- Used by: Status reporting, recovery + +**CredentialLookupQuery** +- Question: What PAT should be used for this server/realm combination? +- Projection: Single credential entry matching server and realm +- Used by: Authentication + +### Data Retention + +- Bifrost Task Source does NOT persist data locally +- All rune state is stored in Bifrost server +- Event logs for telemetry follow orchestrator framework retention policy (90 days) + +--- + +## Out of Scope + +- Real-time push notifications (polling only, no webhooks in v1) +- Custom claim semantics (uses Bifrost's built-in claim API) +- Multi-realm polling (one task source instance = one realm) +- Rune creation from the orchestrator (runes are created externally) +- Saga-level orchestration (individual runes only) +- Automatic retry on failure (orchestrator decides, task source just follows orders) +- Bifrost server administration (no realm/account management from task source) +- Migration tools (no import from other task systems) +- Advanced scheduling (FIFO polling based on Bifrost's ready query order) +- Custom priority handling (Bifrost priority is informational only) +- Branch creation (branch must already exist or be created externally) +- Dependency resolution visualization (no graph generation) +- Worker tag filtering (orchestrator's responsibility, not task source) +- Worker tag inference (orchestrator's responsibility) +- Error classification (orchestrator's responsibility) + +--- + +## Dependencies and Assumptions + +### Dependencies + +| Dependency | Purpose | Version | +|---|---|---|---| +| Bifrost Server | Rune management backend | Current | +| Orchestrator Framework | Core orchestration system | 1.0 | +| TypeScript Runtime | Task source execution | ≥ 24 | +| Node.js fs module | Reading .bifrost.yaml and credentials.yaml | Built-in | +| Node.js fetch API | HTTP requests to Bifrost | Built-in | + +### Assumptions + +1. Bifrost server is accessible via HTTPS from the orchestrator runtime environment +2. A valid PAT with at least `member` role in the target realm is available in credentials file +3. Bifrost server's claim API is atomic and prevents duplicate claims +4. The realm specified in .bifrost.yaml exists +5. Network connectivity allows periodic polling at the configured interval +6. Bifrost server's `bf ready` equivalent API endpoint exists and returns ready runes +7. Bifrost server supports fulfill and unclaim API endpoints +8. Time synchronization between orchestrator and Bifrost server is adequate (clock skew < 1 minute) +9. The orchestrator framework provides the TaskSource interface contract +10. The orchestrator automatically resolves `projectDir` from git root +11. `.bifrost.yaml` exists in the working repository +12. `~/.config/bifrost/credentials.yaml` exists and contains valid credentials +13. The orchestrator handles worker tag routing, not the task source + +### External System Assumptions + +- **Bifrost Server**: Provides HTTP API for rune CRUD operations, claim/unclaim, fulfill, and ready query. Supports PAT authentication and realm-scoped queries. +- **Orchestrator Framework**: Provides TaskSource interface, defines Task and TaskDetail types, handles agent dispatch, manages hook execution, automatically resolves projectDir. + +--- + +## Decision Records + +This section records decisions made during the development of the Bifrost Task Source. Each decision includes the question, the answer, and the rationale. + +### DR-1: No rune filtering in task source + +**Question**: Should the task source filter runes by worker tag? + +**Decision**: No. The task source should not do any rune filtering. If it's ready, it goes in the async generator. + +**Rationale**: Filtering is the orchestrator's responsibility. The task source's job is to provide ready runes from the backend system. The orchestrator framework has agent routing mechanisms that determine which worker should handle which task. Adding filtering to the task source creates unnecessary complexity and coupling. + +### DR-2: Task source not responsible for error classification + +**Question**: Is the task source responsible for determining whether an error is recoverable vs fatal? + +**Decision**: No. The task source is not responsible for this. The orchestrator either fulfills or fails it via `completeTask()` or `failTask()`. + +**Rationale**: The task source's job is to provide the interface for completion and failure. Determining the nature of the error and the appropriate response is the orchestrator's concern. The task source simply calls the appropriate Bifrost API based on which orchestrator method is invoked. + +### DR-3: Polling interval default + +**Question**: What should the default polling interval be? + +**Decision**: 10 seconds. + +**Rationale**: Matches the orchestrator framework's default. Provides good balance between responsiveness and resource usage. + +### DR-4: No API version compatibility handling + +**Question**: How should the task source handle Bifrost API version changes? + +**Decision**: Don't worry about versioning. Assume it's all the correct versions. + +**Rationale**: The task source and Bifrost server are developed together. Version compatibility is managed at the deployment level, not in the task source code. + +### DR-5: Credentials from .bifrost.yaml and credentials.yaml + +**Question**: Where should the task source read credentials from? + +**Decision**: Read the server URL and realm from the `.bifrost.yaml` of the working repository. Read the credentials (PAT) for that repo from `~/.config/bifrost/credentials.yaml`. + +**Rationale**: This follows the Bifrost CLI's credential management pattern. The `.bifrost.yaml` in the working repo specifies which server and realm to use. The credentials file maps those to PATs. This avoids hardcoding PATs in orchestrator config and supports multiple realms/credentials. + +--- + +## Open Questions + +### OQ-1: .bifrost.yaml file format specification + +**Ambiguity**: FR-1 states the task source reads `baseUrl` and `realm` from `.bifrost.yaml`, but the file format is not specified anywhere in the document. + +**Assumed format**: +```yaml +baseUrl: "https://bifrost.example.com" +realm: "my-project" +``` + +**Questions**: +1. Is this the actual format? Does `.bifrost.yaml` use different field names? +2. Is `baseUrl` even in `.bifrost.yaml`, or is it configured elsewhere (server config)? +3. What other fields might be in `.bifrost.yaml` that we should ignore? + +**Impact**: Critical for implementation. Wrong field names will cause credential resolution to fail. + +--- + +### OQ-2: Bifrost HTTP API endpoint contracts + +**Ambiguity**: FR-4, FR-5, FR-6 reference "bf ready equivalent API", "claim API", "fulfill API", "unclaim API" but actual HTTP endpoints and request/response formats are not specified. + +**Questions**: +1. What is the HTTP path for each operation? (e.g., `GET /api/v1/runes/ready`, `POST /api/v1/runes/{id}/claim`) +2. What are the request body formats? +3. What are the response formats? +4. What HTTP status codes indicate success vs. various failure modes? +5. How is the claimant identifier passed to the claim API? + +**Impact**: Critical. Cannot implement without knowing actual API contracts. + +--- + +### OQ-3: Claimant identifier generation and format + +**Ambiguity**: FR-1 requires `claimant: string` in config, but doesn't specify what this value should be or how it's generated. + +**Questions**: +1. Is the claimant a user-chosen string (hostname, orchestrator instance name)? +2. Is there a format requirement (UUID, DNS name)? +3. Must claimant be unique across all orchestrator instances? +4. What happens if two orchestrator instances use the same claimant? + +**Impact**: Medium. Duplicate claimants could cause coordination issues. + +--- + +### OQ-4: Credential resolution failure behavior + +**Ambiguity**: FR-7 describes reading from `~/.config/bifrost/credentials.yaml`, but doesn't specify behavior when credentials aren't found. + +**Questions**: +1. What happens if `credentials.yaml` doesn't exist? +2. What happens if no credential matches the server/realm combination? +3. What happens if the file is malformed (invalid YAML)? +4. Should the task source fail fast on startup, or log and continue? + +**Impact**: Critical. Needs clear failure mode specification. + +--- + +### OQ-5: .bifrost.yaml missing or malformed behavior + +**Ambiguity**: Assumption 11 states `.bifrost.yaml` exists, but doesn't specify behavior if it doesn't. + +**Questions**: +1. What if `.bifrost.yaml` doesn't exist in `projectDir`? +2. What if it exists but is missing required fields? +3. What if both `.orchestrator.yaml` overrides and `.bifrost.yaml` are missing - is this fatal? +4. Should we fall back to `.orchestrator.yaml` only, or fail? + +**Impact**: High. Need to define graceful degradation vs. hard failure. + +--- + +### OQ-6: projectDir access mechanism + +**Ambiguity**: The `TaskSource` interface (FR-2) doesn't include `projectDir` as a parameter, but FR-1 and FR-7 reference reading files from `projectDir`. + +**Questions**: +1. How does the task source receive `projectDir`? Is it passed to the constructor? +2. Is `projectDir` resolved once at startup, or can it change during operation? +3. What if the orchestrator runs from outside a git repository (no projectDir)? + +**Impact**: High. Cannot implement file reading without knowing how projectDir is provided. + +--- + +### OQ-7: Task.metadata type specification + +**Ambiguity**: FR-3 specifies `branch` goes into `Task.metadata.branch`, but the structure of `Task.metadata` is not defined. + +**Questions**: +1. Is `Task.metadata` a free-form `Record`? +2. Are there other metadata fields beyond `branch`? +3. Does the orchestrator framework define `Task.metadata`, or is it task-source-specific? + +**Impact**: Medium. Affects type safety and serialization. + +--- + +### OQ-8: failTask error message handling + +**Ambiguity**: The interface specifies `failTask(taskId: string, error: string)`, but doesn't specify what happens to the error message. + +**Questions**: +1. Is the error message added as a note on the rune in Bifrost? +2. Is it just logged? +3. Is it passed to the unclaim API body? +4. Should the error message be truncated if too long? + +**Impact**: Medium. Affects debugging and user experience. + +--- + +### OQ-9: Claim conflict handling in watchTasks + +**Ambiguity**: FR-5 states "On claim conflict, the Bifrost Task Source MUST log the conflict and NOT yield the rune to the orchestrator." This conflicts with US-2 AC which says the unsuccessful instance "receives a conflict error." + +**Questions**: +1. Does the conflicting rune get silently skipped, or is an error emitted? +2. If an error, is it a logged error or thrown from the async iterator? +3. Should the task source track conflicts and alert if conflicts are frequent? + +**Impact**: Medium. Affects observability and operational debugging. + +--- + +### OQ-10: completeTask/failTask partial failure modes + +**Ambiguity**: FR-6 specifies `completeTask` and `failTask` return `Promise`, but doesn't specify what `false` means vs. throwing an error. + +**Questions**: +1. Does `false` mean "Bifrost acknowledged but state didn't change"? +2. Does `false` mean "network error but operation might have succeeded"? +3. When should the method throw vs. return false? +4. Should the orchestrator retry on `false`? + +**Impact**: High. Affects orchestrator's retry and error handling logic. + +--- + +### OQ-11: Configuration validation timing + +**Ambiguity**: FR-1 through FR-11 specify configuration, but not when validation occurs. + +**Questions**: +1. Is configuration validated at task source construction time, or on first API call? +2. Which validation errors should prevent startup vs. be logged warnings? +3. If `.orchestrator.yaml` has invalid values (negative pollInterval), what happens? + +**Impact**: Medium. Affects startup behavior and diagnosability. + +--- + +## Appendices + +### Appendix A: .bifrost.yaml Schema + +The `.bifrost.yaml` file in the working repository specifies the Bifrost server and realm for that repository. + +```yaml +# .bifrost.yaml +baseUrl: "https://bifrost.example.com" # Bifrost server URL +realm: "my-project" # Realm name for this repository +``` + +Additional fields may be present in the file but are ignored by the task source. + +### Appendix B: Bifrost HTTP API Endpoints + +The following HTTP endpoints are used by the Bifrost Task Source: + +**Ready Runes Query** +``` +GET /api/v1/runes?state=open&realm={realm} +Headers: Authorization: Bearer {pat}, X-Bifrost-Realm: {realm} +Response: 200 OK with array of BifrostRune +``` + +**Claim Rune** +``` +POST /api/v1/runes/{runeId}/claim +Headers: Authorization: Bearer {pat}, X-Bifrost-Realm: {realm} +Body: { "claimant": string } +Response: 200 OK on success, 409 Conflict on claim conflict +``` + +**Get Rune Detail** +``` +GET /api/v1/runes/{runeId} +Headers: Authorization: Bearer {pat}, X-Bifrost-Realm: {realm} +Response: 200 OK with BifrostRuneDetail, 404 Not Found +``` + +**Fulfill Rune** +``` +POST /api/v1/runes/{runeId}/fulfill +Headers: Authorization: Bearer {pat}, X-Bifrost-Realm: {realm} +Response: 200 OK +``` + +**Unclaim Rune** +``` +POST /api/v1/runes/{runeId}/unclaim +Headers: Authorization: Bearer {pat}, X-Bifrost-Realm: {realm} +Body: { "reason": string } +Response: 200 OK +``` + +### Appendix C: HTTP Status Codes + +| Status | Meaning | Task Source Behavior | +|--------|---------|---------------------| +| 200 OK | Success | Proceed | +| 401 Unauthorized | Invalid PAT | Log error, continue polling | +| 403 Forbidden | Insufficient permissions | Log error, continue polling | +| 404 Not Found | Rune not found | Return null from getTaskDetail | +| 409 Conflict | Rune already claimed | Log conflict, skip rune | +| 500+ Server Error | Bifrost server error | Log error, retry on next poll | + +--- From e96a601b4f31a895efd8272c1563930b1eeb242e Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Thu, 7 May 2026 23:00:21 -0500 Subject: [PATCH 06/33] update to simplify the interfaces and fix up the tests --- .vscode/settings.json | 2 +- orchestrator/packages/cli/tsconfig.json | 2 +- orchestrator/packages/cli/vite.config.ts | 37 +- .../core/src/file-task-state-store.spec.ts | 109 -- .../core/src/file-task-state-store.ts | 82 -- orchestrator/packages/core/src/index.ts | 3 - .../core/src/memory-task-source.spec.ts | 184 --- .../packages/core/src/memory-task-source.ts | 143 --- .../core/src/memory-task-state-store.spec.ts | 69 - .../core/src/memory-task-state-store.ts | 59 - .../packages/core/src/orchestrator.spec.ts | 297 +++-- .../packages/core/src/orchestrator.ts | 63 +- .../core/src/task-state-store.spec.ts | 132 -- .../packages/core/src/task-state-store.ts | 32 - orchestrator/packages/core/tsconfig.json | 2 +- orchestrator/packages/core/vite.config.ts | 39 +- .../packages/engine/src/test-engine.ts | 17 +- orchestrator/packages/engine/src/types.ts | 4 +- orchestrator/packages/engine/vite.config.ts | 35 +- .../packages/task-source-memory/package.json | 17 + .../packages/task-source-memory/src/index.ts | 1 + .../src/memory-task-source.spec.ts | 187 +++ .../src/memory-task-source.ts | 100 ++ .../packages/task-source-memory/tsconfig.json | 9 + .../task-source-memory/vite.config.ts | 10 + .../task-source/src/api-task-source.spec.ts | 206 --- .../task-source/src/api-task-source.ts | 158 --- .../packages/task-source/src/index.ts | 1 - .../packages/task-source/src/interface.ts | 17 +- .../packages/task-source/src/types.ts | 15 +- .../packages/task-source/vite.config.ts | 35 +- orchestrator/tsconfig.base.json | 3 +- orchestrator/tsconfig.json | 3 + orchestrator/vite.base.ts | 37 + orchestrator/vitest.config.ts | 1 + orchestrator/vitest.setup.ts | 4 + task-source-prd-v2.md | 102 ++ task-source-prd-v3.md | 1115 +++++++++++++++++ 38 files changed, 1886 insertions(+), 1446 deletions(-) delete mode 100644 orchestrator/packages/core/src/file-task-state-store.spec.ts delete mode 100644 orchestrator/packages/core/src/file-task-state-store.ts delete mode 100644 orchestrator/packages/core/src/memory-task-source.spec.ts delete mode 100644 orchestrator/packages/core/src/memory-task-source.ts delete mode 100644 orchestrator/packages/core/src/memory-task-state-store.spec.ts delete mode 100644 orchestrator/packages/core/src/memory-task-state-store.ts delete mode 100644 orchestrator/packages/core/src/task-state-store.spec.ts delete mode 100644 orchestrator/packages/core/src/task-state-store.ts create mode 100644 orchestrator/packages/task-source-memory/package.json create mode 100644 orchestrator/packages/task-source-memory/src/index.ts create mode 100644 orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts create mode 100644 orchestrator/packages/task-source-memory/src/memory-task-source.ts create mode 100644 orchestrator/packages/task-source-memory/tsconfig.json create mode 100644 orchestrator/packages/task-source-memory/vite.config.ts delete mode 100644 orchestrator/packages/task-source/src/api-task-source.spec.ts delete mode 100644 orchestrator/packages/task-source/src/api-task-source.ts create mode 100644 orchestrator/vite.base.ts create mode 100644 orchestrator/vitest.setup.ts create mode 100644 task-source-prd-v3.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 9f4e67a..7c87547 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,7 @@ "**/node_modules": true, "**/__pycache__": true, "**/.venv": true, - "**/dist": true, + // "**/dist": true, "*/**/.claude": true, }, "python.analysis.extraPaths": [ diff --git a/orchestrator/packages/cli/tsconfig.json b/orchestrator/packages/cli/tsconfig.json index 2b45ec0..1e21715 100644 --- a/orchestrator/packages/cli/tsconfig.json +++ b/orchestrator/packages/cli/tsconfig.json @@ -10,5 +10,5 @@ } ], "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] + "exclude": ["node_modules", "dist", "**/*.spec.ts"] } diff --git a/orchestrator/packages/cli/vite.config.ts b/orchestrator/packages/cli/vite.config.ts index ea8b837..0cb4399 100644 --- a/orchestrator/packages/cli/vite.config.ts +++ b/orchestrator/packages/cli/vite.config.ts @@ -1,29 +1,10 @@ -import { defineConfig } from 'vite' -import dts from 'vite-plugin-dts' +import pkg from './package.json' +// @ts-ignore +import tsconfig from './tsconfig.json' +import base from '../../vite.base' -export default defineConfig({ - plugins: [ - dts({ - tsconfigPath: './tsconfig.json', - rollupTypes: true, - }), - ], - build: { - lib: { - entry: './src/index.ts', - name: 'Cli', - formats: ['es'], - fileName: 'index', - }, - rollupOptions: { - external: ['@orchestrator/core'], - output: { - globals: { - '@orchestrator/core': 'Core', - }, - }, - }, - target: 'esnext', - emptyOutDir: true, - }, -}) +export default base({ + name: 'cli', + pkg, + tsconfig +}) \ No newline at end of file diff --git a/orchestrator/packages/core/src/file-task-state-store.spec.ts b/orchestrator/packages/core/src/file-task-state-store.spec.ts deleted file mode 100644 index bb24ea2..0000000 --- a/orchestrator/packages/core/src/file-task-state-store.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { tmpdir } from 'node:os' -import { rm } from 'node:fs/promises' -import { join } from 'node:path' -import { FileTaskStateStore } from './file-task-state-store.js' - -describe('File Task State Store', () => { - let storeDir: string - let store: FileTaskStateStore - - beforeEach(async () => { - storeDir = join(tmpdir(), `orchestrator-test-${Date.now()}`) - store = new FileTaskStateStore(storeDir) - }) - - afterEach(async () => { - try { - await rm(storeDir, { recursive: true, force: true }) - } catch { - // Ignore cleanup errors - } - }) - - describe('Basic operations', () => { - it('should store and retrieve taskState', async () => { - const taskState = { - language: { name: 'python' }, - snapshotTests: { 'test.js': 'hash123' } - } - - await store.saveTaskState('task-1', taskState) - const retrieved = await store.loadTaskState('task-1') - - expect(retrieved).toEqual(taskState) - }) - - it('should return null for non-existent task', async () => { - const result = await store.loadTaskState('nonexistent') - expect(result).toBeNull() - }) - - it('should delete taskState', async () => { - await store.saveTaskState('task-1', { data: 'test' }) - await store.deleteTaskState('task-1') - - const result = await store.loadTaskState('task-1') - expect(result).toBeNull() - }) - - it('should initialize taskState', async () => { - const initialState = { language: 'python' } - const result = await store.initializeTaskState('task-1', initialState) - - expect(result).toBe(true) - - const retrieved = await store.loadTaskState('task-1') - expect(retrieved).toEqual(initialState) - }) - - it('should not overwrite existing taskState on initialize', async () => { - await store.saveTaskState('task-1', { data: 'existing' }) - - const result = await store.initializeTaskState('task-1', { data: 'new' }) - - expect(result).toBe(false) - - const retrieved = await store.loadTaskState('task-1') - expect(retrieved).toEqual({ data: 'existing' }) - }) - }) - - describe('File persistence', () => { - it('should persist taskState across store instances', async () => { - // Given one store instance saves data - const taskState = { counter: 1 } - await store.saveTaskState('task-1', taskState) - - // When a new store instance is created with same directory - const newStore = new FileTaskStateStore(storeDir) - const retrieved = await newStore.loadTaskState('task-1') - - // Then data is persisted across instances - expect(retrieved).toEqual(taskState) - }) - - it('should save each taskState in a separate file', async () => { - await store.saveTaskState('task-1', { data: 'test1' }) - await store.saveTaskState('task-2', { data: 'test2' }) - - // Both tasks should be retrievable - expect(await store.loadTaskState('task-1')).toEqual({ data: 'test1' }) - expect(await store.loadTaskState('task-2')).toEqual({ data: 'test2' }) - }) - }) - - describe('US-11: Atomic operations', () => { - it('should save taskState atomically', async () => { - // Given a taskState - const taskState = { key: 'value' } - - // When saved - await store.saveTaskState('task-1', taskState) - - // Then either the entire taskState is persisted or none is - const retrieved = await store.loadTaskState('task-1') - expect(retrieved).toEqual(taskState) - }) - }) -}) diff --git a/orchestrator/packages/core/src/file-task-state-store.ts b/orchestrator/packages/core/src/file-task-state-store.ts deleted file mode 100644 index bac4c0e..0000000 --- a/orchestrator/packages/core/src/file-task-state-store.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { mkdir, writeFile, readFile, unlink, stat } from 'node:fs/promises' -import { join, dirname } from 'node:path' -import type { TaskStateStore } from './task-state-store.js' - -/** - * File-based Task State Store implementation. - * Persists taskState as JSON files in a directory. - * - * Each task's state is stored as: /.json - */ -export class FileTaskStateStore implements TaskStateStore { - #storeDir: string - - constructor(storeDir: string) { - this.#storeDir = storeDir - } - - async loadTaskState(taskId: string): Promise | null> { - const filePath = this.#getFilePath(taskId) - - try { - const content = await readFile(filePath, 'utf-8') - return JSON.parse(content) as Record - } catch { - return null - } - } - - async saveTaskState( - taskId: string, - taskState: Record - ): Promise { - const filePath = this.#getFilePath(taskId) - - try { - // Ensure directory exists - await mkdir(dirname(filePath), { recursive: true }) - - // Write atomically by writing to a temp file first, then renaming - // For simplicity, we'll write directly here - Node.js writeFile is atomic on most systems - const content = JSON.stringify(taskState, null, 2) - await writeFile(filePath, content, 'utf-8') - return true - } catch { - return false - } - } - - async deleteTaskState(taskId: string): Promise { - const filePath = this.#getFilePath(taskId) - - try { - await unlink(filePath) - return true - } catch { - return false - } - } - - async initializeTaskState( - taskId: string, - initialState: Record - ): Promise { - const filePath = this.#getFilePath(taskId) - - try { - // Check if file already exists - await stat(filePath) - // File exists, don't initialize - return false - } catch { - // File doesn't exist, create it - return this.saveTaskState(taskId, initialState) - } - } - - #getFilePath(taskId: string): string { - // Sanitize taskId to prevent directory traversal - const sanitized = taskId.replace(/[^a-zA-Z0-9_-]/g, '_') - return join(this.#storeDir, `${sanitized}.json`) - } -} diff --git a/orchestrator/packages/core/src/index.ts b/orchestrator/packages/core/src/index.ts index eed58d5..3661a16 100644 --- a/orchestrator/packages/core/src/index.ts +++ b/orchestrator/packages/core/src/index.ts @@ -4,7 +4,4 @@ export * from './validator.js' export * from './hook-executor.js' export * from './handlebars-renderer.js' export * from './orchestrator.js' -export * from './task-state-store.js' export * from './repo-installer.js' -export * from './memory-task-state-store.js' -export * from './memory-task-source.js' diff --git a/orchestrator/packages/core/src/memory-task-source.spec.ts b/orchestrator/packages/core/src/memory-task-source.spec.ts deleted file mode 100644 index 1a67a00..0000000 --- a/orchestrator/packages/core/src/memory-task-source.spec.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { MemoryTaskSource } from './memory-task-source.js' -import type { Task } from '@orchestrator/task-source' - -describe('Memory Task Source - US-2', () => { - describe('Task Source emits available tasks', () => { - it('should yield tasks via async iterator', async () => { - // Given a task is available for processing - const source = new MemoryTaskSource() - - const task: Task = { - id: 'task-1', - title: 'Test Task', - description: 'Test Description', - status: 'OPEN' as const, - tags: ['worker:reviewer'], - claimant: null, - createdAt: new Date(), - updatedAt: new Date(), - priority: 1 - } - - await source.addTask(task) - - // When the orchestrator polls the task source - const tasks: Task[] = [] - for await (const t of source.watchTasks()) { - tasks.push(t) - break // Get first task - } - - // Then the task source yields the task via its async iterator - expect(tasks).toHaveLength(1) - expect(tasks[0].id).toBe('task-1') - }) - - it('should handle concurrent polling without duplicates', async () => { - // US-2: Task Source supports concurrent polling - // Given two orchestrator instances poll simultaneously - const source = new MemoryTaskSource() - - const task: Task = { - id: 'task-1', - title: 'Test', - description: null, - status: 'OPEN' as const, - tags: ['worker:test'], - claimant: null, - createdAt: null, - updatedAt: null, - priority: 1 - } - - await source.addTask(task) - - // When both poll the task source - const tasks1: Task[] = [] - const tasks2: Task[] = [] - - const poll1 = (async () => { - for await (const t of source.watchTasks()) { - tasks1.push(t) - break - } - })() - - const poll2 = (async () => { - for await (const t of source.watchTasks()) { - tasks2.push(t) - break - } - })() - - await Promise.all([poll1, poll2]) - - // Then each task is yielded to at most one orchestrator - // (In memory implementation, first consumer wins) - expect(tasks1.length + tasks2.length).toBe(1) - }) - - it('should not yield tasks after they are completed', async () => { - // Given a task that has been completed - const source = new MemoryTaskSource() - - const task: Task = { - id: 'task-1', - title: 'Test', - description: null, - status: 'OPEN' as const, - tags: [], - claimant: null, - createdAt: null, - updatedAt: null, - priority: 1 - } - - await source.addTask(task) - await source.completeTask('task-1') - - // When the orchestrator polls - const tasks: Task[] = [] - for await (const t of source.watchTasks()) { - tasks.push(t) - break - } - - // Then completed tasks are not yielded - expect(tasks).toHaveLength(0) - }) - }) - - describe('Task lifecycle', () => { - it('should mark task as completed', async () => { - const source = new MemoryTaskSource() - - const task: Task = { - id: 'task-1', - title: 'Test', - description: null, - status: 'OPEN' as const, - tags: [], - claimant: null, - createdAt: null, - updatedAt: null, - priority: 1 - } - - await source.addTask(task) - const completed = await source.completeTask('task-1') - - expect(completed).toBe(true) - - const retrieved = await source.getTaskDetail('task-1') - expect(retrieved?.status).toBe('COMPLETED') - }) - - it('should mark task as failed', async () => { - const source = new MemoryTaskSource() - - const task: Task = { - id: 'task-1', - title: 'Test', - description: null, - status: 'OPEN' as const, - tags: [], - claimant: null, - createdAt: null, - updatedAt: null, - priority: 1 - } - - await source.addTask(task) - const failed = await source.failTask('task-1', 'Test error') - - expect(failed).toBe(true) - - const retrieved = await source.getTaskDetail('task-1') - expect(retrieved?.status).toBe('FAILED') - }) - - it('should retrieve task detail', async () => { - const source = new MemoryTaskSource() - - const task: Task = { - id: 'task-1', - title: 'Test Task', - description: 'Test Description', - status: 'OPEN' as const, - tags: ['worker:test'], - claimant: null, - createdAt: new Date('2026-01-01'), - updatedAt: new Date('2026-01-01'), - priority: 1 - } - - await source.addTask(task) - const detail = await source.getTaskDetail('task-1') - - expect(detail).toBeDefined() - expect(detail?.id).toBe('task-1') - expect(detail?.title).toBe('Test Task') - }) - }) -}) diff --git a/orchestrator/packages/core/src/memory-task-source.ts b/orchestrator/packages/core/src/memory-task-source.ts deleted file mode 100644 index 6578611..0000000 --- a/orchestrator/packages/core/src/memory-task-source.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { TaskSource, Task, TaskDetail, TaskStatus } from '@orchestrator/task-source' - -/** - * In-memory Task Source implementation. - * Useful for testing and single-process scenarios. - * - * Note: This implementation provides basic coordination for single-process scenarios. - * For distributed scenarios, use Redis, queue-based, or database-backed implementations. - */ -export class MemoryTaskSource implements TaskSource { - #tasks: Map - #pending: Set - #claimed: Set - - constructor() { - this.#tasks = new Map() - this.#pending = new Set() - this.#claimed = new Set() - } - - /** - * Add a task to the task source. - */ - async addTask(task: Task): Promise { - const taskDetail: TaskDetail = { - ...task, - dependencies: [], - notes: [], - acceptanceCriteria: [], - retro: [] - } - - this.#tasks.set(task.id, taskDetail) - this.#pending.add(task.id) - } - - /** - * Yield available tasks via async iterator. - * US-2: Task Source emits available tasks via async iterator - * - * This implementation provides basic coordination by tracking claimed tasks. - * In distributed scenarios, use proper locking or queue semantics. - */ - async *watchTasks(): AsyncGenerator { - // Poll for available tasks - // Continue while there are pending or claimed tasks (tasks being processed) - let iterations = 0 - const maxIterations = 10 // Prevent infinite loops in tests - - while (this.#pending.size > 0 || this.#claimed.size > 0) { - // Find first unclaimed pending task - for (const taskId of this.#pending) { - if (!this.#claimed.has(taskId)) { - const task = this.#tasks.get(taskId) - if (task && task.status === 'OPEN') { - // Mark as claimed - this.#claimed.add(taskId) - - // Update task status - task.status = 'IN_PROGRESS' as TaskStatus - task.claimant = 'memory-source' - - yield { - id: task.id, - title: task.title, - description: task.description, - status: task.status, - tags: task.tags, - claimant: task.claimant, - createdAt: task.createdAt, - updatedAt: task.updatedAt, - priority: task.priority - } - - // Return after yielding one task - return - } - } - } - - // No tasks available, wait a bit before polling again - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Safety check for tests - iterations++ - if (iterations > maxIterations) { - break - } - } - } - - async getTaskDetail(taskId: string): Promise { - return this.#tasks.get(taskId) ?? null - } - - async completeTask(taskId: string): Promise { - const task = this.#tasks.get(taskId) - if (!task) { - return false - } - - task.status = 'COMPLETED' as TaskStatus - this.#pending.delete(taskId) - this.#claimed.delete(taskId) - - return true - } - - async failTask(taskId: string, error: string): Promise { - const task = this.#tasks.get(taskId) - if (!task) { - return false - } - - task.status = 'FAILED' as TaskStatus - task.notes.push({ - id: `error-${Date.now()}`, - content: error, - createdAt: new Date() - }) - - this.#pending.delete(taskId) - this.#claimed.delete(taskId) - - return true - } - - /** - * Get all tasks (useful for testing). - */ - getAllTasks(): TaskDetail[] { - return Array.from(this.#tasks.values()) - } - - /** - * Clear all tasks (useful for testing). - */ - clear(): void { - this.#tasks.clear() - this.#pending.clear() - this.#claimed.clear() - } -} diff --git a/orchestrator/packages/core/src/memory-task-state-store.spec.ts b/orchestrator/packages/core/src/memory-task-state-store.spec.ts deleted file mode 100644 index 5644aaf..0000000 --- a/orchestrator/packages/core/src/memory-task-state-store.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { MemoryTaskStateStore } from './memory-task-state-store.js' - -describe('Memory Task State Store', () => { - describe('Basic operations', () => { - it('should store and retrieve taskState', async () => { - const store = new MemoryTaskStateStore() - - // Given a taskState - const taskState = { - language: { name: 'python' }, - snapshotTests: { 'test.js': 'hash123' } - } - - // When saved - await store.saveTaskState('task-1', taskState) - - // Then it can be retrieved - const retrieved = await store.loadTaskState('task-1') - expect(retrieved).toEqual(taskState) - }) - - it('should return null for non-existent task', async () => { - const store = new MemoryTaskStateStore() - - const result = await store.loadTaskState('nonexistent') - expect(result).toBeNull() - }) - - it('should delete taskState', async () => { - const store = new MemoryTaskStateStore() - - await store.saveTaskState('task-1', { data: 'test' }) - await store.deleteTaskState('task-1') - - const result = await store.loadTaskState('task-1') - expect(result).toBeNull() - }) - - it('should initialize taskState', async () => { - const store = new MemoryTaskStateStore() - - const initialState = { language: 'python' } - const result = await store.initializeTaskState('task-1', initialState) - - expect(result).toBe(true) - - const retrieved = await store.loadTaskState('task-1') - expect(retrieved).toEqual(initialState) - }) - }) - - describe('Concurrency and atomicity', () => { - it('should handle concurrent writes to same task', async () => { - const store = new MemoryTaskStateStore() - - // Given concurrent writes - const write1 = store.saveTaskState('task-1', { version: 1 }) - const write2 = store.saveTaskState('task-1', { version: 2 }) - - // When both complete - await Promise.all([write1, write2]) - - // Then final state is one of the writes (last write wins in memory) - const result = await store.loadTaskState('task-1') - expect(result).toBeDefined() - }) - }) -}) diff --git a/orchestrator/packages/core/src/memory-task-state-store.ts b/orchestrator/packages/core/src/memory-task-state-store.ts deleted file mode 100644 index 3715b4d..0000000 --- a/orchestrator/packages/core/src/memory-task-state-store.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { TaskStateStore } from './task-state-store.js' - -/** - * In-memory Task State Store implementation. - * Useful for testing and single-process scenarios. - * - * Note: This implementation is not persistent across process restarts. - * For production, use Redis, file-based, or database-backed implementations. - */ -export class MemoryTaskStateStore implements TaskStateStore { - #store: Map> - - constructor() { - this.#store = new Map() - } - - async loadTaskState(taskId: string): Promise | null> { - return this.#store.get(taskId) ?? null - } - - async saveTaskState( - taskId: string, - taskState: Record - ): Promise { - this.#store.set(taskId, { ...taskState }) - return true - } - - async deleteTaskState(taskId: string): Promise { - return this.#store.delete(taskId) - } - - async initializeTaskState( - taskId: string, - initialState: Record - ): Promise { - // Only initialize if not already present - if (!this.#store.has(taskId)) { - this.#store.set(taskId, { ...initialState }) - return true - } - return false - } - - /** - * Clear all stored task states. - * Useful for testing. - */ - clear(): void { - this.#store.clear() - } - - /** - * Get the number of stored task states. - */ - get size(): number { - return this.#store.size - } -} diff --git a/orchestrator/packages/core/src/orchestrator.spec.ts b/orchestrator/packages/core/src/orchestrator.spec.ts index 03ad9f4..fdec718 100644 --- a/orchestrator/packages/core/src/orchestrator.spec.ts +++ b/orchestrator/packages/core/src/orchestrator.spec.ts @@ -3,40 +3,32 @@ import { orchestrate } from './orchestrator.js' import type { AgentDefinition, HookExecutionContext } from './types.js' import type { Task, TaskSource, Engine, EngineResult } from '@orchestrator/task-source' -describe('Orchestrator Lifecycle - FR-14', () => { - describe('US-3: Agent Operator - Dispatch agent on task', () => { - it('should execute full orchestration lifecycle successfully', async () => { - // Given a task is yielded from the task source +describe('Orchestrator', () => { + describe('task execution lifecycle', () => { + it('should validate taskState → execute pre-hooks → invoke engine → execute post-hooks → report success', async () => { + // Given a task with valid taskState const task: Task = { id: 'task-1', - title: 'Review code', - description: 'Review PR #123', - status: 'OPEN' as const, - tags: ['worker:reviewer'], - claimant: null, - createdAt: new Date(), - updatedAt: new Date(), - priority: 1 + agentId: 'agent-1', + taskState: { language: 'Python' }, + metadata: { priority: 'high' } } - // And the agent is configured in the agent catalog const agent: AgentDefinition = { name: 'reviewer', description: 'Code review agent', tools: ['readFile', 'edit'], toolClasses: [], - template: { parameters: { language: { name: 'string' } } }, + template: { parameters: { language: { type: 'string' } } }, hooks: { Start: [], Stop: [] }, - promptBody: 'Review the {{language.name}} code.' + promptBody: 'Review the code.' } - const taskState = { language: { name: 'Python' } } - const mockTaskSource: TaskSource = { watchTasks: async function* () { yield task }, - getTaskDetail: vi.fn().mockResolvedValue(null), - completeTask: vi.fn().mockResolvedValue(true), - failTask: vi.fn().mockResolvedValue(true) + completeTask: vi.fn().mockResolvedValue(undefined), + failTask: vi.fn().mockResolvedValue(undefined), + setState: vi.fn().mockResolvedValue(undefined) } const mockEngine: Engine = { @@ -56,58 +48,152 @@ describe('Orchestrator Lifecycle - FR-14', () => { }) } - // When the orchestrator receives the task + // When orchestrating const result = await orchestrate({ task, agent, - taskState, taskSource: mockTaskSource, engine: mockEngine, projectDir: '/test/project' }) - // Then Start hooks are executed (none in this case) - // And the reviewer agent is invoked - expect(mockEngine.execute).toHaveBeenCalled() - - // And Stop hooks are executed (none in this case) - // And the task is marked complete + // Then validation passes, hooks execute, engine runs, task completes expect(result.outcome).toBe('completed') + expect(mockEngine.execute).toHaveBeenCalled() expect(mockTaskSource.completeTask).toHaveBeenCalledWith('task-1') }) - it('should mark task as failed when taskState validation fails', async () => { - // Given a task's taskState fails agent schema validation + it('should fail task when taskState validation fails', async () => { + // Given a task with invalid taskState const task: Task = { id: 'task-2', - title: 'Test', - description: null, - status: 'OPEN' as const, - tags: ['worker:test'], - claimant: null, - createdAt: null, - updatedAt: null, - priority: 1 + agentId: 'agent-1', + taskState: {}, // Missing required 'language' parameter + metadata: {} + } + + const agent: AgentDefinition = { + name: 'reviewer', + description: 'Code review agent', + tools: [], + toolClasses: [], + template: { parameters: { language: { type: 'string' } } }, + hooks: { Start: [], Stop: [] }, + promptBody: 'Review the code.' + } + + const mockTaskSource: TaskSource = { + watchTasks: async function* () { yield task }, + completeTask: vi.fn().mockResolvedValue(undefined), + failTask: vi.fn().mockResolvedValue(undefined), + setState: vi.fn().mockResolvedValue(undefined) + } + + const mockEngine: Engine = { + execute: vi.fn().mockResolvedValue({ + success: true, + skipFulfill: false, + lastMessage: 'Done', + stats: null + }) + } + + // When orchestrating + const result = await orchestrate({ + task, + agent, + taskSource: mockTaskSource, + engine: mockEngine, + projectDir: '/test/project' + }) + + // Then task fails, engine not called + expect(result.outcome).toBe('failed') + expect(mockTaskSource.failTask).toHaveBeenCalledWith('task-2', expect.stringContaining('language')) + expect(mockEngine.execute).not.toHaveBeenCalled() + }) + + it('should pass setState callback to engine', async () => { + const task: Task = { + id: 'task-1', + agentId: 'agent-1', + taskState: { step: 1 }, + metadata: {} } const agent: AgentDefinition = { name: 'test', - description: 'Test agent', + description: 'Test', tools: [], toolClasses: [], - template: { parameters: { language: { name: 'string' } } }, + template: { parameters: {} }, hooks: { Start: [], Stop: [] }, promptBody: 'Test' } - // Missing required language parameter - const taskState = {} + const mockTaskSource: TaskSource = { + watchTasks: async function* () { yield task }, + completeTask: vi.fn().mockResolvedValue(undefined), + failTask: vi.fn().mockResolvedValue(undefined), + setState: vi.fn().mockResolvedValue(undefined) + } + + let capturedSetState: ((state: Record) => Promise) | null = null + + const mockEngine: Engine = { + execute: vi.fn().mockImplementation(async (context) => { + capturedSetState = context.setState + return { + success: true, + skipFulfill: false, + lastMessage: 'Done', + stats: null + } + }) + } + + await orchestrate({ + task, + agent, + taskSource: mockTaskSource, + engine: mockEngine, + projectDir: '/test/project' + }) + + // Engine receives setState callback + expect(capturedSetState).toBeDefined() + + // Calling setState persists to task source + await capturedSetState!({ step: 2 }) + expect(mockTaskSource.setState).toHaveBeenCalledWith('task-1', { step: 2 }) + }) + + it('should fail task when Start hook returns fatal error', async () => { + const task: Task = { + id: 'task-1', + agentId: 'agent-1', + taskState: {}, + metadata: {} + } + + const agent: AgentDefinition = { + name: 'test', + description: 'Test', + tools: [], + toolClasses: [], + template: { parameters: {} }, + hooks: { + Start: [{ name: 'fatal-hook', scriptPath: '/fatal.mjs', timeout: 30000 }], + Stop: [] + }, + promptBody: 'Test' + } const mockTaskSource: TaskSource = { watchTasks: async function* () { yield task }, - getTaskDetail: vi.fn().mockResolvedValue(null), - completeTask: vi.fn().mockResolvedValue(true), - failTask: vi.fn().mockResolvedValue(true) + completeTask: vi.fn().mockResolvedValue(undefined), + failTask: vi.fn().mockResolvedValue(undefined), + setState: vi.fn().mockResolvedValue(undefined) } const mockEngine: Engine = { @@ -119,24 +205,34 @@ describe('Orchestrator Lifecycle - FR-14', () => { }) } - // When the orchestrator receives the task + const mockExec = vi.fn().mockResolvedValue({ + exitCode: 2, // Fatal + stdout: '', + stderr: 'Fatal error' + }) + const result = await orchestrate({ task, agent, - taskState, taskSource: mockTaskSource, engine: mockEngine, - projectDir: '/test/project' + projectDir: '/test/project', + hookExec: mockExec }) - // Then the task is marked as failed expect(result.outcome).toBe('failed') - expect(mockTaskSource.failTask).toHaveBeenCalledWith('task-2', expect.stringContaining('Missing required parameter')) + expect(mockTaskSource.failTask).toHaveBeenCalledWith('task-1', expect.stringContaining('fatal-hook')) expect(mockEngine.execute).not.toHaveBeenCalled() }) - it('should trigger follow-up when Stop hook returns exit code 1', async () => { - // Given Stop hooks that report issues (exit code 1) + it('should fail task when Stop hook returns fatal error', async () => { + const task: Task = { + id: 'task-1', + agentId: 'agent-1', + taskState: {}, + metadata: {} + } + const agent: AgentDefinition = { name: 'test', description: 'Test', @@ -145,66 +241,105 @@ describe('Orchestrator Lifecycle - FR-14', () => { template: { parameters: {} }, hooks: { Start: [], - Stop: [{ name: 'lint', scriptPath: '/lint.mjs', timeout: 30000 }] + Stop: [{ name: 'fatal-hook', scriptPath: '/fatal.mjs', timeout: 30000 }] }, promptBody: 'Test' } + const mockTaskSource: TaskSource = { + watchTasks: async function* () { yield task }, + completeTask: vi.fn().mockResolvedValue(undefined), + failTask: vi.fn().mockResolvedValue(undefined), + setState: vi.fn().mockResolvedValue(undefined) + } + + const mockEngine: Engine = { + execute: vi.fn().mockResolvedValue({ + success: true, + skipFulfill: false, + lastMessage: 'Done', + stats: null + }) + } + + const mockExec = vi.fn().mockResolvedValue({ + exitCode: 2, // Fatal + stdout: '', + stderr: 'Fatal error' + }) + + const result = await orchestrate({ + task, + agent, + taskSource: mockTaskSource, + engine: mockEngine, + projectDir: '/test/project', + hookExec: mockExec + }) + + expect(result.outcome).toBe('failed') + expect(mockTaskSource.failTask).toHaveBeenCalledWith('task-1', expect.stringContaining('fatal-hook')) + }) + + it('should support follow-up loop when Stop hook returns exit code 1', async () => { const task: Task = { - id: 'task-3', - title: 'Test', - description: null, - status: 'OPEN' as const, - tags: ['worker:test'], - claimant: null, - createdAt: null, - updatedAt: null, - priority: 1 + id: 'task-1', + agentId: 'agent-1', + taskState: {}, + metadata: {} } - const taskState = {} + const agent: AgentDefinition = { + name: 'test', + description: 'Test', + tools: [], + toolClasses: [], + template: { parameters: {} }, + hooks: { + Start: [], + Stop: [{ name: 'lint', scriptPath: '/lint.mjs', timeout: 30000 }] + }, + promptBody: 'Test' + } const mockTaskSource: TaskSource = { watchTasks: async function* () { yield task }, - getTaskDetail: vi.fn().mockResolvedValue(null), - completeTask: vi.fn().mockResolvedValue(true), - failTask: vi.fn().mockResolvedValue(true) - } - - const engineResult: EngineResult = { - success: true, - skipFulfill: false, - lastMessage: 'Done', - stats: null + completeTask: vi.fn().mockResolvedValue(undefined), + failTask: vi.fn().mockResolvedValue(undefined), + setState: vi.fn().mockResolvedValue(undefined) } const mockEngine: Engine = { execute: vi.fn() - .mockResolvedValueOnce(engineResult) .mockResolvedValueOnce({ - ...engineResult, - lastMessage: 'Fixed lint issues' + success: true, + skipFulfill: false, + lastMessage: 'First run', + stats: null + }) + .mockResolvedValueOnce({ + success: true, + skipFulfill: false, + lastMessage: 'Second run', + stats: null }) } const mockExec = vi.fn() - .mockResolvedValueOnce({ exitCode: 1, stdout: 'Lint errors', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 1, stdout: 'Fix lint issues', stderr: '' }) .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) - // When orchestrating const result = await orchestrate({ task, agent, - taskState, taskSource: mockTaskSource, engine: mockEngine, projectDir: '/test/project', hookExec: mockExec }) - // Then follow-up is triggered (engine called twice) - expect(mockEngine.execute).toHaveBeenCalledTimes(2) expect(result.outcome).toBe('completed') + expect(mockEngine.execute).toHaveBeenCalledTimes(2) }) }) }) diff --git a/orchestrator/packages/core/src/orchestrator.ts b/orchestrator/packages/core/src/orchestrator.ts index c7f8ed3..ce781bc 100644 --- a/orchestrator/packages/core/src/orchestrator.ts +++ b/orchestrator/packages/core/src/orchestrator.ts @@ -26,7 +26,6 @@ type HookExecFn = ( type OrchestrateOptions = { task: Task agent: AgentDefinition - taskState: Record taskSource: TaskSource engine: Engine projectDir: string @@ -41,26 +40,32 @@ type OrchestrateOptions = { export const orchestrate = async ( options: OrchestrateOptions ): Promise => { - const { task, agent, taskState, taskSource, engine, projectDir, hookExec } = options + const { task, agent, taskSource, engine, projectDir, hookExec } = options const startTime = Date.now() let totalTelemetry: EngineResult['stats'] = null let numTurns = 0 - // FR-14 step 4: Execute Start hooks with taskState + // Step 1: Validate taskState against agent parameter schema + const validation = validateTaskState(task.taskState, agent.template.parameters) + + if (!validation.valid) { + await taskSource.failTask(task.id, validation.errors.join('; ')) + return { outcome: 'failed', error: validation.errors.join('; ') } + } + + // Step 2: Execute pre-task hooks const hookContext: HookExecutionContext = { projectDir, - params: taskState, - taskState + params: task.taskState, + taskState: task.taskState } const defaultHookExec: HookExecFn = async () => ({ exitCode: 0, stdout: '', stderr: '' }) - const execFn = hookExec ?? defaultHookExec const startHookResults = await executeHooks(agent.hooks.Start, 'Start', hookContext, execFn) - // FR-14 step 5: If any hook exits with code 2: mark UoW as failed, notify task source for (const hook of startHookResults) { if (hook.fatal) { await taskSource.failTask(task.id, `Start hook ${hook.hookName} failed: ${hook.stderr}`) @@ -68,44 +73,26 @@ export const orchestrate = async ( } } - // FR-14 step 6: Validate taskState against template.parameters - const validation = validateTaskState(taskState, agent.template.parameters) - - if (!validation.valid) { - // FR-14 step 7: If validation fails: mark UoW as failed, notify task source - const error = validation.errors.join('; ') - await taskSource.failTask(task.id, error) - return { outcome: 'failed', error } - } - - // Extract params from taskState for Handlebars rendering - const params = taskState - - // FR-14 step 8: Render Handlebars prompt with taskState values - const renderedPrompt = renderPrompt(agent.promptBody, taskState) - - // FR-14 step 9: Build EngineContext and execute engine + // Step 3: Invoke engine with setState callback const engineContext: EngineContext = { taskId: task.id, workingDir: projectDir, agentName: agent.name, + taskState: task.taskState, + metadata: task.metadata, + setState: (newState: Record) => taskSource.setState(task.id, newState), verbose: false } // Main execution loop (handles follow-ups) - let maxFollowUps = 10 // Prevent infinite loops + let maxFollowUps = 10 let lastMessage = '' while (maxFollowUps-- > 0) { numTurns++ - // Execute engine - const engineResult: EngineResult = await engine.execute({ - ...engineContext, - // Pass rendered prompt and any follow-up context - }) + const engineResult: EngineResult = await engine.execute(engineContext) - // Accumulate telemetry if (engineResult.stats) { if (!totalTelemetry) { totalTelemetry = { ...engineResult.stats } @@ -122,13 +109,12 @@ export const orchestrate = async ( lastMessage = engineResult.lastMessage || lastMessage - // FR-14 step 11: Execute Stop hooks + // Step 4: Execute post-task hooks const stopHookResults = await executeHooks(agent.hooks.Stop, 'Stop', hookContext, execFn) let needsFollowUp = false let followUpMessage = '' - // FR-14 step 12: If any Stop hook exits with code 1: loop back to step 9 for (const hook of stopHookResults) { if (hook.needsFollowUp) { needsFollowUp = true @@ -136,7 +122,6 @@ export const orchestrate = async ( break } - // FR-14 step 13: If any Stop hook exits with code 2: mark UoW as failed if (hook.fatal) { await taskSource.failTask(task.id, `Stop hook ${hook.hookName} failed: ${hook.stderr}`) return { outcome: 'failed', error: hook.stderr } @@ -147,8 +132,6 @@ export const orchestrate = async ( break } - // FR-14 step 12: loop back to step 9 with follow-up message - // Follow-up: execute engine again with hook output as context if (engine.sendFollowUp) { const followUpResult = await engine.sendFollowUp(followUpMessage) if (followUpResult.stats) { @@ -165,18 +148,12 @@ export const orchestrate = async ( } } numTurns++ - // Continue to next iteration to run Stop hooks again - } else { - // No sendFollowUp method - continue loop to execute again - // Continue to next iteration which will call execute again } } - // FR-14 step 14: Save final taskState to Task State Store (omitted - uses Task State Store plugin) - // FR-14 step 15: Mark task as complete via Task Source + // Step 5: Report success await taskSource.completeTask(task.id) - // FR-14 step 16: Append completion note with telemetry const durationMs = Date.now() - startTime return { diff --git a/orchestrator/packages/core/src/task-state-store.spec.ts b/orchestrator/packages/core/src/task-state-store.spec.ts deleted file mode 100644 index fa747a8..0000000 --- a/orchestrator/packages/core/src/task-state-store.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect } from 'vitest' -import type { TaskStateStore } from './task-state-store.js' - -describe('Task State Store - FR-3', () => { - describe('FR-3: Task State Store Interface', () => { - it('should implement loadTaskState method', async () => { - // US-11: Task State persistence across hook executions - const store: TaskStateStore = { - async loadTaskState(taskId: string) { - if (taskId === 'existing-task') { - return { snapshotTests: { 'test.js': 'hash123' } } - } - return null - }, - async saveTaskState(taskId: string, taskState: Record) { - return taskId === 'existing-task' - }, - async deleteTaskState(taskId: string) { - return true - }, - async initializeTaskState(taskId: string, initialState: Record) { - return true - } - } - - // When a new hook execution begins - const state = await store.loadTaskState('existing-task') - - // Then the Task State Store loads the persisted taskState - expect(state).toEqual({ snapshotTests: { 'test.js': 'hash123' } }) - }) - - it('should implement saveTaskState method', async () => { - const store: TaskStateStore = { - async loadTaskState() { - return null - }, - async saveTaskState(taskId: string, taskState: Record) { - return taskId === 'task-1' - }, - async deleteTaskState(taskId: string) { - return true - }, - async initializeTaskState(taskId: string, initialState: Record) { - return true - } - } - - // Given a Start hook that writes data into taskState - const newState = { snapshotTests: { 'test.js': 'hash123' } } - - // When the Start hook completes - const saved = await store.saveTaskState('task-1', newState) - - // Then the Task State Store persists the updated taskState - expect(saved).toBe(true) - }) - - it('should implement deleteTaskState method', async () => { - const store: TaskStateStore = { - async loadTaskState() { - return null - }, - async saveTaskState() { - return true - }, - async deleteTaskState(taskId: string) { - return taskId === 'task-1' - }, - async initializeTaskState() { - return true - } - } - - const deleted = await store.deleteTaskState('task-1') - expect(deleted).toBe(true) - }) - - it('should implement initializeTaskState method', async () => { - // US-11: Initialize taskState for a new task - const store: TaskStateStore = { - async loadTaskState() { - return null - }, - async saveTaskState() { - return true - }, - async deleteTaskState() { - return true - }, - async initializeTaskState(taskId: string, initialState: Record) { - return taskId !== 'error-task' - } - } - - // Given a task is received for the first time - const initialState = { language: 'python' } - - // When initializeTaskState is called - const initialized = await store.initializeTaskState('task-1', initialState) - - // Then it succeeds - expect(initialized).toBe(true) - }) - }) - - describe('US-11: Task State persistence across hook executions', () => { - it('should allow Stop hook to read data written by Start hook', async () => { - // Given a Start hook that writes taskState.snapshotTests - const store: TaskStateStore = { - async loadTaskState() { - return { snapshotTests: { 'test.js': 'hash123' } } - }, - async saveTaskState() { - return true - }, - async deleteTaskState() { - return true - }, - async initializeTaskState() { - return true - } - } - - // When the Start hook completes - // And the Task State Store persists the updated taskState - // Then the Stop hook can read taskState.snapshotTests in a subsequent execution - const state = await store.loadTaskState('task-1') - expect(state?.snapshotTests).toEqual({ 'test.js': 'hash123' }) - }) - }) -}) diff --git a/orchestrator/packages/core/src/task-state-store.ts b/orchestrator/packages/core/src/task-state-store.ts deleted file mode 100644 index 33f58dc..0000000 --- a/orchestrator/packages/core/src/task-state-store.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * FR-3: Task State Store Interface - * US-11: Task State persistence across hook executions - * - * Task State Store implementations MUST be thread-safe and support concurrent access. - * Each operation MUST be atomic. The orchestrator does not implement additional consistency mechanisms. - */ -export type TaskStateStore = { - /** - * Load taskState for a task. - * US-11: When a new hook execution begins, the Task State Store loads the persisted taskState. - */ - loadTaskState: (taskId: string) => Promise | null> - - /** - * Persist taskState for a task. - * US-11: When a hook completes successfully, the save operation persists taskState. - * The save operation is atomic - either entire taskState is persisted or none is. - */ - saveTaskState: (taskId: string, taskState: Record) => Promise - - /** - * Delete taskState for a task. - */ - deleteTaskState: (taskId: string) => Promise - - /** - * Initialize taskState for a new task. - * US-11: When a task is received for the first time, initializeTaskState is called. - */ - initializeTaskState: (taskId: string, initialState: Record) => Promise -} diff --git a/orchestrator/packages/core/tsconfig.json b/orchestrator/packages/core/tsconfig.json index bec40ab..17c09dd 100644 --- a/orchestrator/packages/core/tsconfig.json +++ b/orchestrator/packages/core/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", + "rootDir": "./src" }, "references": [ { diff --git a/orchestrator/packages/core/vite.config.ts b/orchestrator/packages/core/vite.config.ts index 6676826..f904168 100644 --- a/orchestrator/packages/core/vite.config.ts +++ b/orchestrator/packages/core/vite.config.ts @@ -1,31 +1,10 @@ -import { defineConfig } from 'vite' -import dts from 'vite-plugin-dts' +import pkg from './package.json' +// @ts-ignore +import tsconfig from './tsconfig.json' +import base from '../../vite.base' -export default defineConfig({ - plugins: [ - dts({ - tsconfigPath: './tsconfig.json', - rollupTypes: true, - }), - ], - build: { - lib: { - entry: './src/index.ts', - name: 'Core', - formats: ['es'], - fileName: 'index', - }, - rollupOptions: { - external: ['@orchestrator/engine', '@orchestrator/task-source', 'gray-matter'], - output: { - globals: { - '@orchestrator/engine': 'Engine', - '@orchestrator/task-source': 'TaskSource', - 'gray-matter': 'grayMatter', - }, - }, - }, - target: 'esnext', - emptyOutDir: true, - }, -}) +export default base({ + name: 'core', + pkg, + tsconfig +}) \ No newline at end of file diff --git a/orchestrator/packages/engine/src/test-engine.ts b/orchestrator/packages/engine/src/test-engine.ts index a3fd624..9b4b674 100644 --- a/orchestrator/packages/engine/src/test-engine.ts +++ b/orchestrator/packages/engine/src/test-engine.ts @@ -1,4 +1,5 @@ -import type { Engine, EngineContext, EngineResult } from './types.js' +import type { Engine } from './interface.js' +import type { EngineContext, EngineResult } from './types.js' export type TestEngineConfig = { success?: boolean @@ -39,9 +40,8 @@ export class TestEngine implements Engine { const startTime = Date.now() - // Generate mock telemetry - const stats: EngineResult['stats'] = this.#config.mockStats ?? { - durationMs: this.#config.simulateDelay ?? 10, + const defaultStats: EngineResult['stats'] = { + durationMs: 0, inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, @@ -50,10 +50,11 @@ export class TestEngine implements Engine { numTurns: 1 } - // Override duration based on actual execution time - if (!this.#config.mockStats) { - stats.durationMs = Date.now() - startTime - } + const stats: EngineResult['stats'] = this.#config.mockStats + ? { ...defaultStats, ...this.#config.mockStats } + : defaultStats + + stats.durationMs = Date.now() - startTime return { success: this.#config.success ?? true, diff --git a/orchestrator/packages/engine/src/types.ts b/orchestrator/packages/engine/src/types.ts index ea1e3fb..d34457d 100644 --- a/orchestrator/packages/engine/src/types.ts +++ b/orchestrator/packages/engine/src/types.ts @@ -1,8 +1,10 @@ -// FR-2: EngineContext MUST contain export type EngineContext = { taskId: string workingDir: string agentName: string + taskState: Record + metadata: Record + setState: (newState: Record) => Promise verbose: boolean } diff --git a/orchestrator/packages/engine/vite.config.ts b/orchestrator/packages/engine/vite.config.ts index ce48f13..091920a 100644 --- a/orchestrator/packages/engine/vite.config.ts +++ b/orchestrator/packages/engine/vite.config.ts @@ -1,27 +1,10 @@ -import { defineConfig } from 'vite' -import dts from 'vite-plugin-dts' +import pkg from './package.json' +// @ts-ignore +import tsconfig from './tsconfig.json' +import base from '../../vite.base' -export default defineConfig({ - plugins: [ - dts({ - tsconfigPath: './tsconfig.json', - rollupTypes: true, - }), - ], - build: { - lib: { - entry: './src/index.ts', - name: 'Engine', - formats: ['es'], - fileName: 'index', - }, - rollupOptions: { - external: [], - output: { - globals: {}, - }, - }, - target: 'esnext', - emptyOutDir: true, - }, -}) +export default base({ + name: 'engine', + pkg, + tsconfig +}) \ No newline at end of file diff --git a/orchestrator/packages/task-source-memory/package.json b/orchestrator/packages/task-source-memory/package.json new file mode 100644 index 0000000..c3f888a --- /dev/null +++ b/orchestrator/packages/task-source-memory/package.json @@ -0,0 +1,17 @@ +{ + "name": "@orchestrator/task-source-memory", + "version": "1.0.0", + "description": "In-memory task source implementation for Orchestrator Framework", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "vite build" + } +} diff --git a/orchestrator/packages/task-source-memory/src/index.ts b/orchestrator/packages/task-source-memory/src/index.ts new file mode 100644 index 0000000..384776a --- /dev/null +++ b/orchestrator/packages/task-source-memory/src/index.ts @@ -0,0 +1 @@ +export { MemoryTaskSource } from './memory-task-source.js' diff --git a/orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts b/orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts new file mode 100644 index 0000000..56f8c9b --- /dev/null +++ b/orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from 'vitest' +import { MemoryTaskSource } from './memory-task-source.js' + +describe('MemoryTaskSource', () => { + describe('watchTasks', () => { + it('should yield tasks with id, agentId, taskState, and metadata', async () => { + const source = new MemoryTaskSource() + + await source.addTask({ + id: 'task-1', + agentId: 'agent-1', + taskState: { foo: 'bar' }, + metadata: { tags: ['bug'] } + }) + + const tasks: string[] = [] + for await (const task of source.watchTasks()) { + tasks.push(task.id) + expect(task.id).toBe('task-1') + expect(task.agentId).toBe('agent-1') + expect(task.taskState).toEqual({ foo: 'bar' }) + expect(task.metadata).toEqual({ tags: ['bug'] }) + break + } + + expect(tasks).toHaveLength(1) + }) + + it('should mark task as IN_PROGRESS when yielded', async () => { + const source = new MemoryTaskSource() + + await source.addTask({ + id: 'task-1', + agentId: 'agent-1', + taskState: {}, + metadata: {} + }) + + for await (const task of source.watchTasks()) { + const internal = source.getInternalTask(task.id) + expect(internal?.status).toBe('IN_PROGRESS') + break + } + }) + + it('should not yield the same task twice', async () => { + const source = new MemoryTaskSource() + + await source.addTask({ + id: 'task-1', + agentId: 'agent-1', + taskState: {}, + metadata: {} + }) + + let yieldedCount = 0 + for await (const _task of source.watchTasks()) { + yieldedCount++ + } + + expect(yieldedCount).toBe(1) + }) + }) + + describe('completeTask', () => { + it('should mark task as COMPLETED', async () => { + const source = new MemoryTaskSource() + + await source.addTask({ + id: 'task-1', + agentId: 'agent-1', + taskState: {}, + metadata: {} + }) + + await source.completeTask('task-1') + + const internal = source.getInternalTask('task-1') + expect(internal?.status).toBe('COMPLETED') + }) + + it('should throw if task not found', async () => { + const source = new MemoryTaskSource() + + await expect(source.completeTask('unknown')).rejects.toThrow('Task unknown not found') + }) + }) + + describe('failTask', () => { + it('should mark task as FAILED with error message', async () => { + const source = new MemoryTaskSource() + + await source.addTask({ + id: 'task-1', + agentId: 'agent-1', + taskState: {}, + metadata: {} + }) + + await source.failTask('task-1', 'Test error') + + const internal = source.getInternalTask('task-1') + expect(internal?.status).toBe('FAILED') + expect(internal?.error).toBe('Test error') + }) + + it('should throw if task not found', async () => { + const source = new MemoryTaskSource() + + await expect(source.failTask('unknown', 'error')).rejects.toThrow('Task unknown not found') + }) + }) + + describe('setState', () => { + it('should update taskState', async () => { + const source = new MemoryTaskSource() + + await source.addTask({ + id: 'task-1', + agentId: 'agent-1', + taskState: { foo: 'bar' }, + metadata: {} + }) + + await source.setState('task-1', { foo: 'baz', newField: 'value' }) + + const internal = source.getInternalTask('task-1') + expect(internal?.taskState).toEqual({ foo: 'baz', newField: 'value' }) + }) + + it('should throw if task not found', async () => { + const source = new MemoryTaskSource() + + await expect(source.setState('unknown', {})).rejects.toThrow('Task unknown not found') + }) + }) + + describe('orchestration lifecycle', () => { + it('should support full task lifecycle: add → watch → setState → complete', async () => { + const source = new MemoryTaskSource() + + // Add task + await source.addTask({ + id: 'task-1', + agentId: 'agent-1', + taskState: { step: 1 }, + metadata: { priority: 'high' } + }) + + // Watch task + for await (const task of source.watchTasks()) { + expect(task.id).toBe('task-1') + + // Engine updates state + await source.setState(task.id, { step: 2 }) + + const internal = source.getInternalTask(task.id) + expect(internal?.taskState).toEqual({ step: 2 }) + + // Complete task + await source.completeTask(task.id) + + expect(source.getInternalTask(task.id)?.status).toBe('COMPLETED') + break + } + }) + + it('should support failed task lifecycle: add → watch → fail', async () => { + const source = new MemoryTaskSource() + + await source.addTask({ + id: 'task-1', + agentId: 'agent-1', + taskState: {}, + metadata: {} + }) + + for await (const task of source.watchTasks()) { + await source.failTask(task.id, 'Execution failed') + + expect(source.getInternalTask(task.id)?.status).toBe('FAILED') + expect(source.getInternalTask(task.id)?.error).toBe('Execution failed') + break + } + }) + }) +}) diff --git a/orchestrator/packages/task-source-memory/src/memory-task-source.ts b/orchestrator/packages/task-source-memory/src/memory-task-source.ts new file mode 100644 index 0000000..3d22e46 --- /dev/null +++ b/orchestrator/packages/task-source-memory/src/memory-task-source.ts @@ -0,0 +1,100 @@ +import type { TaskSource, Task } from '@orchestrator/task-source' + +type InternalTask = { + id: string + agentId: string + taskState: Record + metadata: Record + status: 'OPEN' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED' + error?: string +} + +export class MemoryTaskSource implements TaskSource { + #tasks: Map = new Map() + #pending: Set = new Set() + #claimed: Set = new Set() + + async addTask(task: Omit): Promise { + const internalTask: InternalTask = { + ...task, + status: 'OPEN' + } + this.#tasks.set(task.id, internalTask) + this.#pending.add(task.id) + } + + async *watchTasks(): AsyncGenerator { + const maxIterations = 100 + let iterations = 0 + + while ((this.#pending.size > 0 || this.#claimed.size > 0) && iterations < maxIterations) { + for (const taskId of this.#pending) { + if (!this.#claimed.has(taskId)) { + const task = this.#tasks.get(taskId) + if (task && task.status === 'OPEN') { + this.#claimed.add(taskId) + task.status = 'IN_PROGRESS' + + yield { + id: task.id, + agentId: task.agentId, + taskState: task.taskState, + metadata: task.metadata + } + + return + } + } + } + + await new Promise((resolve) => setTimeout(resolve, 50)) + iterations++ + } + } + + async completeTask(taskId: string): Promise { + const task = this.#tasks.get(taskId) + if (!task) { + throw new Error(`Task ${taskId} not found`) + } + + task.status = 'COMPLETED' + this.#pending.delete(taskId) + this.#claimed.delete(taskId) + } + + async failTask(taskId: string, error: string): Promise { + const task = this.#tasks.get(taskId) + if (!task) { + throw new Error(`Task ${taskId} not found`) + } + + task.status = 'FAILED' + task.error = error + this.#pending.delete(taskId) + this.#claimed.delete(taskId) + } + + async setState(taskId: string, taskState: Record): Promise { + const task = this.#tasks.get(taskId) + if (!task) { + throw new Error(`Task ${taskId} not found`) + } + + task.taskState = { ...taskState } + } + + getInternalTask(taskId: string): InternalTask | undefined { + return this.#tasks.get(taskId) + } + + getAllTasks(): InternalTask[] { + return Array.from(this.#tasks.values()) + } + + clear(): void { + this.#tasks.clear() + this.#pending.clear() + this.#claimed.clear() + } +} diff --git a/orchestrator/packages/task-source-memory/tsconfig.json b/orchestrator/packages/task-source-memory/tsconfig.json new file mode 100644 index 0000000..49cc6d1 --- /dev/null +++ b/orchestrator/packages/task-source-memory/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/orchestrator/packages/task-source-memory/vite.config.ts b/orchestrator/packages/task-source-memory/vite.config.ts new file mode 100644 index 0000000..77415e8 --- /dev/null +++ b/orchestrator/packages/task-source-memory/vite.config.ts @@ -0,0 +1,10 @@ +import pkg from './package.json' +// @ts-ignore +import tsconfig from './tsconfig.json' +import base from '../../vite.base' + +export default base({ + name: 'tast-source-memory', + pkg, + tsconfig +}) \ No newline at end of file diff --git a/orchestrator/packages/task-source/src/api-task-source.spec.ts b/orchestrator/packages/task-source/src/api-task-source.spec.ts deleted file mode 100644 index 1de4ec6..0000000 --- a/orchestrator/packages/task-source/src/api-task-source.spec.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { APITaskSource } from './api-task-source.js' -import type { Task } from '@orchestrator/task-source' - -// Mock fetch globally -const mockFetch = vi.fn() -global.fetch = mockFetch as any - -describe('API Task Source', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('US-2: Task Source emits available tasks', () => { - it('should poll API and yield available tasks', async () => { - // Given a task is available for processing - const mockTask: Task = { - id: 'task-1', - title: 'Test Task', - description: 'Test Description', - status: 'OPEN' as const, - tags: ['worker:reviewer'], - claimant: null, - createdAt: new Date(), - updatedAt: new Date(), - priority: 1 - } - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => [mockTask] - } as Response) - - const source = new APITaskSource({ - baseUrl: 'https://api.example.com', - pollInterval: 100 - }) - - // When the orchestrator polls the task source - const tasks: Task[] = [] - for await (const t of source.watchTasks()) { - tasks.push(t) - break - } - - // Then the task source yields the task via its async iterator - expect(tasks).toHaveLength(1) - expect(tasks[0].id).toBe('task-1') - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.example.com/tasks?status=OPEN', - expect.any(Object) - ) - }) - - it('should respect poll interval', async () => { - const source = new APITaskSource({ - baseUrl: 'https://api.example.com', - pollInterval: 50 - }) - - // Return empty tasks array - mockFetch.mockResolvedValue({ - ok: true, - json: async () => [] - } as Response) - - // Close after a short time - setTimeout(() => source.close(), 100) - - // Start polling - const pollPromise = (async () => { - for await (const _task of source.watchTasks()) { - // Won't be reached since no tasks - } - })() - - await pollPromise - - // Should have attempted polls - expect(mockFetch).toHaveBeenCalled() - }) - }) - - describe('Task lifecycle methods', () => { - it('should get task detail', async () => { - const mockTaskDetail = { - id: 'task-1', - title: 'Test', - description: 'Description', - status: 'OPEN' as const, - tags: [], - claimant: null, - createdAt: new Date(), - updatedAt: new Date(), - priority: 1, - dependencies: [], - notes: [], - acceptanceCriteria: [], - retro: [] - } - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => mockTaskDetail - } as Response) - - const source = new APITaskSource({ - baseUrl: 'https://api.example.com' - }) - - const detail = await source.getTaskDetail('task-1') - - expect(detail).toEqual(mockTaskDetail) - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.example.com/tasks/task-1', - expect.any(Object) - ) - }) - - it('should complete task', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ success: true }) - } as Response) - - const source = new APITaskSource({ - baseUrl: 'https://api.example.com' - }) - - const result = await source.completeTask('task-1') - - expect(result).toBe(true) - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.example.com/tasks/task-1/complete', - expect.objectContaining({ - method: 'POST' - }) - ) - }) - - it('should fail task', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ success: true }) - } as Response) - - const source = new APITaskSource({ - baseUrl: 'https://api.example.com' - }) - - const result = await source.failTask('task-1', 'Test error') - - expect(result).toBe(true) - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.example.com/tasks/task-1/fail', - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('Test error') - }) - ) - }) - }) - - describe('Error handling', () => { - it('should handle API errors gracefully', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500 - } as Response) - - const source = new APITaskSource({ - baseUrl: 'https://api.example.com', - pollInterval: 50 - }) - - // Close after a short time - setTimeout(() => source.close(), 150) - - // Should not throw, should continue polling - const tasks: Task[] = [] - for await (const task of source.watchTasks()) { - tasks.push(task) - } - - // Should have attempted polls despite errors - expect(mockFetch).toHaveBeenCalled() - }) - - it('should include telemetry in completion notes', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ success: true }) - } as Response) - - const source = new APITaskSource({ - baseUrl: 'https://api.example.com' - }) - - // When completing task with telemetry - await source.completeTask('task-1') - - // Then request is made - expect(mockFetch).toHaveBeenCalled() - }) - }) -}) diff --git a/orchestrator/packages/task-source/src/api-task-source.ts b/orchestrator/packages/task-source/src/api-task-source.ts deleted file mode 100644 index 721bf23..0000000 --- a/orchestrator/packages/task-source/src/api-task-source.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { TaskSource, Task, TaskDetail, TaskStatus } from './types.js' - -export type APITaskSourceConfig = { - baseUrl: string - pollInterval?: number // milliseconds, default 10000 - headers?: Record - timeout?: number // request timeout in milliseconds, default 30000 -} - -/** - * API-based Task Source implementation. - * Polls an HTTP API endpoint for available tasks. - * - * US-2: Task Source emits available tasks via async iterator - * FR-1: Task Source Interface - */ -export class APITaskSource implements TaskSource { - #config: APITaskSourceConfig - #abortController: AbortController | null = null - - constructor(config: APITaskSourceConfig) { - this.#config = { - pollInterval: 10000, - timeout: 30000, - ...config - } - } - - /** - * Yield available tasks via async iterator. - * US-2: Task Source emits available tasks via async iterator - * - * This method polls the API endpoint at the configured interval. - * The API endpoint is responsible for coordination (claiming, locking). - */ - async *watchTasks(): AsyncGenerator { - this.#abortController = new AbortController() - - while (!this.#abortController.signal.aborted) { - try { - // FR-1: Yield available tasks from API - const tasks = await this.#fetchTasks() - - for (const task of tasks) { - yield task - } - } catch (error) { - // Log error but continue polling (FR-15: reconnection) - console.error('Error polling task source:', error) - } - - // Wait before next poll - await this.#delay(this.#config.pollInterval!) - } - } - - /** - * Fetch tasks from the API. - */ - async #fetchTasks(): Promise { - const url = `${this.#config.baseUrl}/tasks?status=OPEN` - - const response = await fetch(url, { - signal: this.#abortController?.signal, - headers: this.#config.headers - }) - - if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`) - } - - const tasks = await response.json() - return tasks as Task[] - } - - /** - * Delay helper. - */ - #delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) - } - - /** - * Retrieve full task details. - * FR-1: getTaskDetail method - */ - async getTaskDetail(taskId: string): Promise { - const url = `${this.#config.baseUrl}/tasks/${taskId}` - - const response = await fetch(url, { - signal: this.#abortController?.signal, - headers: this.#config.headers - }) - - if (!response.ok) { - if (response.status === 404) { - return null - } - throw new Error(`API error: ${response.status} ${response.statusText}`) - } - - return (await response.json()) as TaskDetail - } - - /** - * Mark task as fulfilled. - * FR-1: completeTask method - */ - async completeTask(taskId: string): Promise { - const url = `${this.#config.baseUrl}/tasks/${taskId}/complete` - - const response = await fetch(url, { - method: 'POST', - signal: this.#abortController?.signal, - headers: { - ...this.#config.headers, - 'Content-Type': 'application/json' - } - }) - - if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`) - } - - return true - } - - /** - * Mark task as failed. - * FR-1: failTask method - */ - async failTask(taskId: string, error: string): Promise { - const url = `${this.#config.baseUrl}/tasks/${taskId}/fail` - - const response = await fetch(url, { - method: 'POST', - signal: this.#abortController?.signal, - headers: { - ...this.#config.headers, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ error }) - }) - - if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`) - } - - return true - } - - /** - * Stop polling tasks. - */ - close(): void { - this.#abortController?.abort() - } -} diff --git a/orchestrator/packages/task-source/src/index.ts b/orchestrator/packages/task-source/src/index.ts index 5941a39..dca6fdc 100644 --- a/orchestrator/packages/task-source/src/index.ts +++ b/orchestrator/packages/task-source/src/index.ts @@ -1,3 +1,2 @@ export * from './types.js' export * from './interface.js' -export * from './api-task-source.js' diff --git a/orchestrator/packages/task-source/src/interface.ts b/orchestrator/packages/task-source/src/interface.ts index 2519896..e74e7d3 100644 --- a/orchestrator/packages/task-source/src/interface.ts +++ b/orchestrator/packages/task-source/src/interface.ts @@ -1,16 +1,13 @@ -import { Task, TaskDetail } from './types.js' +import { Task } from './types.js' -// FR-1: Task Source Interface export type TaskSource = { - // Yield available tasks. Task source responsible for coordination. + // Yield tasks with ALL data needed watchTasks: () => AsyncGenerator - // Retrieve full task details - getTaskDetail: (taskId: string) => Promise + // Report completion/failure + completeTask: (taskId: string) => Promise + failTask: (taskId: string, error: string) => Promise - // Mark task as fulfilled - completeTask: (taskId: string) => Promise - - // Mark task as failed - failTask: (taskId: string, error: string) => Promise + // Engine calls this to persist state updates during execution + setState: (taskId: string, taskState: Record) => Promise } diff --git a/orchestrator/packages/task-source/src/types.ts b/orchestrator/packages/task-source/src/types.ts index c1df70f..c80f378 100644 --- a/orchestrator/packages/task-source/src/types.ts +++ b/orchestrator/packages/task-source/src/types.ts @@ -1,4 +1,4 @@ -// FR-1: Task Status enum values +// Task Status enum - used by task source implementations internally export const TaskStatus = { OPEN: 'OPEN', IN_PROGRESS: 'IN_PROGRESS', @@ -9,17 +9,12 @@ export const TaskStatus = { export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus] -// FR-1: Task aggregate +// Minimal Task type - orchestrator treats metadata as opaque export type Task = { id: string - title: string - description: string | null - status: TaskStatus - tags: string[] - claimant: string | null - createdAt: Date | null - updatedAt: Date | null - priority: number + agentId: string + taskState: Record + metadata: Record } // FR-1: TaskDetail extends Task diff --git a/orchestrator/packages/task-source/vite.config.ts b/orchestrator/packages/task-source/vite.config.ts index 7b59ef3..678fc45 100644 --- a/orchestrator/packages/task-source/vite.config.ts +++ b/orchestrator/packages/task-source/vite.config.ts @@ -1,27 +1,10 @@ -import { defineConfig } from 'vite' -import dts from 'vite-plugin-dts' +import pkg from './package.json' +// @ts-ignore +import tsconfig from './tsconfig.json' +import base from '../../vite.base' -export default defineConfig({ - plugins: [ - dts({ - tsconfigPath: './tsconfig.json', - rollupTypes: true, - }), - ], - build: { - lib: { - entry: './src/index.ts', - name: 'TaskSource', - formats: ['es'], - fileName: 'index', - }, - rollupOptions: { - external: [], - output: { - globals: {}, - }, - }, - target: 'esnext', - emptyOutDir: true, - }, -}) +export default base({ + name: 'task-source', + pkg, + tsconfig +}) \ No newline at end of file diff --git a/orchestrator/tsconfig.base.json b/orchestrator/tsconfig.base.json index 22a206b..700bca6 100644 --- a/orchestrator/tsconfig.base.json +++ b/orchestrator/tsconfig.base.json @@ -14,6 +14,5 @@ "declarationMap": true, "sourceMap": true, "composite": true - }, - "exclude": ["node_modules", "dist"] + } } diff --git a/orchestrator/tsconfig.json b/orchestrator/tsconfig.json index 846ae6e..7082629 100644 --- a/orchestrator/tsconfig.json +++ b/orchestrator/tsconfig.json @@ -11,6 +11,9 @@ }, { "path": "./packages/task-source" + }, + { + "path": "./packages/task-source-memory" } ] } \ No newline at end of file diff --git a/orchestrator/vite.base.ts b/orchestrator/vite.base.ts new file mode 100644 index 0000000..6aa3f89 --- /dev/null +++ b/orchestrator/vite.base.ts @@ -0,0 +1,37 @@ +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' + +export default ({ + name, + tsconfig, + pkg, +}: { + name: string, + tsconfig: { references?: Array<{ path: string }> }, + pkg: Record, +}) => defineConfig({ + plugins: [ + dts({ + tsconfigPath: './tsconfig.json', + }), + ], + build: { + lib: { + entry: './src/index.ts', + name, + formats: ['es'], + fileName: 'index', + }, + rollupOptions: { + external: [ + ...Object.keys(('dependencies' in pkg && pkg.dependencies) ?? {}), + ...Object.keys(('peerDependencies' in pkg && pkg.peerDependencies) ?? {}), + ...(('references' in tsconfig && tsconfig.references) || []).map((x: any) => x.path), + /^node:.*$/, + /node_modules/ + ], + }, + target: 'node20', + emptyOutDir: true, + }, +}) \ No newline at end of file diff --git a/orchestrator/vitest.config.ts b/orchestrator/vitest.config.ts index d87fc4a..1fd34fe 100644 --- a/orchestrator/vitest.config.ts +++ b/orchestrator/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { globals: true, environment: 'node', + setupFiles: './vitest.setup.ts' }, }) diff --git a/orchestrator/vitest.setup.ts b/orchestrator/vitest.setup.ts new file mode 100644 index 0000000..b70612c --- /dev/null +++ b/orchestrator/vitest.setup.ts @@ -0,0 +1,4 @@ +console.log = () => {}; +console.warn = () => {}; +console.error = () => {}; +console.info = () => {}; \ No newline at end of file diff --git a/task-source-prd-v2.md b/task-source-prd-v2.md index 7f4489f..ce2d75d 100644 --- a/task-source-prd-v2.md +++ b/task-source-prd-v2.md @@ -785,6 +785,108 @@ This section records decisions made during the development of the Bifrost Task S --- +### DR-6: .bifrost.yaml and credentials.yaml formats from source code + +**Question**: What are the actual file formats for `.bifrost.yaml` and `credentials.yaml`? + +**Decision**: Use formats from Bifrost source code (`cli/config.go`, `cli/credentials.go`). + +**Rationale**: Bifrost CLI is the reference implementation. Task source must match exactly for interoperability. + +**Impact**: Corrected field names and structure in Appendices A and B. + +--- + +### DR-7: Claimant identifier defaults to system username + +**Question**: What is the default claimant if not provided in configuration? + +**Decision**: Use system username from `whoami` bash command (Node.js equivalent: `os.userInfo().username`). + +**Rationale**: Matches Bifrost CLI behavior (`user.Current().Username` in Go). Provides sensible default while allowing override. + +--- + +### DR-8: Credential resolution failures are fatal + +**Question**: What happens when credentials can't be resolved? + +**Decision**: Raise an error to the orchestrator and die. Do not continue without credentials. + +**Rationale**: Continuing without credentials would pollute Bifrost with anonymous claims. Fail fast is better. + +--- + +### DR-9: .bifrost.yaml missing is fatal + +**Question**: What happens when `.bifrost.yaml` doesn't exist or is malformed? + +**Decision**: Raise an error to the orchestrator and die. + +**Rationale**: The task source cannot function without knowing which server and realm to connect to. Fail fast. + +--- + +### DR-10: projectDir parameter is new orchestrator feature + +**Question**: How does the task source receive `projectDir`? + +**Decision**: Gap in orchestrator definition. Add `projectDir` parameter to task source constructor as a new orchestrator framework feature. + +**Rationale**: Task sources need access to working repository for config files. This is orchestrator framework's responsibility to provide. + +--- + +### DR-11: TaskState Store interface for task metadata + +**Question**: What is `Task.metadata` and how is it shared between orchestrator and task source? + +**Decision**: Gap in orchestrator definition. Task source implements `TaskStateStore` interface for storing/retrieving per-task metadata. Orchestrator and task source share task state through this interface. + +**Rationale**: Task metadata (like branch) needs persistence across hook executions. The task source is responsible for the storage backend, orchestrator provides the data. + +--- + +### DR-12: failTask passes error as reason to Bifrost fail command + +**Question**: What happens to the `error` parameter in `failTask`? + +**Decision**: Pass it as the `reason` field in Bifrost's `/api/fail-rune` command. + +**Rationale**: Bifrost's `FailRune` command has a `Reason` field. The error message provides diagnostic value. + +--- + +### DR-13: No claim conflict handling needed + +**Question**: Should the task source handle claim conflicts? + +**Decision**: No. Bifrost server guarantees no conflicts through atomic claim operations. Task source does not need conflict handling. + +**Rationale**: Bifrost's event-sourced design with single-stream claims prevents race conditions at the server level. The task source can trust claim operations succeed or fail with genuine errors. + +--- + +### DR-14: Remove boolean return from completion methods + +**Question**: Should `completeTask` and `failTask` return `Promise`? + +**Decision**: No. Remove boolean return. Methods only throw on failure. Bifrost guarantees idempotency, atomicity, and eventual consistency. + +**Rationale**: Boolean returns create ambiguity about failure modes. Throwing is clearer. Bifrost's design ensures operations either succeed or throw. + +--- + +### DR-15: Configuration validation at construction time + +**Question**: When is configuration validated? + +**Decision**: At task source construction time. Invalid configuration = log error and throw. + +**Rationale**: Fail fast on startup rather than discovering config issues during operation. + +--- + ## Open Questions ### OQ-1: .bifrost.yaml file format specification diff --git a/task-source-prd-v3.md b/task-source-prd-v3.md new file mode 100644 index 0000000..76f35fd --- /dev/null +++ b/task-source-prd-v3.md @@ -0,0 +1,1115 @@ +# Bifrost Task Source PRD + +**Status:** Draft +**Authors:** Eric Siebeneich +**Date:** 2026-05-07 +**Version:** 3.0 + +--- + +## Product Description, Problem, and Goal + +### Product Description + +The **Bifrost Task Source** is a plugin implementation of the Orchestrator Framework's `TaskSource` interface that connects to a **Bifrost** server—the event-sourced rune management service. It enables orchestrator instances to discover, claim, and fulfill **runes** (work items) stored in Bifrost, providing seamless integration between AI agent orchestration and the Bifrost task management system. + +**Key Terms:** + +- **Bifrost**: Event-sourced rune (work item) management service for AI agents +- **Rune**: A work item in Bifrost (issue, task, bug, feature, etc.) +- **Saga**: An epic; a collection of related runes in Bifrost +- **Realm**: A tenant namespace in Bifrost for organizing runes with credentials +- **Task Source**: Orchestrator Framework plugin that discovers, claims, and fulfills tasks from external systems +- **Claimant**: Identifier for the agent instance currently working on a task +- **Rune State**: The lifecycle state of a rune (draft, forged, open, claimed, fulfilled, sealed) +- **PAT**: Personal Access Token used for authentication with Bifrost +- **Orchestrator**: TypeScript-based distributed task execution system that coordinates AI agents +- **taskState**: Free-form object containing all context for a single task execution +- **projectDir**: Git root of the working repository, resolved automatically by the orchestrator and passed to task source constructor + +### Problem + +Sarah has deployed the Orchestrator Framework to automate code maintenance across her monorepo. Her team uses Bifrost for rune management—developers create runes for features, bugs, and refactors. She wants her AI agents to automatically pick up and work on these runes, but she faces three problems: + +1. **No integration**: The orchestrator's default API task source doesn't understand Bifrost's rune lifecycle, state model, or event-sourced architecture +2. **No coordination**: Multiple orchestrator instances might claim the same rune, causing duplicate work and race conditions +3. **No state synchronization**: When an agent completes work, the rune state in Bifrost isn't automatically updated—developers must manually mark runes as fulfilled + +Marcus, Sarah's teammate, has written custom scripts to poll Bifrost's HTTP API and dispatch work to agents. These scripts are fragile—they don't handle reconnection, don't respect Bifrost's claim semantics, and error handling is ad-hoc. When the Bifrost server is temporarily unavailable, the scripts crash and lose track of which runes were being processed. When two script instances run simultaneously, they both claim the same rune, wasting compute resources and creating conflicting code changes. + +Sarah spends time manually deconflicting agent work, restarting crashed scripts, and syncing rune states back to Bifrost. She can't reliably scale her automation because the integration layer is a house of cards. + +### Goal + +With the Bifrost Task Source plugin, Sarah configures her orchestrator instances to use the Bifrost Task Source. The plugin: + +1. Reads server URL and realm from `.bifrost.yaml` in the working repository +2. Reads PAT credentials from `~/.config/bifrost/credentials.yaml` +3. Polls for runes in the `open` state that are ready for work (unblocked) +4. Uses Bifrost's native claim API to ensure exclusive ownership (no duplicate work) +5. Yields all ready runes via async iterator (no filtering) +6. Maps Bifrost runes to orchestrator Tasks with full metadata (tags, dependencies, notes) +7. Stores and retrieves task state via TaskStateStore interface +8. Executes agents via the orchestrator framework +9. Calls fulfill or fail API based on orchestrator's `completeTask()` or `failTask()` calls +10. Handles Bifrost server unavailability with retry on next poll interval +11. Emits structured telemetry for every operation (claim, execution, completion) + +Marcus removes his fragile scripts. The orchestrator instances now coordinate seamlessly through Bifrost's claim semantics. When a rune is completed, its state is automatically updated—developers see real-time progress in the Bifrost UI. Sarah can deploy multiple orchestrator instances knowing they won't duplicate work. The system is reliable, observable, and requires no custom glue code. + +--- + +## User Stories / Use Cases + +### US-1: Configure orchestrator to use Bifrost Task Source + +**As a** platform engineer +**I want** to configure the orchestrator to use the Bifrost Task Source +**So that** orchestrator instances can connect to my Bifrost server using the working repo's Bifrost configuration + +**Acceptance Criteria:** + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_source.type is "bifrost" +And the working repository has a .bifrost.yaml file +And .bifrost.yaml contains realm: "my-project" +And ~/.config/bifrost/credentials.yaml contains a PAT for the server +When the orchestrator loads configuration +Then a BifrostTaskSource is created using the realm from .bifrost.yaml +And the BifrostTaskSource reads credentials from ~/.config/bifrost/credentials.yaml +And the BifrostTaskSource connects to the Bifrost server +``` + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_source.settings.pollInterval is 30 +When the orchestrator loads configuration +Then the BifrostTaskSource polls for ready runes every 30 seconds +``` + +``` +Given a .orchestrator.yaml configuration file +And orchestrate.task_source.settings.claimant is "orchestrator-prod-1" +When the orchestrator loads configuration +Then the BifrostTaskSource uses "orchestrator-prod-1" as the claimant identifier for all rune claims +``` + +``` +Given .orchestrator.yaml does not specify claimant +When the orchestrator loads configuration +Then the BifrostTaskSource uses the system username from os.userInfo().username as the claimant +``` + +``` +Given .orchestrator.yaml contains optional overrides +And orchestrate.task_source.settings.baseUrl is "https://custom.bifrost.com" +And .bifrost.yaml contains a different baseUrl +When the orchestrator loads configuration +Then the BifrostTaskSource uses the override from .orchestrator.yaml +``` + +``` +Given .bifrost.yaml does not exist in projectDir +When the orchestrator attempts to create the BifrostTaskSource +Then an error is thrown indicating .bifrost.yaml is missing +And the orchestrator does not start +``` + +``` +Given ~/.config/bifrost/credentials.yaml does not exist +When the orchestrator attempts to create the BifrostTaskSource +Then an error is thrown indicating credentials are missing +And the orchestrator does not start +``` + +### US-2: Poll for ready runes and claim them + +**As an** orchestrator instance +**I want** to poll Bifrost for runes in the `open` state that are ready for work +**So that** I can claim and execute them + +**Acceptance Criteria:** + +``` +Given the Bifrost Task Source is configured +And one or more runes exist in the configured realm with state "open" +And all rune dependencies are satisfied (no blocking runes) +When the poll interval elapses +Then the Bifrost Task Source queries Bifrost for ready runes +And each ready rune is yielded via the async iterator +``` + +``` +Given a ready rune is yielded from the async iterator +When the orchestrator receives the rune +Then the Bifrost Task Source claims the rune via Bifrost's claim API +And the rune state in Bifrost transitions to "claimed" +And the claimant field is set to the configured claimant identifier +``` + +``` +Given the Bifrost server is unreachable +When the Bifrost Task Source attempts to poll +Then the poll attempt is logged as failed +And the Task Source waits for the configured poll interval before retrying +``` + +### US-3: Map Bifrost runes to orchestrator Tasks + +**As the** orchestrator framework +**I want** Bifrost runes to be mapped to the Task interface with all relevant metadata +**So that** agents can access rune context + +**Acceptance Criteria:** + +``` +Given a claimed Bifrost rune with title "Fix login bug" +And the rune has description "Users cannot authenticate" +And the rune has tags ["bug", "auth", "priority:high"] +And the rune has priority 2 +When the rune is mapped to an orchestrator Task +Then Task.id is the rune ID +And Task.title is "Fix login bug" +And Task.description is "Users cannot authenticate" +And Task.tags is ["bug", "auth", "priority:high"] +And Task.priority is 2 +And Task.status is "IN_PROGRESS" +And Task.claimant is the configured claimant identifier +``` + +``` +Given a Bifrost rune with acceptance criteria +And the rune has dependencies on other runes +And the rune has notes from developers +When the rune is mapped to an orchestrator TaskDetail +Then TaskDetail.acceptanceCriteria contains all AC entries +And TaskDetail.dependencies contains all DependencyRef entries +And TaskDetail.notes contains all NoteEntry entries +``` + +``` +Given a Bifrost rune with branch tracking enabled +And the rune is associated with branch "feature/fix-login" +When the rune is mapped to an orchestrator Task +Then Task.taskState contains { branch: "feature/fix-login" } +And agents can access the branch information via taskState +``` + +### US-4: Complete or fail rune based on orchestrator call + +**As the** orchestrator framework +**I want** to mark a rune as fulfilled or failed based on the orchestrator's completion method +**So that** rune state in Bifrost reflects the actual outcome + +**Acceptance Criteria:** + +``` +Given a rune has been claimed and processed by an agent +And the agent execution completed successfully +When the orchestrator calls completeTask(taskId) +Then the Bifrost Task Source calls Bifrost's fulfill API +And the rune state transitions to "fulfilled" +``` + +``` +Given a rune has been claimed and processed +And the agent execution failed with error "Test timeout after 5 minutes" +When the orchestrator calls failTask(taskId, error) +Then the Bifrost Task Source calls Bifrost's fail API with reason "Test timeout after 5 minutes" +And the rune state transitions to "open" +``` + +``` +Given the Bifrost server is unreachable when completing a task +When the orchestrator calls completeTask(taskId) +Then the method throws an error +And the orchestrator logs the failure +And the orchestrator marks the task as failed in its own records +``` + +``` +Given completeTask is called for a rune that is already fulfilled +When the Bifrost API acknowledges the operation +Then the method returns without error (Bifrost guarantees idempotency) +``` + +### US-5: Handle Bifrost server unavailability + +**As an** orchestrator operator +**I want** the Bifrost Task Source to handle server unavailability gracefully +**So that** temporary outages don't crash the orchestrator + +**Acceptance Criteria:** + +``` +Given the Bifrost Task Source is polling for tasks +And the Bifrost server becomes unreachable +When a poll attempt fails +Then the error is logged with server URL and timestamp +And the Task Source waits for the configured poll interval +And the Task Source retries polling +``` + +``` +Given the Bifrost Task Source async iterator is active +And a network error occurs during iteration +When the error is detected +Then the async iterator does not terminate +And the error is logged +And polling continues on the next interval +``` + +``` +Given the Bifrost server has been unavailable for 5 minutes +And the server becomes available again +When the next poll occurs +Then the Bifrost Task Source successfully connects +And polling resumes normally +And a reconnection log entry is written +``` + +### US-6: Emit telemetry for all operations + +**As a** platform engineer +**I want** detailed telemetry emitted for all Bifrost Task Source operations +**So that** I can monitor integration health and debug issues + +**Acceptance Criteria:** + +``` +Given the Bifrost Task Source successfully polls for ready runes +When the poll completes +Then a log entry is emitted with: poll timestamp, number of ready runes found, realm name +``` + +``` +Given the Bifrost Task Source claims a rune +When the claim API call succeeds +Then a log entry is emitted with: rune ID, claimant, timestamp +``` + +``` +Given the Bifrost Task Source completes or fails a rune +When the fulfill/fail API call succeeds +Then a log entry is emitted with: rune ID, operation, timestamp +``` + +### US-7: Store and retrieve task state + +**As an** orchestrator framework +**I want** the task source to store and retrieve task state +**So that** hooks and agents can share data across executions + +**Acceptance Criteria:** + +``` +Given a hook writes taskState.snapshotTests = { "test.js": "hash123" } +When the hook completes +Then the task source persists the taskState via TaskStateStore +``` + +``` +Given a subsequent hook needs to read taskState +When the hook executes +Then the task source loads the persisted taskState from TaskStateStore +And the hook receives the updated taskState via stdin +``` + +``` +Given the TaskStateStore is unavailable +When a task state operation is attempted +Then an error is thrown +And the orchestrator marks the UoW as failed +``` + +--- + +## Functional Requirements + +### FR-1: Configuration Schema + +The Bifrost Task Source MUST support the following configuration in `.orchestrator.yaml`: + +```yaml +orchestrate: + task_source: + type: "bifrost" + settings: + # Optional overrides for values from .bifrost.yaml + url: string # Override Bifrost server URL + realm: string # Override realm name + claimant: string # Claimant identifier (defaults to system username) + pollInterval: number # Polling interval in milliseconds (default: 10000) + timeout: number # Request timeout in milliseconds (default: 30000) +``` + +The task source MUST read from: +- `.bifrost.yaml` in `projectDir` for default `url` and `realm` +- `~/.config/bifrost/credentials.yaml` for PAT authentication + +Configuration priority: `.orchestrator.yaml` settings override `.bifrost.yaml` values. + +**Configuration validation**: +- All validation occurs at task source construction time +- Invalid configuration throws an error +- The task source is not created if validation fails + +**Missing configuration behavior**: +- If `.bifrost.yaml` doesn't exist in `projectDir`: throw error with clear message +- If `.bifrost.yaml` exists but is missing required fields: throw error with clear message +- If `credentials.yaml` doesn't exist: throw error with clear message +- If no matching credential entry is found: throw error with clear message +- If files are malformed (invalid YAML): throw error with clear message + +### FR-2: BifrostTaskSource Implementation + +The Bifrost Task Source MUST implement the `TaskSource` and `TaskStateStore` interfaces: + +```typescript +export type TaskSource = { + watchTasks: () => AsyncGenerator + getTaskDetail: (taskId: string) => Promise + completeTask: (taskId: string) => Promise + failTask: (taskId: string, error: string) => Promise +} + +export type TaskStateStore = { + loadTaskState: (taskId: string) => Promise | null> + saveTaskState: (taskId: string, taskState: Record) => Promise + deleteTaskState: (taskId: string) => Promise + initializeTaskState: (taskId: string, initialState: Record) => Promise +} +``` + +The BifrostTaskSource constructor MUST accept: + +```typescript +constructor(config: BifrostTaskSourceConfig, projectDir: string) +``` + +The `projectDir` parameter is the git root of the working repository, provided by the orchestrator framework at task source instantiation. This is an orchestrator framework feature that passes the resolved project directory to all task sources. + +### FR-3: Rune to Task Mapping + +The Bifrost Task Source MUST map Bifrost runes to orchestrator Tasks with the following field mappings: + +| Bifrost Field | Task Field | Notes | +|---------------|------------|-------| +| `id` | `id` | Direct mapping | +| `title` | `title` | Direct mapping | +| `description` | `description` | Direct mapping | +| `state` | `status` | Mapped: "claimed" → "IN_PROGRESS" | +| `tags` | `tags` | Direct mapping, no modification | +| `claimant` | `claimant` | Direct mapping | +| `created_at` | `createdAt` | Date parsing | +| `updated_at` | `updatedAt` | Date parsing | +| `priority` | `priority` | Direct mapping | +| `branch` | Stored in taskState as `taskState.branch` | Passed via TaskStateStore | + +The task source MUST NOT modify tags. The orchestrator framework handles worker tag routing. + +### FR-4: Ready Rune Query + +The Bifrost Task Source MUST query for runes that meet ALL of the following criteria: + +- `state == "open"` (rune is ready to be claimed) +- `blocked == "false"` (all dependency runes in the `blocks` relationship are in state `fulfilled` or `sealed`) +- No circular dependencies exist (enforced by Bifrost) +- `is_saga == "false"` (exclude sagas, only return individual runes) + +The query MUST use Bifrost's HTTP API: +``` +GET /api/runes?status=open&blocked=false&is_saga=false +Headers: Authorization: Bearer {pat}, X-Bifrost-Realm: {realm} +``` + +The task source MUST NOT filter runes by any criteria including worker tags. All ready runes are yielded to the orchestrator. + +### FR-5: Claim Semantics + +The Bifrost Task Source MUST use Bifrost's HTTP API to claim runes: + +``` +POST /api/claim-rune +Headers: Authorization: Bearer {pat}, X-Bifrost-Realm: {realm} +Body: { "id": string, "claimant": string } +Response: 204 No Content on success +``` + +The claim operation is atomic. Bifrost's event-sourced design prevents race conditions at the server level. + +If a claim fails with a domain error (rune not in correct state, etc.), the rune is NOT yielded to the orchestrator. The task source logs the failure and continues polling. + +### FR-6: Completion and Failure Handling + +The Bifrost Task Source MUST provide methods for the orchestrator to signal completion or failure: + +- `completeTask(taskId)`: Calls Bifrost's fulfill API. Transitions rune to `fulfilled` state. +- `failTask(taskId, error)`: Calls Bifrost's fail API with the error message as the reason. Transitions rune back to `open` state. + +Both methods return `Promise` and throw on error. + +**Fulfill API**: +``` +POST /api/fulfill-rune +Headers: Authorization: Bearer {pat}, X-Bifrost-Realm: {realm} +Body: { "id": string } +Response: 204 No Content +``` + +**Fail API**: +``` +POST /api/fail-rune +Headers: Authorization: Bearer {pat}, X-Bifrost-Realm: {realm} +Body: { "id": string, "reason": string } +Response: 204 No Content +``` + +The task source MUST NOT classify errors. That is the orchestrator's responsibility. + +**Error handling**: +- Network errors: throw error +- HTTP 4xx/5xx: throw error with message +- Bifrost guarantees idempotency: calling fulfill on an already-fulfilled rune succeeds without error + +### FR-7: Authentication and Credentials + +The Bifrost Task Source MUST authenticate using Bearer token authentication: + +- Read credentials from `~/.config/bifrost/credentials.yaml` (or `$XDG_CONFIG_HOME/bifrost/credentials.yaml`) +- Include the PAT in the `Authorization` header as `Bearer {pat}` +- Include the realm name in the `X-Bifrost-Realm` header +- Handle 401/403 responses by logging authentication failures and continuing polling + +The credentials file format matches the Bifrost CLI format: + +```yaml +# ~/.config/bifrost/credentials.yaml +credentials: + "https://bifrost.example.com": + token: "the-pat-token" +``` + +Credentials are resolved by matching the normalized `url` from `.bifrost.yaml` (or override) to the keys in the `credentials` map. URL normalization removes trailing slashes. + +**Credential resolution failure behavior**: +- If `credentials.yaml` doesn't exist: throw error at construction +- If no matching credential entry is found: throw error at construction +- If the file is malformed (invalid YAML): throw error at construction +- The error MUST clearly indicate which file or credential lookup failed + +### FR-8: Reconnection Behavior + +If the Bifrost server becomes unreachable, the Bifrost Task Source MUST: + +- Log the connection error with timestamp and server URL +- Continue polling at the configured interval (no exponential backoff for v1) +- Successfully reconnect when the server becomes available +- Log successful reconnection + +The `watchTasks()` async iterator MUST NOT terminate on network errors. + +### FR-9: Branch and TaskState Handling + +If a Bifrost rune has an associated Git branch, the Bifrost Task Source MUST: + +- Store the branch name in taskState via `saveTaskState(taskId, { branch: branchName })` +- Subsequent loads of taskState will include the branch field +- Agents can access this via taskState for checkout operations + +### FR-10: Dependency Mapping + +Bifrost dependency relationships MUST be mapped to orchestrator TaskDetail: + +| Bifrost Relationship | TaskDetail.dependencies[] | +|----------------------|---------------------------| +| `blocks` / `blocked_by` | `{ taskId, type: "blocks" }` | +| `relates_to` | `{ taskId, type: "relates_to" }` | +| `duplicates` / `duplicated_by` | `{ taskId, type: "duplicates" }` | +| `supersedes` / `superseded_by` | `{ taskId, type: "supersedes" }` | +| `replies_to` / `replied_to_by` | `{ taskId, type: "replies_to" }` | + +### FR-11: Notes and Retro Mapping + +Bifrost notes and retro entries MUST be mapped to TaskDetail: + +- Notes → `TaskDetail.notes[]` with `{ id, content, createdAt }` +- Retro entries → `TaskDetail.retro[]` with `{ id, content, createdAt }` + +### FR-12: TaskStateStore Implementation + +The Bifrost Task Source MUST implement the `TaskStateStore` interface using Bifrost's rune state API: + +**Load**: +``` +GET /api/rune?id={taskId} +Response: 200 OK with rune detail including state field +``` + +**Save**: +``` +POST /api/update-rune-state +Body: { "id": string, "state": Record } +``` + +**Delete**: +``` +POST /api/clear-rune-state +Body: { "id": string } +``` + +**Initialize**: +``` +POST /api/update-rune-state +Body: { "id": string, "state": Record } +``` + +The taskState is stored as a free-form JSON object in the rune's `state` field in Bifrost. + +### FR-13: Claimant Default + +If `claimant` is not provided in the configuration, the task source MUST default to the system username using Node.js `os.userInfo().username`. + +### FR-14: Get Rune Detail + +The task source MUST implement `getTaskDetail` using: + +``` +GET /api/rune?id={runeId} +Headers: Authorization: Bearer {pat}, X-Bifrost-Realm: {realm} +Response: 200 OK with full rune detail, 404 Not Found +``` + +--- + +## Non-Functional Requirements + +### NFR-1: Performance + +- Polling interval default: 10 seconds (configurable) +- API request timeout default: 30 seconds (configurable) +- Rune to Task mapping MUST complete in under 10ms per rune +- Bifrost API calls MUST complete within the configured timeout + +### NFR-2: Reliability + +- The Bifrost Task Source MUST handle Bifrost server unavailability without crashing +- The Bifrost Task Source MUST log all failures without terminating the async iterator +- Bifrost's atomic claim operations prevent duplicate work +- Network errors MUST be logged and retried on next poll + +### NFR-3: Monitoring and Observability + +- All Bifrost API calls MUST be logged with: endpoint, status code, duration +- All claim operations MUST be logged with: rune ID, claimant, success/failure +- All fulfill/fail operations MUST be logged with: rune ID, result +- Poll operations MUST be logged with: realm, ready rune count +- Reconnection events MUST be logged + +### NFR-4: Concurrency + +- Multiple orchestrator instances MUST be able to poll simultaneously +- Bifrost's atomic claim operations ensure only one instance claims each rune +- No additional locking is required in the Bifrost Task Source + +### NFR-5: Error Handling + +- Authentication failures (401/403) MUST be logged and polling must continue +- Invalid configuration MUST throw at construction +- Realm not found MUST be logged and polling must continue +- Malformed API responses MUST be logged and the rune must be skipped + +### NFR-6: Security + +- PATs MUST be transmitted via HTTPS only +- PATs MUST NOT be logged under any circumstances +- The Bifrost Task Source MUST validate SSL certificates +- Credential file permissions SHOULD be restricted (user-readable only) + +### NFR-7: Idempotency + +- The task source MUST be idempotent for all operations +- Bifrost guarantees idempotency for claim, fulfill, and fail operations +- Repeated calls to `completeTask` on an already-fulfilled rune succeed without error + +--- + +## Data & Storage + +### Commands + +**PollReadyRunes** +- `realm: string`, `claimant: string` +- Occurs when: Poll interval elapses + +**ClaimRune** +- `runeId: string`, `claimant: string` +- Occurs when: Ready rune is identified + +**MapRuneToTask** +- `rune: BifrostRune`, `projectDir: string` +- Occurs when: Claimed rune is yielded to orchestrator + +**FulfillRune** +- `runeId: string` +- Occurs when: Orchestrator calls completeTask() + +**FailRune** +- `runeId: string`, `reason: string` +- Occurs when: Orchestrator calls failTask() + +**LoadTaskState** +- `runeId: string` +- Occurs when: Hook or agent needs to read taskState + +**SaveTaskState** +- `runeId: string`, `taskState: Record` +- Occurs when: Hook modifies taskState + +**DeleteTaskState** +- `runeId: string` +- Occurs when: Task is completed and state should be cleaned up + +**InitializeTaskState** +- `runeId: string`, `initialState: Record` +- Occurs when: Task is claimed for the first time + +### Events + +**ReadyRunesPolled** +- `realm: string`, `readyRuneCount: number`, `pollTimestamp: ISO8601` + +**RuneClaimed** +- `runeId: string`, `claimant: string`, `claimedAt: ISO8601` + +**RuneFulfilled** +- `runeId: string`, `claimant: string`, `fulfilledAt: ISO8601` + +**RuneFailed** +- `runeId: string`, `claimant: string`, `reason: string`, `failedAt: ISO8601` + +**BifrostApiCallFailed** +- `endpoint: string`, `statusCode: number`, `error: string`, `timestamp: ISO8601` + +**BifrostServerReconnecting** +- `baseUrl: string`, `lastError: string`, `reattemptDelay: number`, `reattemptAt: ISO8601` + +**BifrostServerReconnected** +- `baseUrl: string`, `downtimeDuration: number`, `reconnectedAt: ISO8601` + +**CredentialResolutionFailed** +- `server: string`, `reason: string`, `timestamp: ISO8601` + +**TaskStateLoaded** +- `runeId: string`, `keys: string[]`, `loadedAt: ISO8601` + +**TaskStateSaved** +- `runeId: string`, `keys: string[]`, `savedAt: ISO8601` + +### Aggregates + +**BifrostRune** +```typescript +type BifrostRune = { + id: string + title: string + description: string | null + state: RuneState + tags: string[] + claimant: string | null + created_at: Date + updated_at: Date + priority: number + branch: string | null + realm_id: string + parent_id: string | null +} + +type RuneState = "draft" | "forged" | "open" | "claimed" | "fulfilled" | "sealed" +``` + +**BifrostRuneDetail** +```typescript +type BifrostRuneDetail = BifrostRune & { + dependencies: BifrostDependency[] + notes: BifrostNote[] + acceptance_criteria: BifrostAC[] + retro: BifrostRetroEntry[] + state: Record // Free-form taskState +} + +type BifrostDependency = { + target_id: string + relationship: "blocked_by" | "relates_to" | "duplicated_by" | "superseded_by" | "replied_to_by" +} +``` + +**BifrostTaskSourceConfig** +```typescript +type BifrostTaskSourceConfig = { + url?: string // Optional override + realm?: string // Optional override + claimant?: string // Optional, defaults to system username + pollInterval: number // Default: 10000 + timeout: number // Default: 30000 +} +``` + +**BifrostRepoConfig** (from `.bifrost.yaml`) +```typescript +type BifrostRepoConfig = { + url: string // Bifrost server URL + realm: string // Realm name (required) + orchestrate?: OrchestrateConfig + api_key?: string // Deprecated, use credentials.yaml +} +``` + +**BifrostCredentialsFile** (from `~/.config/bifrost/credentials.yaml`) +```typescript +type BifrostCredentialsFile = { + credentials: Record +} +``` + +### Query Projections + +**ReadyRunesQuery** +- Question: Which runes in this realm are ready to be claimed? +- API Call: `GET /api/runes?status=open&blocked=false&is_saga=false` +- Used by: Poll operation + +**RuneDetailQuery** +- Question: What is the full detail for a specific rune? +- API Call: `GET /api/rune?id={runeId}` +- Used by: getTaskDetail, agent context + +**CredentialLookupQuery** +- Question: What PAT should be used for this server URL? +- Projection: Single credential entry matching normalized URL +- Used by: Authentication + +### Data Retention + +- Task state is persisted in Bifrost rune's `state` field +- Bifrost Task Source does NOT persist data separately +- Event logs for telemetry follow orchestrator framework retention policy (90 days) + +--- + +## Out of Scope + +- Real-time push notifications (polling only, no webhooks in v1) +- Custom claim semantics (uses Bifrost's built-in claim API) +- Multi-realm polling (one task source instance = one realm) +- Rune creation from the orchestrator (runes are created externally) +- Saga-level orchestration (individual runes only, sagas excluded via `is_saga=false`) +- Automatic retry on failure (orchestrator decides, task source just follows orders) +- Bifrost server administration (no realm/account management from task source) +- Migration tools (no import from other task systems) +- Advanced scheduling (FIFO polling based on Bifrost's ready query order) +- Custom priority handling (Bifrost priority is informational only) +- Branch creation (branch must already exist or be created externally) +- Dependency resolution visualization (no graph generation) +- Worker tag filtering (orchestrator's responsibility, not task source) +- Worker tag inference (orchestrator's responsibility) +- Error classification (orchestrator's responsibility) +- Claim conflict handling (Bifrost guarantees no conflicts at server level) + +--- + +## Dependencies and Assumptions + +### Dependencies + +| Dependency | Purpose | Version | +|---|---|---|---| +| Bifrost Server | Rune management backend | Current | +| Orchestrator Framework | Core orchestration system | 1.0 | +| TypeScript Runtime | Task source execution | ≥ 24 | +| Node.js os module | System username discovery | Built-in | +| Node.js fs module | Reading .bifrost.yaml and credentials.yaml | Built-in | +| Node.js fetch API | HTTP requests to Bifrost | Built-in | + +### Assumptions + +1. Bifrost server is accessible via HTTPS from the orchestrator runtime environment +2. A valid PAT with at least `member` role in the target realm is available in credentials file +3. Bifrost server's claim API is atomic and prevents duplicate claims +4. The realm specified in .bifrost.yaml exists +5. Network connectivity allows periodic polling at the configured interval +6. Bifrost server's HTTP API is available and returns ready runes +7. Bifrost server supports fulfill and fail API endpoints +8. Time synchronization between orchestrator and Bifrost server is adequate (clock skew < 1 minute) +9. The orchestrator framework provides the TaskSource and TaskStateStore interfaces +10. The orchestrator automatically resolves `projectDir` from git root and passes it to task source constructor +11. `.bifrost.yaml` exists in the working repository (fatal if missing) +12. `~/.config/bifrost/credentials.yaml` exists and contains valid credentials (fatal if missing) +13. The orchestrator handles worker tag routing, not the task source +14. Bifrost guarantees idempotency for all operations +15. Bifrost guarantees atomicity and prevents claim conflicts at the server level + +### External System Assumptions + +- **Bifrost Server**: Provides HTTP API for rune CRUD operations, claim/fail, fulfill, and ready query. Supports PAT authentication and realm-scoped queries. Guarantees idempotency, atomicity, and eventual consistency. +- **Orchestrator Framework**: Provides TaskSource and TaskStateStore interfaces, defines Task and TaskDetail types, handles agent dispatch, manages hook execution, automatically resolves projectDir and passes to task source constructor. + +--- + +## Decision Records + +This section records decisions made during the development of the Bifrost Task Source. Each decision includes the question, the answer, and the rationale. + +### DR-1: No rune filtering in task source + +**Question**: Should the task source filter runes by worker tag? + +**Decision**: No. The task source should not do any rune filtering. If it's ready, it goes in the async generator. + +**Rationale**: Filtering is the orchestrator's responsibility. The task source's job is to provide ready runes from the backend system. The orchestrator framework has agent routing mechanisms that determine which worker should handle which task. Adding filtering to the task source creates unnecessary complexity and coupling. + +### DR-2: Task source not responsible for error classification + +**Question**: Is the task source responsible for determining whether an error is recoverable vs fatal? + +**Decision**: No. The task source is not responsible for this. The orchestrator either fulfills or fails it via `completeTask()` or `failTask()`. + +**Rationale**: The task source's job is to provide the interface for completion and failure. Determining the nature of the error and the appropriate response is the orchestrator's concern. The task source simply calls the appropriate Bifrost API based on which orchestrator method is invoked. + +### DR-3: Polling interval default + +**Question**: What should the default polling interval be? + +**Decision**: 10 seconds. + +**Rationale**: Matches the orchestrator framework's default. Provides good balance between responsiveness and resource usage. + +### DR-4: No API version compatibility handling + +**Question**: How should the task source handle Bifrost API version changes? + +**Decision**: Don't worry about versioning. Assume it's all the correct versions. + +**Rationale**: The task source and Bifrost server are developed together. Version compatibility is managed at the deployment level, not in the task source code. + +### DR-5: Credentials from .bifrost.yaml and credentials.yaml + +**Question**: Where should the task source read credentials from? + +**Decision**: Read the server URL and realm from the `.bifrost.yaml` of the working repository. Read the credentials (PAT) for that repo from `~/.config/bifrost/credentials.yaml`. + +**Rationale**: This follows the Bifrost CLI's credential management pattern. The `.bifrost.yaml` in the working repo specifies which server and realm to use. The credentials file maps those to PATs. This avoids hardcoding PATs in orchestrator config and supports multiple realms/credentials. + +### DR-6: .bifrost.yaml and credentials.yaml formats from source code + +**Question**: What are the actual file formats for `.bifrost.yaml` and `credentials.yaml`? + +**Decision**: Use formats from Bifrost source code (`cli/config.go`, `cli/credentials.go`). + +**Rationale**: Bifrost CLI is the reference implementation. Task source must match exactly for interoperability. See Appendix A and B for exact formats. + +### DR-7: Claimant identifier defaults to system username + +**Question**: What is the default claimant if not provided in configuration? + +**Decision**: Use system username from `os.userInfo().username` (Node.js equivalent of Go's `user.Current().Username`). + +**Rationale**: Matches Bifrost CLI behavior. Provides sensible default while allowing override. + +### DR-8: Credential resolution failures are fatal + +**Question**: What happens when credentials can't be resolved? + +**Decision**: Raise an error to the orchestrator and die. Do not continue without credentials. + +**Rationale**: Continuing without credentials would pollute Bifrost with anonymous claims. Fail fast is better. + +### DR-9: .bifrost.yaml missing is fatal + +**Question**: What happens when `.bifrost.yaml` doesn't exist or is malformed? + +**Decision**: Raise an error to the orchestrator and die. + +**Rationale**: The task source cannot function without knowing which server and realm to connect to. Fail fast. + +### DR-10: projectDir parameter is orchestrator framework feature + +**Question**: How does the task source receive `projectDir`? + +**Decision**: The orchestrator framework passes `projectDir` to the task source constructor. This is a new orchestrator framework feature. + +**Rationale**: Task sources need access to working repository for config files. The orchestrator already resolves projectDir for hooks; it should also pass it to task sources. + +### DR-11: TaskStateStore interface for task metadata + +**Question**: What is `Task.metadata` and how is it shared? + +**Decision**: Task source implements `TaskStateStore` interface for storing/retrieving per-task state. The orchestrator and task source share task state through this interface. Data is stored in Bifrost's rune `state` field. + +**Rationale**: Task metadata needs persistence across hook executions. Making the task source responsible for storage (via Bifrost) keeps the interface clean and data co-located with the task. + +### DR-12: failTask passes error as reason to Bifrost fail command + +**Question**: What happens to the `error` parameter in `failTask`? + +**Decision**: Pass it as the `reason` field in Bifrost's `/api/fail-rune` command. + +**Rationale**: Bifrost's `FailRune` command has a `Reason` field. The error message provides diagnostic value for developers. + +### DR-13: No claim conflict handling needed + +**Question**: Should the task source handle claim conflicts? + +**Decision**: No. Bifrost server guarantees no conflicts through atomic claim operations. Task source does not need conflict handling. + +**Rationale**: Bifrost's event-sourced design with single-stream claims prevents race conditions at the server level. The task source can trust claim operations succeed or fail with genuine errors. + +### DR-14: Remove boolean return from completion methods + +**Question**: Should `completeTask` and `failTask` return `Promise`? + +**Decision**: No. Return `Promise`. Methods only throw on failure. Bifrost guarantees idempotency, atomicity, and eventual consistency. + +**Rationale**: Boolean returns create ambiguity about failure modes. Throwing is clearer. Bifrost's design ensures operations either succeed or throw. + +### DR-15: Configuration validation at construction time + +**Question**: When is configuration validated? + +**Decision**: At task source construction time. Invalid configuration = log error and throw. + +**Rationale**: Fail fast on startup rather than discovering config issues during operation. + +--- + +## Appendices + +### Appendix A: .bifrost.yaml Schema + +The `.bifrost.yaml` file in the working repository specifies the Bifrost server and realm for that repository. + +```yaml +# .bifrost.yaml +url: "https://bifrost.example.com" # Bifrost server URL +realm: "my-project" # Realm name (required) +orchestrate: # Optional orchestrator config + dispatcher: "echo" + claimant: "" + poll_interval: "10s" + concurrency: 1 +api_key: "deprecated-token" # DEPRECATED: Use credentials.yaml instead +``` + +**Notes**: +- Field is `url`, not `baseUrl` +- `realm` is required +- `api_key` is deprecated; use `credentials.yaml` +- Task source ignores `orchestrate` section (that's for Bifrost CLI's orchestrate command) + +### Appendix B: ~/.config/bifrost/credentials.yaml Schema + +The credentials file stores PATs mapped by server URL. + +```yaml +# ~/.config/bifrost/credentials.yaml (or $XDG_CONFIG_HOME/bifrost/credentials.yaml) +credentials: + "https://bifrost.example.com": + token: "the-pat-token" + "https://other-bifrost.example.com": + token: "another-token" +``` + +**Notes**: +- Top-level key is `credentials` +- Server URLs are map keys (not values) +- URLs are normalized (trailing slashes removed) for matching +- Token values are the actual PAT strings + +### Appendix C: Bifrost HTTP API Endpoints + +The task source uses these Bifrost HTTP API endpoints: + +**List Ready Runes** +``` +GET /api/runes?status=open&blocked=false&is_saga=false +Headers: + Authorization: Bearer {pat} + X-Bifrost-Realm: {realm} +Response: 200 OK with array of rune objects +``` + +**Claim Rune** +``` +POST /api/claim-rune +Headers: + Authorization: Bearer {pat} + X-Bifrost-Realm: {realm} +Body: { "id": string, "claimant": string } +Response: 204 No Content on success, 4xx on error +``` + +**Get Rune Detail** +``` +GET /api/rune?id={runeId} +Headers: + Authorization: Bearer {pat} + X-Bifrost-Realm: {realm} +Response: 200 OK with rune detail object, 404 Not Found +``` + +**Fulfill Rune** +``` +POST /api/fulfill-rune +Headers: + Authorization: Bearer {pat} + X-Bifrost-Realm: {realm} +Body: { "id": string } +Response: 204 No Content +``` + +**Fail Rune** +``` +POST /api/fail-rune +Headers: + Authorization: Bearer {pat} + X-Bifrost-Realm: {realm} +Body: { "id": string, "reason": string } +Response: 204 No Content +``` + +**Update Rune State (TaskStateStore)** +``` +POST /api/update-rune-state +Headers: + Authorization: Bearer {pat} + X-Bifrost-Realm: {realm} +Body: { "id": string, "state": Record } +Response: 204 No Content +``` + +**Clear Rune State** +``` +POST /api/clear-rune-state +Headers: + Authorization: Bearer {pat} + X-Bifrost-Realm: {realm} +Body: { "id": string } +Response: 204 No Content +``` + +### Appendix D: HTTP Status Codes + +| Status | Meaning | Task Source Behavior | +|--------|---------|---------------------| +| 204 No Content | Success | Proceed | +| 401 Unauthorized | Invalid PAT | Log error, continue polling | +| 403 Forbidden | Insufficient permissions | Log error, continue polling | +| 404 Not Found | Rune not found | Return null from getTaskDetail | +| 409 Conflict | Domain constraint violation | Log error, skip rune | +| 422 Unprocessable Entity | Validation error | Log error, skip rune | +| 500+ Server Error | Bifrost server error | Log error, retry on next poll | + +--- From 5a2de1bfdc851952bc7d4f759a2da24005157b65 Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Thu, 7 May 2026 23:08:14 -0500 Subject: [PATCH 07/33] tests are tsc'd --- orchestrator/last-statusline | 1 - orchestrator/package.json | 1 + .../packages/core/src/orchestrator.spec.ts | 6 +- .../packages/engine/src/interface.spec.ts | 18 ++- .../packages/engine/src/test-engine.spec.ts | 19 ++- .../packages/engine/src/types.spec.ts | 11 +- .../task-source/src/interface.spec.ts | 121 ++++++------------ .../packages/task-source/src/types.spec.ts | 46 +++---- orchestrator/tsconfig.test.json | 13 ++ 9 files changed, 109 insertions(+), 127 deletions(-) delete mode 100644 orchestrator/last-statusline create mode 100644 orchestrator/tsconfig.test.json diff --git a/orchestrator/last-statusline b/orchestrator/last-statusline deleted file mode 100644 index 366f9bf..0000000 --- a/orchestrator/last-statusline +++ /dev/null @@ -1 +0,0 @@ -{"code":200,"msg":"Operation successful","data":{"limits":[{"type":"TIME_LIMIT","unit":5,"number":1,"usage":100,"currentValue":0,"remaining":100,"percentage":0,"nextResetTime":1780495603982,"usageDetails":[{"modelCode":"search-prime","usage":0},{"modelCode":"web-reader","usage":0},{"modelCode":"zread","usage":0}]},{"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":23,"nextResetTime":1778205257153}],"level":"lite"},"success":true} diff --git a/orchestrator/package.json b/orchestrator/package.json index 1ccf9bb..9294e88 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -9,6 +9,7 @@ ], "scripts": { "test": "vitest run", + "test:typecheck": "tsc --project tsconfig.test.json --noEmit", "build": "npm run build -ws", "dev": "vitest --watch" }, diff --git a/orchestrator/packages/core/src/orchestrator.spec.ts b/orchestrator/packages/core/src/orchestrator.spec.ts index fdec718..804f6af 100644 --- a/orchestrator/packages/core/src/orchestrator.spec.ts +++ b/orchestrator/packages/core/src/orchestrator.spec.ts @@ -1,7 +1,9 @@ import { describe, it, expect, vi } from 'vitest' import { orchestrate } from './orchestrator.js' -import type { AgentDefinition, HookExecutionContext } from './types.js' -import type { Task, TaskSource, Engine, EngineResult } from '@orchestrator/task-source' +import type { AgentDefinition } from './types.js' +import type { HookExecutionContext } from './hook-executor.js' +import type { Task, TaskSource } from '@orchestrator/task-source' +import type { Engine, EngineResult } from '@orchestrator/engine' describe('Orchestrator', () => { describe('task execution lifecycle', () => { diff --git a/orchestrator/packages/engine/src/interface.spec.ts b/orchestrator/packages/engine/src/interface.spec.ts index 3ea8ee9..704f68b 100644 --- a/orchestrator/packages/engine/src/interface.spec.ts +++ b/orchestrator/packages/engine/src/interface.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { Engine } from './interface.js' import { EngineContext, EngineResult } from './types.js' @@ -29,6 +29,9 @@ describe('Engine Interface', () => { taskId: 'task-1', workingDir: '/test', agentName: 'test-agent', + taskState: {}, + metadata: {}, + setState: vi.fn().mockResolvedValue(undefined), verbose: false } @@ -69,7 +72,7 @@ describe('Engine Interface', () => { it('should allow engine without sendFollowUp', async () => { class MockEngine implements Engine { - async execute(): Promise { + async execute(_context: EngineContext): Promise { return { success: true, skipFulfill: false, @@ -80,7 +83,16 @@ describe('Engine Interface', () => { } const engine: Engine = new MockEngine() - const result = await engine.execute() + const context: EngineContext = { + taskId: 'task-1', + workingDir: '/test', + agentName: 'test-agent', + taskState: {}, + metadata: {}, + setState: vi.fn().mockResolvedValue(undefined), + verbose: false + } + const result = await engine.execute(context) expect(result.success).toBe(true) // sendFollowUp is optional, so engine doesn't need it diff --git a/orchestrator/packages/engine/src/test-engine.spec.ts b/orchestrator/packages/engine/src/test-engine.spec.ts index 30624ce..979159f 100644 --- a/orchestrator/packages/engine/src/test-engine.spec.ts +++ b/orchestrator/packages/engine/src/test-engine.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { TestEngine } from './test-engine.js' import type { EngineContext } from './types.js' @@ -11,6 +11,9 @@ describe('Test Engine', () => { taskId: 'task-1', workingDir: '/test/project', agentName: 'test-agent', + taskState: {}, + metadata: {}, + setState: vi.fn().mockResolvedValue(undefined), verbose: false } @@ -29,6 +32,9 @@ describe('Test Engine', () => { taskId: 'task-1', workingDir: '/test', agentName: 'agent', + taskState: {}, + metadata: {}, + setState: vi.fn().mockResolvedValue(undefined), verbose: false } @@ -68,6 +74,9 @@ describe('Test Engine', () => { taskId: 'task-1', workingDir: '/test', agentName: 'agent', + taskState: {}, + metadata: {}, + setState: vi.fn().mockResolvedValue(undefined), verbose: false } @@ -88,6 +97,9 @@ describe('Test Engine', () => { taskId: 'task-1', workingDir: '/test', agentName: 'agent', + taskState: {}, + metadata: {}, + setState: vi.fn().mockResolvedValue(undefined), verbose: false } @@ -106,6 +118,9 @@ describe('Test Engine', () => { taskId: 'task-1', workingDir: '/test', agentName: 'agent', + taskState: {}, + metadata: {}, + setState: vi.fn().mockResolvedValue(undefined), verbose: false } @@ -113,7 +128,7 @@ describe('Test Engine', () => { await engine.execute(context) const duration = Date.now() - start - expect(duration).toBeGreaterThanOrEqual(95) // Allow some timing variance + expect(duration).toBeGreaterThanOrEqual(95) }) }) }) diff --git a/orchestrator/packages/engine/src/types.spec.ts b/orchestrator/packages/engine/src/types.spec.ts index 4ee8bde..3560720 100644 --- a/orchestrator/packages/engine/src/types.spec.ts +++ b/orchestrator/packages/engine/src/types.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { EngineResult, ExecutionStats, EngineContext } from './types.js' describe('Engine Types', () => { @@ -64,18 +64,23 @@ describe('Engine Types', () => { }) describe('EngineContext', () => { - it('should contain taskId, workingDir, agentName, and verbose', () => { - // FR-2: EngineContext MUST contain + it('should contain taskId, workingDir, agentName, taskState, metadata, setState, and verbose', () => { const context: EngineContext = { taskId: 'task-123', workingDir: '/home/user/project', agentName: 'reviewer', + taskState: { step: 1 }, + metadata: { priority: 'high' }, + setState: vi.fn().mockResolvedValue(undefined), verbose: true } expect(context.taskId).toBe('task-123') expect(context.workingDir).toBe('/home/user/project') expect(context.agentName).toBe('reviewer') + expect(context.taskState).toEqual({ step: 1 }) + expect(context.metadata).toEqual({ priority: 'high' }) + expect(context.setState).toBeDefined() expect(context.verbose).toBe(true) }) }) diff --git a/orchestrator/packages/task-source/src/interface.spec.ts b/orchestrator/packages/task-source/src/interface.spec.ts index f4a0fc8..c49f437 100644 --- a/orchestrator/packages/task-source/src/interface.spec.ts +++ b/orchestrator/packages/task-source/src/interface.spec.ts @@ -1,36 +1,27 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { TaskSource } from './interface.js' -import { TaskStatus } from './types.js' +import type { Task } from './types.js' describe('TaskSource Interface', () => { describe('FR-1: Task Source Interface', () => { it('should require watchTasks method returning AsyncIterator', async () => { - // US-2: Task Source emits available tasks via async iterator class MockTaskSource implements TaskSource { - async *watchTasks(): AsyncGenerator { + async *watchTasks(): AsyncGenerator { yield { id: 'task-1', - title: 'Test', - description: null, - status: TaskStatus.OPEN, - tags: ['worker:test'], - claimant: null, - createdAt: new Date(), - updatedAt: new Date(), - priority: 1 + agentId: 'agent-1', + taskState: { step: 1 }, + metadata: { priority: 'high' } } } - async getTaskDetail(_taskId: string) { - throw new Error('Not implemented') + async completeTask(_taskId: string): Promise { } - async completeTask(_taskId: string) { - return true + async failTask(_taskId: string, _error: string): Promise { } - async failTask(_taskId: string, _error: string) { - return true + async setState(_taskId: string, _taskState: Record): Promise { } } @@ -39,114 +30,74 @@ describe('TaskSource Interface', () => { for await (const task of tasks) { expect(task.id).toBe('task-1') - expect(task.status).toBe(TaskStatus.OPEN) + expect(task.agentId).toBe('agent-1') + expect(task.taskState).toEqual({ step: 1 }) + expect(task.metadata).toEqual({ priority: 'high' }) break } }) - it('should require getTaskDetail method', async () => { + it('should require completeTask method', async () => { const source: TaskSource = { async *watchTasks() { yield { id: 'task-1', - title: 'Test', - description: null, - status: TaskStatus.OPEN, - tags: [], - claimant: null, - createdAt: new Date(), - updatedAt: new Date(), - priority: 1 + agentId: 'agent-1', + taskState: {}, + metadata: {} } }, - async getTaskDetail(taskId: string) { - return { - id: taskId, - title: 'Detail Test', - description: 'Description', - status: TaskStatus.OPEN, - tags: [], - claimant: null, - createdAt: new Date(), - updatedAt: new Date(), - priority: 1, - dependencies: [], - notes: [], - acceptanceCriteria: [], - retro: [] - } + async completeTask(_taskId: string): Promise { }, - async completeTask(_taskId: string) { - return true + async failTask(_taskId: string, _error: string): Promise { }, - async failTask(_taskId: string, _error: string) { - return true + async setState(_taskId: string, _taskState: Record): Promise { } } - const detail = await source.getTaskDetail('task-1') - expect(detail?.id).toBe('task-1') - expect(detail?.dependencies).toEqual([]) + await source.completeTask('task-1') }) - it('should require completeTask method', async () => { + it('should require failTask method', async () => { const source: TaskSource = { async *watchTasks() { yield { id: 'task-1', - title: 'Test', - description: null, - status: TaskStatus.OPEN, - tags: [], - claimant: null, - createdAt: new Date(), - updatedAt: new Date(), - priority: 1 + agentId: 'agent-1', + taskState: {}, + metadata: {} } }, - async getTaskDetail(_taskId: string) { - return null + async completeTask(_taskId: string): Promise { }, - async completeTask(taskId: string) { - return taskId === 'task-1' + async failTask(_taskId: string, _error: string): Promise { }, - async failTask(_taskId: string, _error: string) { - return false + async setState(_taskId: string, _taskState: Record): Promise { } } - const result = await source.completeTask('task-1') - expect(result).toBe(true) + await source.failTask('task-1', 'Test error') }) - it('should require failTask method', async () => { + it('should require setState method', async () => { const source: TaskSource = { async *watchTasks() { yield { id: 'task-1', - title: 'Test', - description: null, - status: TaskStatus.OPEN, - tags: [], - claimant: null, - createdAt: new Date(), - updatedAt: new Date(), - priority: 1 + agentId: 'agent-1', + taskState: {}, + metadata: {} } }, - async getTaskDetail(_taskId: string) { - return null + async completeTask(_taskId: string): Promise { }, - async completeTask(_taskId: string) { - return false + async failTask(_taskId: string, _error: string): Promise { }, - async failTask(taskId: string, error: string) { - return taskId === 'task-1' && error.length > 0 + async setState(_taskId: string, _taskState: Record): Promise { } } - const result = await source.failTask('task-1', 'Test error') - expect(result).toBe(true) + await source.setState('task-1', { step: 2 }) }) }) }) diff --git a/orchestrator/packages/task-source/src/types.spec.ts b/orchestrator/packages/task-source/src/types.spec.ts index 9850b65..19ddd67 100644 --- a/orchestrator/packages/task-source/src/types.spec.ts +++ b/orchestrator/packages/task-source/src/types.spec.ts @@ -1,10 +1,9 @@ import { describe, it, expect } from 'vitest' -import { TaskStatus, Task, TaskDetail } from './types.js' +import { TaskStatus, Task } from './types.js' describe('TaskSource Types', () => { describe('TaskStatus enum', () => { it('should have all required status values', () => { - // FR-1: Task Status enum values expect(TaskStatus.OPEN).toBe('OPEN') expect(TaskStatus.IN_PROGRESS).toBe('IN_PROGRESS') expect(TaskStatus.COMPLETED).toBe('COMPLETED') @@ -17,42 +16,27 @@ describe('TaskSource Types', () => { it('should create a valid Task with required fields', () => { const task: Task = { id: 'task-123', - title: 'Test Task', - description: 'Test Description', - status: TaskStatus.OPEN, - tags: ['worker:reviewer'], - claimant: null, - createdAt: new Date('2026-01-01'), - updatedAt: new Date('2026-01-01'), - priority: 1 + agentId: 'agent-1', + taskState: { language: 'Python', step: 1 }, + metadata: { priority: 'high', tags: ['bug'] } } expect(task.id).toBe('task-123') - expect(task.status).toBe(TaskStatus.OPEN) - expect(task.tags).toContain('worker:reviewer') + expect(task.agentId).toBe('agent-1') + expect(task.taskState).toEqual({ language: 'Python', step: 1 }) + expect(task.metadata).toEqual({ priority: 'high', tags: ['bug'] }) }) - }) - describe('TaskDetail type', () => { - it('should extend Task with additional fields', () => { - const detail: TaskDetail = { - id: 'task-123', - title: 'Test Task', - description: 'Test Description', - status: TaskStatus.OPEN, - tags: ['worker:reviewer'], - claimant: null, - createdAt: new Date('2026-01-01'), - updatedAt: new Date('2026-01-01'), - priority: 1, - dependencies: [], - notes: [], - acceptanceCriteria: [], - retro: [] + it('should allow empty objects for taskState and metadata', () => { + const task: Task = { + id: 'task-1', + agentId: 'agent-1', + taskState: {}, + metadata: {} } - expect(detail.dependencies).toEqual([]) - expect(detail.notes).toEqual([]) + expect(task.taskState).toEqual({}) + expect(task.metadata).toEqual({}) }) }) }) diff --git a/orchestrator/tsconfig.test.json b/orchestrator/tsconfig.test.json new file mode 100644 index 0000000..7cad209 --- /dev/null +++ b/orchestrator/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false + }, + "include": [ + "packages/*/src/**/*.ts", + "packages/*/src/**/*.spec.ts" + ], + "exclude": ["node_modules", "dist"] +} From 262fe9eb3d7a2acd0cf6835d71238df5febed7ed Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Thu, 7 May 2026 23:10:52 -0500 Subject: [PATCH 08/33] prettier and oxlint --- orchestrator/oxlint.config.ts | 10 + orchestrator/package-lock.json | 418 ++++++++++++++++++ orchestrator/package.json | 8 +- orchestrator/packages/cli/src/config.spec.ts | 74 ++-- orchestrator/packages/cli/src/config.ts | 112 ++--- .../packages/cli/src/git-root.spec.ts | 26 +- orchestrator/packages/cli/src/git-root.ts | 20 +- orchestrator/packages/cli/src/index.spec.ts | 34 +- orchestrator/packages/cli/src/index.ts | 50 +-- orchestrator/packages/cli/vite.config.ts | 10 +- .../packages/core/src/agent-parser.spec.ts | 108 ++--- .../packages/core/src/agent-parser.ts | 118 ++--- .../core/src/handlebars-renderer.spec.ts | 122 ++--- .../packages/core/src/handlebars-renderer.ts | 15 +- .../packages/core/src/hook-executor.spec.ts | 208 +++++---- .../packages/core/src/hook-executor.ts | 80 ++-- orchestrator/packages/core/src/index.ts | 14 +- .../packages/core/src/orchestrator.spec.ts | 259 ++++++----- .../packages/core/src/orchestrator.ts | 194 ++++---- .../packages/core/src/repo-installer.spec.ts | 178 ++++---- .../packages/core/src/repo-installer.ts | 40 +- orchestrator/packages/core/src/types.ts | 34 +- .../packages/core/src/validator.spec.ts | 132 +++--- orchestrator/packages/core/src/validator.ts | 56 ++- orchestrator/packages/core/vite.config.ts | 10 +- orchestrator/packages/engine/src/index.ts | 6 +- .../packages/engine/src/interface.spec.ts | 64 +-- orchestrator/packages/engine/src/interface.ts | 8 +- .../packages/engine/src/test-engine.spec.ts | 106 ++--- .../packages/engine/src/test-engine.ts | 52 +-- .../packages/engine/src/types.spec.ts | 80 ++-- orchestrator/packages/engine/src/types.ts | 42 +- orchestrator/packages/engine/vite.config.ts | 10 +- .../packages/task-source-memory/src/index.ts | 2 +- .../src/memory-task-source.spec.ts | 168 +++---- .../src/memory-task-source.ts | 88 ++-- .../task-source-memory/vite.config.ts | 10 +- .../packages/task-source/src/index.ts | 4 +- .../task-source/src/interface.spec.ts | 96 ++-- .../packages/task-source/src/interface.ts | 12 +- .../packages/task-source/src/types.spec.ts | 46 +- .../packages/task-source/src/types.ts | 56 +-- .../packages/task-source/vite.config.ts | 10 +- orchestrator/prettier.config.mjs | 10 + orchestrator/tsconfig.json | 2 +- orchestrator/tsconfig.test.json | 5 +- orchestrator/vite.base.ts | 61 +-- orchestrator/vitest.config.ts | 6 +- orchestrator/vitest.setup.ts | 2 +- 49 files changed, 1871 insertions(+), 1405 deletions(-) create mode 100644 orchestrator/oxlint.config.ts create mode 100644 orchestrator/prettier.config.mjs diff --git a/orchestrator/oxlint.config.ts b/orchestrator/oxlint.config.ts new file mode 100644 index 0000000..b9f0ace --- /dev/null +++ b/orchestrator/oxlint.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + plugins: ['typescript', 'react'], + rules: { + 'no-console': 'warn', + 'no-unused-vars': 'error', + 'react-hooks/exhaustive-deps': 'error', + }, +}); diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 4a072d2..bdfb5e7 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -16,6 +16,8 @@ }, "devDependencies": { "@types/node": "^25.6.1", + "oxlint": "^1.63.0", + "prettier": "^3.8.3", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", "vite": "^8.0.11", @@ -328,6 +330,10 @@ "resolved": "packages/task-source", "link": true }, + "node_modules/@orchestrator/task-source-memory": { + "resolved": "packages/task-source-memory", + "link": true + }, "node_modules/@oxc-project/types": { "version": "0.128.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", @@ -338,6 +344,353 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.63.0.tgz", + "integrity": "sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.63.0.tgz", + "integrity": "sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.63.0.tgz", + "integrity": "sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.63.0.tgz", + "integrity": "sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.63.0.tgz", + "integrity": "sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.63.0.tgz", + "integrity": "sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.63.0.tgz", + "integrity": "sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.63.0.tgz", + "integrity": "sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.63.0.tgz", + "integrity": "sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.63.0.tgz", + "integrity": "sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.63.0.tgz", + "integrity": "sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.63.0.tgz", + "integrity": "sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.63.0.tgz", + "integrity": "sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.63.0.tgz", + "integrity": "sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.63.0.tgz", + "integrity": "sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.63.0.tgz", + "integrity": "sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.63.0.tgz", + "integrity": "sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.63.0.tgz", + "integrity": "sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.63.0.tgz", + "integrity": "sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.18", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", @@ -2239,6 +2592,51 @@ "node": ">= 0.8.0" } }, + "node_modules/oxlint": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.63.0.tgz", + "integrity": "sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.63.0", + "@oxlint/binding-android-arm64": "1.63.0", + "@oxlint/binding-darwin-arm64": "1.63.0", + "@oxlint/binding-darwin-x64": "1.63.0", + "@oxlint/binding-freebsd-x64": "1.63.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.63.0", + "@oxlint/binding-linux-arm-musleabihf": "1.63.0", + "@oxlint/binding-linux-arm64-gnu": "1.63.0", + "@oxlint/binding-linux-arm64-musl": "1.63.0", + "@oxlint/binding-linux-ppc64-gnu": "1.63.0", + "@oxlint/binding-linux-riscv64-gnu": "1.63.0", + "@oxlint/binding-linux-riscv64-musl": "1.63.0", + "@oxlint/binding-linux-s390x-gnu": "1.63.0", + "@oxlint/binding-linux-x64-gnu": "1.63.0", + "@oxlint/binding-linux-x64-musl": "1.63.0", + "@oxlint/binding-openharmony-arm64": "1.63.0", + "@oxlint/binding-win32-arm64-msvc": "1.63.0", + "@oxlint/binding-win32-ia32-msvc": "1.63.0", + "@oxlint/binding-win32-x64-msvc": "1.63.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2381,6 +2779,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3083,6 +3497,10 @@ "packages/task-source": { "name": "@orchestrator/task-source", "version": "1.0.0" + }, + "packages/task-source-memory": { + "name": "@orchestrator/task-source-memory", + "version": "1.0.0" } } } diff --git a/orchestrator/package.json b/orchestrator/package.json index 9294e88..b0a2961 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -11,10 +11,14 @@ "test": "vitest run", "test:typecheck": "tsc --project tsconfig.test.json --noEmit", "build": "npm run build -ws", - "dev": "vitest --watch" + "dev": "vitest --watch", + "lint": "oxlint", + "format": "prettier . -w" }, "devDependencies": { - "@types/node": "^25.6.1", + "@types/node": "^25.6.2", + "oxlint": "^1.63.0", + "prettier": "^3.8.3", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", "vite": "^8.0.11", diff --git a/orchestrator/packages/cli/src/config.spec.ts b/orchestrator/packages/cli/src/config.spec.ts index 71b29b7..f4f4795 100644 --- a/orchestrator/packages/cli/src/config.spec.ts +++ b/orchestrator/packages/cli/src/config.spec.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, vi } from 'vitest' -import { loadConfig } from './config.js' -import { readFile } from 'node:fs/promises' +import { describe, it, expect, vi } from 'vitest'; +import { loadConfig } from './config.js'; +import { readFile } from 'node:fs/promises'; -vi.mock('node:fs/promises') +vi.mock('node:fs/promises'); describe('Config Loader - US-8, FR-13', () => { describe('Load .orchestrator.yaml configuration', () => { @@ -29,37 +29,37 @@ orchestrate: concurrency: 5 claimant: orchestrator-1 logging: verbose -` +`; - vi.mocked(readFile).mockResolvedValue(yamlContent) + vi.mocked(readFile).mockResolvedValue(yamlContent); // When the orchestrator loads configuration - const config = await loadConfig('/test/project') + const config = await loadConfig('/test/project'); // Then an APITaskSource is created with the specified base_url - expect(config.orchestrate.task_source.type).toBe('api') - expect(config.orchestrate.task_source.settings?.base_url).toBe('https://api.example.com') + expect(config.orchestrate.task_source.type).toBe('api'); + expect(config.orchestrate.task_source.settings?.base_url).toBe('https://api.example.com'); // And the task source polls every 30 seconds - expect(config.orchestrate.task_source.settings?.poll_interval).toBe(30) + expect(config.orchestrate.task_source.settings?.poll_interval).toBe(30); // And an AIRuntimeEngine is created with the specified endpoint - expect(config.orchestrate.engine.type).toBe('ai-runtime') - expect(config.orchestrate.engine.settings?.endpoint).toBe('https://ai.example.com') + expect(config.orchestrate.engine.type).toBe('ai-runtime'); + expect(config.orchestrate.engine.settings?.endpoint).toBe('https://ai.example.com'); // And a RedisTaskStateStore is created - expect(config.orchestrate.task_state_store.type).toBe('redis') - expect(config.orchestrate.task_state_store.settings?.url).toBe('redis://localhost:6379') + expect(config.orchestrate.task_state_store.type).toBe('redis'); + expect(config.orchestrate.task_state_store.settings?.url).toBe('redis://localhost:6379'); // And concurrency is 5 - expect(config.orchestrate.concurrency).toBe(5) + expect(config.orchestrate.concurrency).toBe(5); // And claimant is set - expect(config.orchestrate.claimant).toBe('orchestrator-1') + expect(config.orchestrate.claimant).toBe('orchestrator-1'); // And logging is verbose - expect(config.orchestrate.logging).toBe('verbose') - }) + expect(config.orchestrate.logging).toBe('verbose'); + }); it('should use default values when optional fields are missing', async () => { const yamlContent = ` @@ -70,16 +70,16 @@ orchestrate: type: test task_state_store: type: memory -` +`; - vi.mocked(readFile).mockResolvedValue(yamlContent) + vi.mocked(readFile).mockResolvedValue(yamlContent); - const config = await loadConfig('/test/project') + const config = await loadConfig('/test/project'); - expect(config.orchestrate.concurrency).toBe(1) // default - expect(config.orchestrate.claimant).toBeNull() // default - expect(config.orchestrate.logging).toBe('normal') // default - }) + expect(config.orchestrate.concurrency).toBe(1); // default + expect(config.orchestrate.claimant).toBeNull(); // default + expect(config.orchestrate.logging).toBe('normal'); // default + }); it('should throw error for unknown task source type', async () => { // Given an unknown task source type is configured @@ -91,14 +91,16 @@ orchestrate: type: test task_state_store: type: memory -` +`; - vi.mocked(readFile).mockResolvedValue(yamlContent) + vi.mocked(readFile).mockResolvedValue(yamlContent); // When the orchestrator attempts to create the task source // Then an error is raised with message "Unknown task source type: {type}" - await expect(loadConfig('/test/project')).rejects.toThrow('Unknown task source type: unknown-type') - }) + await expect(loadConfig('/test/project')).rejects.toThrow( + 'Unknown task source type: unknown-type' + ); + }); it('should load from home directory when not in project', async () => { // Test loading config from home directory as fallback @@ -110,13 +112,13 @@ orchestrate: type: test task_state_store: type: memory -` +`; - vi.mocked(readFile).mockResolvedValue(yamlContent) + vi.mocked(readFile).mockResolvedValue(yamlContent); - const config = await loadConfig('/home/user/.orchestrator.yaml') + const config = await loadConfig('/home/user/.orchestrator.yaml'); - expect(config.orchestrate.task_source.type).toBe('memory') - }) - }) -}) + expect(config.orchestrate.task_source.type).toBe('memory'); + }); + }); +}); diff --git a/orchestrator/packages/cli/src/config.ts b/orchestrator/packages/cli/src/config.ts index 090d1e1..f606e32 100644 --- a/orchestrator/packages/cli/src/config.ts +++ b/orchestrator/packages/cli/src/config.ts @@ -1,35 +1,35 @@ -import { readFile } from 'node:fs/promises' -import { resolve, join } from 'node:path' -import { homedir } from 'node:os' -import { parse as yamlParse } from 'yaml' +import { readFile } from 'node:fs/promises'; +import { resolve, join } from 'node:path'; +import { homedir } from 'node:os'; +import { parse as yamlParse } from 'yaml'; export type TaskSourceConfig = { - type: string - settings?: Record -} + type: string; + settings?: Record; +}; export type EngineConfig = { - type: string - settings?: Record -} + type: string; + settings?: Record; +}; export type TaskStateStoreConfig = { - type: 'redis' | 'memory' | 'file' - settings?: Record -} + type: 'redis' | 'memory' | 'file'; + settings?: Record; +}; export type OrchestrateConfig = { - task_source: TaskSourceConfig - engine: EngineConfig - task_state_store: TaskStateStoreConfig - concurrency: number - claimant: string | null - logging: 'normal' | 'verbose' -} + task_source: TaskSourceConfig; + engine: EngineConfig; + task_state_store: TaskStateStoreConfig; + concurrency: number; + claimant: string | null; + logging: 'normal' | 'verbose'; +}; export type OrchestratorConfig = { - orchestrate: OrchestrateConfig -} + orchestrate: OrchestrateConfig; +}; const DEFAULT_CONFIG: OrchestratorConfig = { orchestrate: { @@ -38,9 +38,9 @@ const DEFAULT_CONFIG: OrchestratorConfig = { task_state_store: { type: 'memory' }, concurrency: 1, claimant: null, - logging: 'normal' - } -} + logging: 'normal', + }, +}; /** * Load .orchestrator.yaml configuration file. @@ -50,75 +50,75 @@ const DEFAULT_CONFIG: OrchestratorConfig = { * @param projectDir - The project directory path * @returns Parsed configuration */ -export const loadConfig = async ( - projectDir: string -): Promise => { +export const loadConfig = async (projectDir: string): Promise => { // Try project directory first, then home directory - const projectConfigPath = resolve(projectDir, '.orchestrator.yaml') - const homeConfigPath = resolve(homedir(), '.orchestrator.yaml') + const projectConfigPath = resolve(projectDir, '.orchestrator.yaml'); + const homeConfigPath = resolve(homedir(), '.orchestrator.yaml'); - let configContent: string + let configContent: string; try { - configContent = await readFile(projectConfigPath, 'utf-8') + configContent = await readFile(projectConfigPath, 'utf-8'); } catch { try { - configContent = await readFile(homeConfigPath, 'utf-8') + configContent = await readFile(homeConfigPath, 'utf-8'); } catch { // No config file found, return defaults - return DEFAULT_CONFIG + return DEFAULT_CONFIG; } } - const parsed = yamlParse(configContent) as Record + const parsed = yamlParse(configContent) as Record; if (!parsed.orchestrate) { - return DEFAULT_CONFIG + return DEFAULT_CONFIG; } - const orchestrate = parsed.orchestrate as Record + const orchestrate = parsed.orchestrate as Record; // Validate task source type - const taskSource = orchestrate.task_source as TaskSourceConfig | undefined - const taskSourceType = taskSource?.type || 'memory' + const taskSource = orchestrate.task_source as TaskSourceConfig | undefined; + const taskSourceType = taskSource?.type || 'memory'; // FR-13: If unknown task source type, raise error - const validTaskSourceTypes = ['api', 'memory', 'file', 'queue'] + const validTaskSourceTypes = ['api', 'memory', 'file', 'queue']; if (!validTaskSourceTypes.includes(taskSourceType)) { - throw new Error(`Unknown task source type: ${taskSourceType}`) + throw new Error(`Unknown task source type: ${taskSourceType}`); } // Extract settings - const taskSourceSettings = (taskSource?.settings as Record) || {} + const taskSourceSettings = (taskSource?.settings as Record) || {}; - const engine = orchestrate.engine as EngineConfig | undefined - const engineSettings = (engine?.settings as Record) || {} + const engine = orchestrate.engine as EngineConfig | undefined; + const engineSettings = (engine?.settings as Record) || {}; - const taskStateStore = orchestrate.task_state_store as TaskStateStoreConfig | undefined - const taskStateStoreSettings = (taskStateStore?.settings as Record) || {} + const taskStateStore = orchestrate.task_state_store as TaskStateStoreConfig | undefined; + const taskStateStoreSettings = (taskStateStore?.settings as Record) || {}; // Parse optional fields with defaults - const concurrency = typeof orchestrate.concurrency === 'number' ? orchestrate.concurrency : 1 - const claimant = typeof orchestrate.claimant === 'string' ? orchestrate.claimant : null - const logging = (orchestrate.logging === 'verbose' ? 'verbose' : 'normal') as 'normal' | 'verbose' + const concurrency = typeof orchestrate.concurrency === 'number' ? orchestrate.concurrency : 1; + const claimant = typeof orchestrate.claimant === 'string' ? orchestrate.claimant : null; + const logging = (orchestrate.logging === 'verbose' ? 'verbose' : 'normal') as + | 'normal' + | 'verbose'; return { orchestrate: { task_source: { type: taskSourceType, - settings: taskSourceSettings + settings: taskSourceSettings, }, engine: { type: engine?.type || 'test', - settings: engineSettings + settings: engineSettings, }, task_state_store: { type: (taskStateStore?.type as 'redis' | 'memory' | 'file') || 'memory', - settings: taskStateStoreSettings + settings: taskStateStoreSettings, }, concurrency, claimant, - logging - } - } -} + logging, + }, + }; +}; diff --git a/orchestrator/packages/cli/src/git-root.spec.ts b/orchestrator/packages/cli/src/git-root.spec.ts index d7e6fdc..1a3ec6f 100644 --- a/orchestrator/packages/cli/src/git-root.spec.ts +++ b/orchestrator/packages/cli/src/git-root.spec.ts @@ -1,34 +1,34 @@ -import { describe, it, expect } from 'vitest' -import { resolveGitRoot } from './git-root.js' +import { describe, it, expect } from 'vitest'; +import { resolveGitRoot } from './git-root.js'; describe('Git Root Resolution - US-10', () => { describe('FR-6: projectDir Resolution', () => { it('should set projectDir to git root when run from inside repository', async () => { // Given a developer runs the orchestrator from a directory inside a git repository // When the orchestrator program starts - const root = await resolveGitRoot('/home/devzeebo/git/bifrost/orchestrator/packages/cli') + const root = await resolveGitRoot('/home/devzeebo/git/bifrost/orchestrator/packages/cli'); // Then projectDir is set to the git root - expect(root).toContain('/bifrost') - }) + expect(root).toContain('/bifrost'); + }); it('should find git root from subdirectory', async () => { // Given a git repository rooted at /home/user/myrepo // And a developer runs the orchestrator from /home/user/myrepo/src/lib - const root = await resolveGitRoot('/home/devzeebo/git/bifrost/orchestrator/packages/cli/src') + const root = await resolveGitRoot('/home/devzeebo/git/bifrost/orchestrator/packages/cli/src'); // When the orchestrator program starts // Then projectDir is /home/user/myrepo - expect(root).toBeTruthy() - }) + expect(root).toBeTruthy(); + }); it('should exit with error when not inside a git repository', async () => { // Given a developer runs the orchestrator from a directory that is not inside any git repository // When the orchestrator program starts - const root = await resolveGitRoot('/tmp/nonexistent/path') + const root = await resolveGitRoot('/tmp/nonexistent/path'); // Then it exits with a descriptive error stating that no git root could be found - expect(root).toBeNull() - }) - }) -}) + expect(root).toBeNull(); + }); + }); +}); diff --git a/orchestrator/packages/cli/src/git-root.ts b/orchestrator/packages/cli/src/git-root.ts index 4f4a746..8af3b0f 100644 --- a/orchestrator/packages/cli/src/git-root.ts +++ b/orchestrator/packages/cli/src/git-root.ts @@ -1,5 +1,5 @@ -import { existsSync } from 'node:fs' -import { resolve } from 'node:path' +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; /** * Resolve the git repository root by walking up from the current directory. @@ -10,27 +10,27 @@ import { resolve } from 'node:path' * @returns The git root directory path, or null if not found */ export const resolveGitRoot = async (startPath: string): Promise => { - let currentPath = resolve(startPath) + let currentPath = resolve(startPath); // Walk up the directory tree while (currentPath !== '/') { - const gitDir = resolve(currentPath, '.git') + const gitDir = resolve(currentPath, '.git'); if (existsSync(gitDir)) { - return currentPath + return currentPath; } // Move up one directory - const parentPath = resolve(currentPath, '..') + const parentPath = resolve(currentPath, '..'); // Prevent infinite loop if (parentPath === currentPath) { - break + break; } - currentPath = parentPath + currentPath = parentPath; } // No git root found - return null -} + return null; +}; diff --git a/orchestrator/packages/cli/src/index.spec.ts b/orchestrator/packages/cli/src/index.spec.ts index 09cebea..88e83a9 100644 --- a/orchestrator/packages/cli/src/index.spec.ts +++ b/orchestrator/packages/cli/src/index.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' -import { listAgents } from './index.js' +import { describe, it, expect } from 'vitest'; +import { listAgents } from './index.js'; describe('CLI - US-9: List Available Agents', () => { describe('--list-agents command', () => { @@ -13,32 +13,32 @@ describe('CLI - US-9: List Available Agents', () => { model: 'claude-opus-4-7', hooks: { Start: [{ name: 'validate-args', scriptPath: '/hooks/validate-args.mjs' }], - Stop: [{ name: 'check-new-tests', scriptPath: '/hooks/check.mjs' }] - } - } + Stop: [{ name: 'check-new-tests', scriptPath: '/hooks/check.mjs' }], + }, + }; // When the orchestrator CLI is invoked with --list-agents - const output = await listAgents([mockAgent]) + const output = await listAgents([mockAgent]); // Then each agent name is printed - expect(output).toContain('reviewer') + expect(output).toContain('reviewer'); // And agent description is printed if present - expect(output).toContain('Code review agent') + expect(output).toContain('Code review agent'); // And agent tools are printed as comma-separated list - expect(output).toContain('readFile, edit') + expect(output).toContain('readFile, edit'); // And start_hooks are printed - expect(output).toContain('validate-args') + expect(output).toContain('validate-args'); // And stop_hooks are printed - expect(output).toContain('check-new-tests') - }) + expect(output).toContain('check-new-tests'); + }); it('should print "No agents found" when catalog is empty', async () => { // Given the agent catalog is empty // When the orchestrator CLI is invoked with --list-agents - const output = await listAgents([]) + const output = await listAgents([]); // Then "No agents found." is printed - expect(output).toContain('No agents found') - }) - }) -}) + expect(output).toContain('No agents found'); + }); + }); +}); diff --git a/orchestrator/packages/cli/src/index.ts b/orchestrator/packages/cli/src/index.ts index 09fc9af..09f90e0 100644 --- a/orchestrator/packages/cli/src/index.ts +++ b/orchestrator/packages/cli/src/index.ts @@ -1,17 +1,17 @@ #!/usr/bin/env node -import type { AgentDefinition } from '@orchestrator/core' +import type { AgentDefinition } from '@orchestrator/core'; export type AgentDisplayInfo = { - name: string - description?: string - model?: string - tools?: string[] + name: string; + description?: string; + model?: string; + tools?: string[]; hooks?: { - Start: Array<{ name: string }> - Stop: Array<{ name: string }> - } -} + Start: Array<{ name: string }>; + Stop: Array<{ name: string }>; + }; +}; /** * List available agents in human-readable format. @@ -19,45 +19,45 @@ export type AgentDisplayInfo = { */ export const listAgents = async (agents: AgentDisplayInfo[]): Promise => { if (agents.length === 0) { - return 'No agents found.' + return 'No agents found.'; } - const lines: string[] = [] + const lines: string[] = []; for (const agent of agents) { - lines.push(`Agent: ${agent.name}`) + lines.push(`Agent: ${agent.name}`); if (agent.description) { - lines.push(` Description: ${agent.description}`) + lines.push(` Description: ${agent.description}`); } if (agent.model) { - lines.push(` Model: ${agent.model}`) + lines.push(` Model: ${agent.model}`); } if (agent.tools && agent.tools.length > 0) { - lines.push(` Tools: ${agent.tools.join(', ')}`) + lines.push(` Tools: ${agent.tools.join(', ')}`); } if (agent.hooks?.Start && agent.hooks.Start.length > 0) { - const startHooks = agent.hooks.Start.map((h) => h.name).join(', ') - lines.push(` Start Hooks: ${startHooks}`) + const startHooks = agent.hooks.Start.map((h) => h.name).join(', '); + lines.push(` Start Hooks: ${startHooks}`); } if (agent.hooks?.Stop && agent.hooks.Stop.length > 0) { - const stopHooks = agent.hooks.Stop.map((h) => h.name).join(', ') - lines.push(` Stop Hooks: ${stopHooks}`) + const stopHooks = agent.hooks.Stop.map((h) => h.name).join(', '); + lines.push(` Stop Hooks: ${stopHooks}`); } - lines.push('') // Blank line between agents + lines.push(''); // Blank line between agents } - return lines.join('\n') -} + return lines.join('\n'); +}; export function run() { - console.log('Orchestrator CLI') + console.log('Orchestrator CLI'); } -export * from './git-root.js' -export * from './config.js' +export * from './git-root.js'; +export * from './config.js'; diff --git a/orchestrator/packages/cli/vite.config.ts b/orchestrator/packages/cli/vite.config.ts index 0cb4399..2a7b514 100644 --- a/orchestrator/packages/cli/vite.config.ts +++ b/orchestrator/packages/cli/vite.config.ts @@ -1,10 +1,10 @@ -import pkg from './package.json' +import pkg from './package.json'; // @ts-ignore -import tsconfig from './tsconfig.json' -import base from '../../vite.base' +import tsconfig from './tsconfig.json'; +import base from '../../vite.base'; export default base({ name: 'cli', pkg, - tsconfig -}) \ No newline at end of file + tsconfig, +}); diff --git a/orchestrator/packages/core/src/agent-parser.spec.ts b/orchestrator/packages/core/src/agent-parser.spec.ts index a9f6e67..9dc287c 100644 --- a/orchestrator/packages/core/src/agent-parser.spec.ts +++ b/orchestrator/packages/core/src/agent-parser.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' -import { parseAgentDefinition } from './agent-parser.js' +import { describe, it, expect } from 'vitest'; +import { parseAgentDefinition } from './agent-parser.js'; describe('AGENT.md Parser - US-1', () => { describe('Valid AGENT.md parsing', () => { @@ -33,19 +33,19 @@ hooks: scriptPath: hooks/Stop.d/check-new-tests.mjs --- You are a code reviewer. Review the changes for {{language.name}}. -` +`; - const agent = parseAgentDefinition(content) + const agent = parseAgentDefinition(content); // Then the agent name, description, tools, toolClasses, template parameter schema, and prompt body are all accessible - expect(agent).toBeDefined() - expect(agent?.name).toBe('reviewer') - expect(agent?.description).toBe('Code review agent') - expect(agent?.tools).toEqual(['readFile', 'edit']) - expect(agent?.toolClasses).toEqual(['linter']) - expect(agent?.template.parameters).toBeDefined() - expect(agent?.promptBody).toContain('You are a code reviewer') - }) + expect(agent).toBeDefined(); + expect(agent?.name).toBe('reviewer'); + expect(agent?.description).toBe('Code review agent'); + expect(agent?.tools).toEqual(['readFile', 'edit']); + expect(agent?.toolClasses).toEqual(['linter']); + expect(agent?.template.parameters).toBeDefined(); + expect(agent?.promptBody).toContain('You are a code reviewer'); + }); it('should render Handlebars tokens from template.parameters', () => { const content = `--- @@ -59,12 +59,12 @@ template: framework: string --- Write {{language.name}} tests using {{framework}}. -` +`; - const agent = parseAgentDefinition(content) - expect(agent?.promptBody).toContain('Write {{language.name}} tests using {{framework}}') - }) - }) + const agent = parseAgentDefinition(content); + expect(agent?.promptBody).toContain('Write {{language.name}} tests using {{framework}}'); + }); + }); describe('Required field validation', () => { it('should fail when name is missing', () => { @@ -74,14 +74,14 @@ description: Test agent tools: [] --- Test prompt -` +`; // When the orchestrator reads the file - const agent = parseAgentDefinition(content) + const agent = parseAgentDefinition(content); // Then parsing fails with a descriptive error naming the missing field - expect(agent).toBeNull() - }) + expect(agent).toBeNull(); + }); it('should fail when description is missing', () => { const content = `--- @@ -89,11 +89,11 @@ name: test-agent tools: [] --- Test prompt -` +`; - const agent = parseAgentDefinition(content) - expect(agent).toBeNull() - }) + const agent = parseAgentDefinition(content); + expect(agent).toBeNull(); + }); it('should fail when tools is missing', () => { const content = `--- @@ -101,12 +101,12 @@ name: test-agent description: Test --- Test prompt -` +`; - const agent = parseAgentDefinition(content) - expect(agent).toBeNull() - }) - }) + const agent = parseAgentDefinition(content); + expect(agent).toBeNull(); + }); + }); describe('Optional parameters (ending with ?)', () => { it('should mark field ending with ? as optional', () => { @@ -122,15 +122,15 @@ template: notes?: string --- Test prompt {{context.prDescription}} -` +`; - const agent = parseAgentDefinition(content) + const agent = parseAgentDefinition(content); // When the parameter schema is parsed // Then that field is marked optional - expect(agent?.template.parameters['context?']).toBeDefined() - }) - }) + expect(agent?.template.parameters['context?']).toBeDefined(); + }); + }); describe('Handlebars token validation', () => { it('should fail when prompt references undeclared Handlebars token', () => { @@ -144,15 +144,15 @@ template: language: string --- Use the {{framework}} for {{language}}. -` +`; // When the AGENT.md is parsed - const agent = parseAgentDefinition(content) + const agent = parseAgentDefinition(content); // Then parsing fails identifying the undeclared token by name - expect(agent).toBeNull() - }) - }) + expect(agent).toBeNull(); + }); + }); describe('Hook parsing', () => { it('should parse Start hooks', () => { @@ -167,13 +167,13 @@ hooks: timeout: 120000 --- Prompt -` +`; - const agent = parseAgentDefinition(content) - expect(agent?.hooks.Start).toHaveLength(1) - expect(agent?.hooks.Start[0].name).toBe('validate-args') - expect(agent?.hooks.Start[0].timeout).toBe(120000) - }) + const agent = parseAgentDefinition(content); + expect(agent?.hooks.Start).toHaveLength(1); + expect(agent?.hooks.Start[0].name).toBe('validate-args'); + expect(agent?.hooks.Start[0].timeout).toBe(120000); + }); it('should parse Stop hooks', () => { const content = `--- @@ -186,11 +186,11 @@ hooks: scriptPath: hooks/Stop.d/check-new-tests.mjs --- Prompt -` - - const agent = parseAgentDefinition(content) - expect(agent?.hooks.Stop).toHaveLength(1) - expect(agent?.hooks.Stop[0].name).toBe('check-new-tests') - }) - }) -}) +`; + + const agent = parseAgentDefinition(content); + expect(agent?.hooks.Stop).toHaveLength(1); + expect(agent?.hooks.Stop[0].name).toBe('check-new-tests'); + }); + }); +}); diff --git a/orchestrator/packages/core/src/agent-parser.ts b/orchestrator/packages/core/src/agent-parser.ts index 623c320..7f6083f 100644 --- a/orchestrator/packages/core/src/agent-parser.ts +++ b/orchestrator/packages/core/src/agent-parser.ts @@ -1,58 +1,58 @@ -import matter from 'gray-matter' -import { AgentDefinition } from './types.js' +import matter from 'gray-matter'; +import { AgentDefinition } from './types.js'; /** * Extract all Handlebars tokens from a string. * Matches patterns like {{token}}, {{token.path}}, {{#if token}}...{{/if}} */ const extractHandlebarsTokens = (content: string): Set => { - const tokens = new Set() + const tokens = new Set(); // Match simple tokens: {{variableName}} - const simpleTokenRegex = /\{\{([^#/][^}]*)\}\}/g - let match + const simpleTokenRegex = /\{\{([^#/][^}]*)\}\}/g; + let match; while ((match = simpleTokenRegex.exec(content)) !== null) { - const token = match[1].trim() + const token = match[1].trim(); // Extract the base path (first part before any dots or spaces) - const basePath = token.split('.')[0].split(' ')[0] - tokens.add(basePath) + const basePath = token.split('.')[0].split(' ')[0]; + tokens.add(basePath); } // Match block helpers: {{#if variableName}}...{{/if}} - const blockTokenRegex = /\{\{#(?:if|unless|each)\s+([^}]+)\}\}/g + const blockTokenRegex = /\{\{#(?:if|unless|each)\s+([^}]+)\}\}/g; while ((match = blockTokenRegex.exec(content)) !== null) { - const token = match[1].trim() - const basePath = token.split('.')[0].split(' ')[0] - tokens.add(basePath) + const token = match[1].trim(); + const basePath = token.split('.')[0].split(' ')[0]; + tokens.add(basePath); } - return tokens -} + return tokens; +}; /** * Get all parameter paths from template.parameters, including nested paths. * Handles optional parameters (ending with ?). */ const getDeclaredParameters = (params: Record): Set => { - const declared = new Set() + const declared = new Set(); for (const key of Object.keys(params)) { // Remove the ? suffix for optional parameters - const baseKey = key.endsWith('?') ? key.slice(0, -1) : key - declared.add(baseKey) + const baseKey = key.endsWith('?') ? key.slice(0, -1) : key; + declared.add(baseKey); // Recursively extract nested parameters - const value = params[key] + const value = params[key]; if (typeof value === 'object' && value !== null) { - const nestedParams = getDeclaredParameters(value as Record) + const nestedParams = getDeclaredParameters(value as Record); for (const nested of nestedParams) { - declared.add(`${baseKey}.${nested}`) + declared.add(`${baseKey}.${nested}`); } } } - return declared -} + return declared; +}; /** * Parse AGENT.md file with YAML frontmatter. @@ -60,83 +60,83 @@ const getDeclaredParameters = (params: Record): Set => */ export const parseAgentDefinition = (content: string): AgentDefinition | null => { try { - const parsed = matter(content) + const parsed = matter(content); - const data = parsed.data as Record - const promptBody = parsed.content + const data = parsed.data as Record; + const promptBody = parsed.content; // Validate required fields: name, description, tools if (!data.name || typeof data.name !== 'string') { - console.error('Missing or invalid required field: name') - return null + console.error('Missing or invalid required field: name'); + return null; } if (!data.description || typeof data.description !== 'string') { - console.error('Missing or invalid required field: description') - return null + console.error('Missing or invalid required field: description'); + return null; } if (!Array.isArray(data.tools)) { - console.error('Missing or invalid required field: tools') - return null + console.error('Missing or invalid required field: tools'); + return null; } // Extract toolClasses (optional, defaults to empty array) - const toolClasses = Array.isArray(data.toolClasses) ? data.toolClasses as string[] : [] + const toolClasses = Array.isArray(data.toolClasses) ? (data.toolClasses as string[]) : []; // Extract template.parameters - const templateData = data.template as Record | undefined - const parameters = (templateData?.parameters as Record) || {} + const templateData = data.template as Record | undefined; + const parameters = (templateData?.parameters as Record) || {}; // Extract Handlebars tokens from prompt body - const usedTokens = extractHandlebarsTokens(promptBody) + const usedTokens = extractHandlebarsTokens(promptBody); // Get all declared parameter paths - const declaredParams = getDeclaredParameters(parameters) + const declaredParams = getDeclaredParameters(parameters); // Validate that all used tokens are declared for (const token of usedTokens) { // Check if token or any of its parent paths are declared - let isDeclared = declaredParams.has(token) + let isDeclared = declaredParams.has(token); // Check for parent paths (e.g., if using "context.prDescription", check if "context" or "context?" is declared) if (!isDeclared) { - const parts = token.split('.') + const parts = token.split('.'); for (let i = parts.length; i > 0; i--) { - const parentPath = parts.slice(0, i).join('.') + const parentPath = parts.slice(0, i).join('.'); if (declaredParams.has(parentPath) || declaredParams.has(`${parentPath}?`)) { - isDeclared = true - break + isDeclared = true; + break; } } } if (!isDeclared) { - console.error(`Undeclared Handlebars token: ${token}`) - return null + console.error(`Undeclared Handlebars token: ${token}`); + return null; } } // Parse hooks - const hooksData = data.hooks as Record | undefined + const hooksData = data.hooks as Record | undefined; const hooks: { - Start: Array<{ name: string; scriptPath: string; timeout?: number }> - Stop: Array<{ name: string; scriptPath: string; timeout?: number }> + Start: Array<{ name: string; scriptPath: string; timeout?: number }>; + Stop: Array<{ name: string; scriptPath: string; timeout?: number }>; } = { Start: [], - Stop: [] - } + Stop: [], + }; if (hooksData?.Start && Array.isArray(hooksData.Start)) { for (const hook of hooksData.Start) { if (typeof hook === 'object' && hook !== null) { - const hookObj = hook as Record + const hookObj = hook as Record; if (typeof hookObj.name === 'string' && typeof hookObj.scriptPath === 'string') { hooks.Start.push({ name: hookObj.name, scriptPath: hookObj.scriptPath, - timeout: typeof hookObj.timeout === 'number' ? hookObj.timeout : undefined - }) + timeout: typeof hookObj.timeout === 'number' ? hookObj.timeout : undefined, + }); } } } @@ -145,13 +145,13 @@ export const parseAgentDefinition = (content: string): AgentDefinition | null => if (hooksData?.Stop && Array.isArray(hooksData.Stop)) { for (const hook of hooksData.Stop) { if (typeof hook === 'object' && hook !== null) { - const hookObj = hook as Record + const hookObj = hook as Record; if (typeof hookObj.name === 'string' && typeof hookObj.scriptPath === 'string') { hooks.Stop.push({ name: hookObj.name, scriptPath: hookObj.scriptPath, - timeout: typeof hookObj.timeout === 'number' ? hookObj.timeout : undefined - }) + timeout: typeof hookObj.timeout === 'number' ? hookObj.timeout : undefined, + }); } } } @@ -164,10 +164,10 @@ export const parseAgentDefinition = (content: string): AgentDefinition | null => toolClasses, template: { parameters }, hooks, - promptBody - } + promptBody, + }; } catch (error) { - console.error('Failed to parse AGENT.md:', error) - return null + console.error('Failed to parse AGENT.md:', error); + return null; } -} +}; diff --git a/orchestrator/packages/core/src/handlebars-renderer.spec.ts b/orchestrator/packages/core/src/handlebars-renderer.spec.ts index ff9b84f..84b9190 100644 --- a/orchestrator/packages/core/src/handlebars-renderer.spec.ts +++ b/orchestrator/packages/core/src/handlebars-renderer.spec.ts @@ -1,121 +1,123 @@ -import { describe, it, expect } from 'vitest' -import { renderPrompt } from './handlebars-renderer.js' +import { describe, it, expect } from 'vitest'; +import { renderPrompt } from './handlebars-renderer.js'; describe('Handlebars Prompt Renderer', () => { describe('FR-14: Render Handlebars prompt with taskState values', () => { it('should replace simple Handlebars tokens with taskState values', () => { // Given a prompt body with Handlebars tokens - const promptBody = 'Write {{language.name}} code using {{testFramework.name}}.' + const promptBody = 'Write {{language.name}} code using {{testFramework.name}}.'; // And taskState with matching values const taskState = { language: { name: 'Python', prompt: 'Write Python code' }, - testFramework: { name: 'pytest', prompt: 'Use pytest framework' } - } + testFramework: { name: 'pytest', prompt: 'Use pytest framework' }, + }; // When rendering - const rendered = renderPrompt(promptBody, taskState) + const rendered = renderPrompt(promptBody, taskState); // Then tokens are replaced with values - expect(rendered).toContain('Python') - expect(rendered).toContain('pytest') - expect(rendered).not.toContain('{{') - }) + expect(rendered).toContain('Python'); + expect(rendered).toContain('pytest'); + expect(rendered).not.toContain('{{'); + }); it('should render nested object paths', () => { - const promptBody = 'PR: {{context.prDescription}}, Notes: {{context.additionalNotes}}' + const promptBody = 'PR: {{context.prDescription}}, Notes: {{context.additionalNotes}}'; const taskState = { context: { prDescription: 'Fix authentication bug', - additionalNotes: 'Urgent - blocking release' - } - } + additionalNotes: 'Urgent - blocking release', + }, + }; - const rendered = renderPrompt(promptBody, taskState) + const rendered = renderPrompt(promptBody, taskState); - expect(rendered).toContain('Fix authentication bug') - expect(rendered).toContain('Urgent - blocking release') - }) + expect(rendered).toContain('Fix authentication bug'); + expect(rendered).toContain('Urgent - blocking release'); + }); it('should render empty string for missing optional parameters', () => { // Given absent optional Handlebars tokens render as empty string - const promptBody = 'Write tests. {{context.additionalNotes}}' + const promptBody = 'Write tests. {{context.additionalNotes}}'; const taskState = { // context is optional and absent - } + }; // When rendering - const rendered = renderPrompt(promptBody, taskState) + const rendered = renderPrompt(promptBody, taskState); // Then absent optional field renders as empty string - expect(rendered).toBe('Write tests. ') - }) + expect(rendered).toBe('Write tests. '); + }); it('should support {{#if}} blocks for optional parameters', () => { - const promptBody = '{{#if context.prDescription}}PR: {{context.prDescription}}{{/if}} Write tests.' + const promptBody = + '{{#if context.prDescription}}PR: {{context.prDescription}}{{/if}} Write tests.'; const taskState = { context: { - prDescription: 'Add unit tests' - } - } + prDescription: 'Add unit tests', + }, + }; - const rendered = renderPrompt(promptBody, taskState) + const rendered = renderPrompt(promptBody, taskState); - expect(rendered).toContain('PR: Add unit tests') - expect(rendered).toContain('Write tests') - }) + expect(rendered).toContain('PR: Add unit tests'); + expect(rendered).toContain('Write tests'); + }); it('should exclude {{#if}} content when parameter is absent', () => { - const promptBody = '{{#if context.prDescription}}PR: {{context.prDescription}}{{/if}} Write tests.' - const taskState = {} + const promptBody = + '{{#if context.prDescription}}PR: {{context.prDescription}}{{/if}} Write tests.'; + const taskState = {}; - const rendered = renderPrompt(promptBody, taskState) + const rendered = renderPrompt(promptBody, taskState); - expect(rendered).not.toContain('PR:') - expect(rendered).toContain('Write tests') - }) - }) + expect(rendered).not.toContain('PR:'); + expect(rendered).toContain('Write tests'); + }); + }); describe('FR-5: Handlebars tokens in prompt body must match declared parameters', () => { it('should fail gracefully on undeclared tokens', () => { // This is validated during AGENT.md parsing, not rendering // Rendering should handle missing keys gracefully - const promptBody = 'Use {{framework}} for {{language}}' + const promptBody = 'Use {{framework}} for {{language}}'; const taskState = { - language: 'Python' + language: 'Python', // framework is missing - } + }; - const rendered = renderPrompt(promptBody, taskState) + const rendered = renderPrompt(promptBody, taskState); // Missing tokens render as empty string - expect(rendered).toContain('Python') - expect(rendered).toContain('for') // "Use for Python" - framework is empty - }) - }) + expect(rendered).toContain('Python'); + expect(rendered).toContain('for'); // "Use for Python" - framework is empty + }); + }); describe('NFR-7: Reproducibility', () => { it('should produce identical output for same inputs', () => { - const promptBody = 'Write {{language}} tests with {{framework}}' - const taskState = { language: 'Python', framework: 'pytest' } + const promptBody = 'Write {{language}} tests with {{framework}}'; + const taskState = { language: 'Python', framework: 'pytest' }; - const rendered1 = renderPrompt(promptBody, taskState) - const rendered2 = renderPrompt(promptBody, taskState) + const rendered1 = renderPrompt(promptBody, taskState); + const rendered2 = renderPrompt(promptBody, taskState); // Given same AGENT.md, same taskState, same projectDir // Two dispatches produce identical rendered prompts - expect(rendered1).toBe(rendered2) - }) + expect(rendered1).toBe(rendered2); + }); it('should be side-effect free', () => { - const promptBody = 'Use {{language}}' - const taskState = { language: 'Python' } + const promptBody = 'Use {{language}}'; + const taskState = { language: 'Python' }; - const originalTaskState = { ...taskState } - renderPrompt(promptBody, taskState) + const originalTaskState = { ...taskState }; + renderPrompt(promptBody, taskState); // Handlebars rendering is deterministic and side-effect free - expect(taskState).toEqual(originalTaskState) - }) - }) -}) + expect(taskState).toEqual(originalTaskState); + }); + }); +}); diff --git a/orchestrator/packages/core/src/handlebars-renderer.ts b/orchestrator/packages/core/src/handlebars-renderer.ts index 0bd73aa..0491837 100644 --- a/orchestrator/packages/core/src/handlebars-renderer.ts +++ b/orchestrator/packages/core/src/handlebars-renderer.ts @@ -1,4 +1,4 @@ -import Handlebars from 'handlebars' +import Handlebars from 'handlebars'; /** * Render a Handlebars template with taskState values. @@ -10,10 +10,7 @@ import Handlebars from 'handlebars' * @param taskState - The taskState values for substitution * @returns Rendered prompt string */ -export const renderPrompt = ( - promptBody: string, - taskState: Record -): string => { +export const renderPrompt = (promptBody: string, taskState: Record): string => { // Register any custom helpers if needed // {{#if}} is built-in to Handlebars @@ -21,9 +18,9 @@ export const renderPrompt = ( // Handlebars.compile creates a template function that is deterministic const template = Handlebars.compile(promptBody, { strict: false, // Don't throw on missing variables - render as empty string - knownHelpers: { if: true } // Declare built-in helpers - }) + knownHelpers: { if: true }, // Declare built-in helpers + }); // Render with taskState - this operation is side-effect free - return template(taskState) -} + return template(taskState); +}; diff --git a/orchestrator/packages/core/src/hook-executor.spec.ts b/orchestrator/packages/core/src/hook-executor.spec.ts index 9386a55..868177a 100644 --- a/orchestrator/packages/core/src/hook-executor.spec.ts +++ b/orchestrator/packages/core/src/hook-executor.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from 'vitest' -import { executeHooks, HookExecutionContext } from './hook-executor.js' +import { describe, it, expect, vi } from 'vitest'; +import { executeHooks, HookExecutionContext } from './hook-executor.js'; describe('Hook Executor - US-4', () => { describe('Start hooks execution', () => { @@ -9,224 +9,222 @@ describe('Hook Executor - US-4', () => { { name: 'validate-args', scriptPath: '/hooks/validate-args.mjs', - timeout: 30000 - } - ] + timeout: 30000, + }, + ]; const context: HookExecutionContext = { projectDir: '/test/project', params: { language: 'python' }, - taskState: { language: { name: 'python' } } - } + taskState: { language: { name: 'python' } }, + }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); // When the agent is dispatched - const results = await executeHooks(hooks, 'Start', context, mockExec) + const results = await executeHooks(hooks, 'Start', context, mockExec); // Then each Start hook executes in declaration order - expect(results).toHaveLength(1) - expect(results[0].hookName).toBe('validate-args') - expect(results[0].exitCode).toBe(0) - }) + expect(results).toHaveLength(1); + expect(results[0].hookName).toBe('validate-args'); + expect(results[0].exitCode).toBe(0); + }); it('should pass exit code 0 allows agent to proceed', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }] + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }]; const context = { projectDir: '/test', params: {}, - taskState: {} - } + taskState: {}, + }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); - const results = await executeHooks(hooks, 'Start', context, mockExec) + const results = await executeHooks(hooks, 'Start', context, mockExec); // Exit code 0 allows the agent to proceed - expect(results[0].shouldProceed).toBe(true) - }) + expect(results[0].shouldProceed).toBe(true); + }); it('should pass exit code 1 passes stdout as warning and continues', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }] + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }]; const context = { projectDir: '/test', params: {}, - taskState: {} - } + taskState: {}, + }; const mockExec = vi.fn().mockResolvedValue({ exitCode: 1, stdout: 'Warning: deprecated usage', - stderr: '' - }) + stderr: '', + }); - const results = await executeHooks(hooks, 'Start', context, mockExec) + const results = await executeHooks(hooks, 'Start', context, mockExec); // Exit code 1 passes hook stdout to agent as warning and continues - expect(results[0].exitCode).toBe(1) - expect(results[0].stdout).toBe('Warning: deprecated usage') - expect(results[0].shouldProceed).toBe(true) - }) + expect(results[0].exitCode).toBe(1); + expect(results[0].stdout).toBe('Warning: deprecated usage'); + expect(results[0].shouldProceed).toBe(true); + }); it('should pass exit code 2 halts agent and marks UoW as failed', async () => { - const hooks = [{ name: 'validate-args', scriptPath: '/test.mjs', timeout: 30000 }] + const hooks = [{ name: 'validate-args', scriptPath: '/test.mjs', timeout: 30000 }]; const context = { projectDir: '/test', params: {}, - taskState: {} - } + taskState: {}, + }; const mockExec = vi.fn().mockResolvedValue({ exitCode: 2, stdout: '', - stderr: 'Validation failed' - }) + stderr: 'Validation failed', + }); - const results = await executeHooks(hooks, 'Start', context, mockExec) + const results = await executeHooks(hooks, 'Start', context, mockExec); // Exit code 2 halts the agent, marks UoW as failed - expect(results[0].exitCode).toBe(2) - expect(results[0].shouldProceed).toBe(false) - expect(results[0].fatal).toBe(true) - }) - }) + expect(results[0].exitCode).toBe(2); + expect(results[0].shouldProceed).toBe(false); + expect(results[0].fatal).toBe(true); + }); + }); describe('Hook stdin format', () => { it('should receive JSON with projectDir, params, taskState', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }] + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }]; const context = { projectDir: '/test/project', params: { language: 'python' }, - taskState: { language: { name: 'python' } } - } + taskState: { language: { name: 'python' } }, + }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); - await executeHooks(hooks, 'Start', context, mockExec) + await executeHooks(hooks, 'Start', context, mockExec); // Hook receives JSON containing: projectDir, params, taskState expect(mockExec).toHaveBeenCalledWith( expect.objectContaining({ - stdin: expect.stringContaining('projectDir') + stdin: expect.stringContaining('projectDir'), }) - ) - }) + ); + }); it('should NOT include rendered prompt in stdin', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }] + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }]; const context = { projectDir: '/test', params: {}, - taskState: {} - } + taskState: {}, + }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); - await executeHooks(hooks, 'Start', context, mockExec) + await executeHooks(hooks, 'Start', context, mockExec); // The rendered prompt is NOT present in stdin - const callArgs = mockExec.mock.calls[0] - const stdin = callArgs[0].stdin - expect(stdin).not.toContain('prompt') - }) - }) + const callArgs = mockExec.mock.calls[0]; + const stdin = callArgs[0].stdin; + expect(stdin).not.toContain('prompt'); + }); + }); describe('Hook timeout behavior', () => { it('should use default timeout of 300000ms when not configured', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs' }] // no timeout + const hooks = [{ name: 'test', scriptPath: '/test.mjs' }]; // no timeout const context = { projectDir: '/test', params: {}, - taskState: {} - } + taskState: {}, + }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); - await executeHooks(hooks, 'Start', context, mockExec) + await executeHooks(hooks, 'Start', context, mockExec); // Default timeout of 300000ms (5 minutes) is applied expect(mockExec).toHaveBeenCalledWith( expect.objectContaining({ - timeout: 300000 + timeout: 300000, }) - ) - }) + ); + }); it('should use configured timeout when specified', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 120000 }] + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 120000 }]; const context = { projectDir: '/test', params: {}, - taskState: {} - } + taskState: {}, + }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); - await executeHooks(hooks, 'Start', context, mockExec) + await executeHooks(hooks, 'Start', context, mockExec); expect(mockExec).toHaveBeenCalledWith( expect.objectContaining({ - timeout: 120000 + timeout: 120000, }) - ) - }) + ); + }); it('should treat timeout as exit code 2', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 100 }] + const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 100 }]; const context = { projectDir: '/test', params: {}, - taskState: {} - } + taskState: {}, + }; - const mockExec = vi.fn().mockRejectedValue(new Error('Timeout')) + const mockExec = vi.fn().mockRejectedValue(new Error('Timeout')); - const results = await executeHooks(hooks, 'Start', context, mockExec) + const results = await executeHooks(hooks, 'Start', context, mockExec); // Hook execution is terminated and treated as exit code 2 - expect(results[0].fatal).toBe(true) - expect(results[0].timedOut).toBe(true) - }) - }) + expect(results[0].fatal).toBe(true); + expect(results[0].timedOut).toBe(true); + }); + }); describe('Stop hooks execution', () => { it('should execute Stop hooks after agent finishes', async () => { - const hooks = [ - { name: 'check-new-tests', scriptPath: '/check.mjs', timeout: 30000 } - ] + const hooks = [{ name: 'check-new-tests', scriptPath: '/check.mjs', timeout: 30000 }]; const context = { projectDir: '/test', params: {}, - taskState: {} - } + taskState: {}, + }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); - const results = await executeHooks(hooks, 'Stop', context, mockExec) + const results = await executeHooks(hooks, 'Stop', context, mockExec); - expect(results).toHaveLength(1) - expect(results[0].hookName).toBe('check-new-tests') - }) + expect(results).toHaveLength(1); + expect(results[0].hookName).toBe('check-new-tests'); + }); it('should trigger follow-up on exit code 1', async () => { - const hooks = [{ name: 'lint', scriptPath: '/lint.mjs', timeout: 30000 }] + const hooks = [{ name: 'lint', scriptPath: '/lint.mjs', timeout: 30000 }]; const context = { projectDir: '/test', params: {}, - taskState: {} - } + taskState: {}, + }; const mockExec = vi.fn().mockResolvedValue({ exitCode: 1, stdout: 'Lint errors found', - stderr: '' - }) + stderr: '', + }); - const results = await executeHooks(hooks, 'Stop', context, mockExec) + const results = await executeHooks(hooks, 'Stop', context, mockExec); // Exit code 1 returns stdout to agent for remediation (follow-up loop) - expect(results[0].needsFollowUp).toBe(true) - expect(results[0].stdout).toBe('Lint errors found') - }) - }) -}) + expect(results[0].needsFollowUp).toBe(true); + expect(results[0].stdout).toBe('Lint errors found'); + }); + }); +}); diff --git a/orchestrator/packages/core/src/hook-executor.ts b/orchestrator/packages/core/src/hook-executor.ts index cbbdcbf..d21ad51 100644 --- a/orchestrator/packages/core/src/hook-executor.ts +++ b/orchestrator/packages/core/src/hook-executor.ts @@ -1,28 +1,30 @@ -import { HookSpec } from './types.js' +import { HookSpec } from './types.js'; export type HookExecutionContext = { - projectDir: string - params: Record - taskState: Record -} + projectDir: string; + params: Record; + taskState: Record; +}; export type HookResult = { - hookName: string - exitCode: number - stdout: string - stderr: string - durationMs: number - shouldProceed: boolean - fatal: boolean - needsFollowUp: boolean - timedOut: boolean -} + hookName: string; + exitCode: number; + stdout: string; + stderr: string; + durationMs: number; + shouldProceed: boolean; + fatal: boolean; + needsFollowUp: boolean; + timedOut: boolean; +}; -type HookExecFunction = ( - opts: { scriptPath: string; stdin: string; timeout: number } -) => Promise<{ exitCode: number; stdout: string; stderr: string }> +type HookExecFunction = (opts: { + scriptPath: string; + stdin: string; + timeout: number; +}) => Promise<{ exitCode: number; stdout: string; stderr: string }>; -const DEFAULT_HOOK_TIMEOUT = 300000 // 5 minutes in ms +const DEFAULT_HOOK_TIMEOUT = 300000; // 5 minutes in ms /** * Execute hooks for a lifecycle phase (Start or Stop). @@ -35,35 +37,35 @@ export const executeHooks = async ( context: HookExecutionContext, execFn: HookExecFunction ): Promise => { - const results: HookResult[] = [] + const results: HookResult[] = []; for (const hook of hooks) { - const startTime = Date.now() - const timeout = hook.timeout ?? DEFAULT_HOOK_TIMEOUT + const startTime = Date.now(); + const timeout = hook.timeout ?? DEFAULT_HOOK_TIMEOUT; // FR-10: stdin format - projectDir, params, taskState (no rendered prompt) const stdin = JSON.stringify({ projectDir: context.projectDir, params: context.params, - taskState: context.taskState - }) + taskState: context.taskState, + }); try { const { exitCode, stdout, stderr } = await execFn({ scriptPath: hook.scriptPath, stdin, - timeout - }) + timeout, + }); - const durationMs = Date.now() - startTime + const durationMs = Date.now() - startTime; // FR-10: Exit codes // 0 = Success, proceed // 1 = Recoverable error, pass stdout as context, continue // 2 = Fatal error, halt, mark UoW as failed - const fatal = exitCode === 2 - const shouldProceed = exitCode !== 2 - const needsFollowUp = lifecycle === 'Stop' && exitCode === 1 + const fatal = exitCode === 2; + const shouldProceed = exitCode !== 2; + const needsFollowUp = lifecycle === 'Stop' && exitCode === 1; results.push({ hookName: hook.name, @@ -74,15 +76,15 @@ export const executeHooks = async ( shouldProceed, fatal, needsFollowUp, - timedOut: false - }) + timedOut: false, + }); // If hook returned fatal error, stop processing further hooks if (fatal) { - break + break; } } catch (error) { - const durationMs = Date.now() - startTime + const durationMs = Date.now() - startTime; // Hook execution exception - treat as fatal (exit code 2) results.push({ @@ -94,13 +96,13 @@ export const executeHooks = async ( shouldProceed: false, fatal: true, needsFollowUp: false, - timedOut: true - }) + timedOut: true, + }); // Stop processing further hooks on timeout/error - break + break; } } - return results -} + return results; +}; diff --git a/orchestrator/packages/core/src/index.ts b/orchestrator/packages/core/src/index.ts index 3661a16..3bb8809 100644 --- a/orchestrator/packages/core/src/index.ts +++ b/orchestrator/packages/core/src/index.ts @@ -1,7 +1,7 @@ -export * from './types.js' -export * from './agent-parser.js' -export * from './validator.js' -export * from './hook-executor.js' -export * from './handlebars-renderer.js' -export * from './orchestrator.js' -export * from './repo-installer.js' +export * from './types.js'; +export * from './agent-parser.js'; +export * from './validator.js'; +export * from './hook-executor.js'; +export * from './handlebars-renderer.js'; +export * from './orchestrator.js'; +export * from './repo-installer.js'; diff --git a/orchestrator/packages/core/src/orchestrator.spec.ts b/orchestrator/packages/core/src/orchestrator.spec.ts index 804f6af..df6b289 100644 --- a/orchestrator/packages/core/src/orchestrator.spec.ts +++ b/orchestrator/packages/core/src/orchestrator.spec.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, vi } from 'vitest' -import { orchestrate } from './orchestrator.js' -import type { AgentDefinition } from './types.js' -import type { HookExecutionContext } from './hook-executor.js' -import type { Task, TaskSource } from '@orchestrator/task-source' -import type { Engine, EngineResult } from '@orchestrator/engine' +import { describe, it, expect, vi } from 'vitest'; +import { orchestrate } from './orchestrator.js'; +import type { AgentDefinition } from './types.js'; +import type { HookExecutionContext } from './hook-executor.js'; +import type { Task, TaskSource } from '@orchestrator/task-source'; +import type { Engine, EngineResult } from '@orchestrator/engine'; describe('Orchestrator', () => { describe('task execution lifecycle', () => { @@ -13,8 +13,8 @@ describe('Orchestrator', () => { id: 'task-1', agentId: 'agent-1', taskState: { language: 'Python' }, - metadata: { priority: 'high' } - } + metadata: { priority: 'high' }, + }; const agent: AgentDefinition = { name: 'reviewer', @@ -23,15 +23,17 @@ describe('Orchestrator', () => { toolClasses: [], template: { parameters: { language: { type: 'string' } } }, hooks: { Start: [], Stop: [] }, - promptBody: 'Review the code.' - } + promptBody: 'Review the code.', + }; const mockTaskSource: TaskSource = { - watchTasks: async function* () { yield task }, + watchTasks: async function* () { + yield task; + }, completeTask: vi.fn().mockResolvedValue(undefined), failTask: vi.fn().mockResolvedValue(undefined), - setState: vi.fn().mockResolvedValue(undefined) - } + setState: vi.fn().mockResolvedValue(undefined), + }; const mockEngine: Engine = { execute: vi.fn().mockResolvedValue({ @@ -45,10 +47,10 @@ describe('Orchestrator', () => { cacheReadTokens: 100, cacheCreationTokens: 50, totalCostUsd: 0.05, - numTurns: 3 - } - }) - } + numTurns: 3, + }, + }), + }; // When orchestrating const result = await orchestrate({ @@ -56,14 +58,14 @@ describe('Orchestrator', () => { agent, taskSource: mockTaskSource, engine: mockEngine, - projectDir: '/test/project' - }) + projectDir: '/test/project', + }); // Then validation passes, hooks execute, engine runs, task completes - expect(result.outcome).toBe('completed') - expect(mockEngine.execute).toHaveBeenCalled() - expect(mockTaskSource.completeTask).toHaveBeenCalledWith('task-1') - }) + expect(result.outcome).toBe('completed'); + expect(mockEngine.execute).toHaveBeenCalled(); + expect(mockTaskSource.completeTask).toHaveBeenCalledWith('task-1'); + }); it('should fail task when taskState validation fails', async () => { // Given a task with invalid taskState @@ -71,8 +73,8 @@ describe('Orchestrator', () => { id: 'task-2', agentId: 'agent-1', taskState: {}, // Missing required 'language' parameter - metadata: {} - } + metadata: {}, + }; const agent: AgentDefinition = { name: 'reviewer', @@ -81,24 +83,26 @@ describe('Orchestrator', () => { toolClasses: [], template: { parameters: { language: { type: 'string' } } }, hooks: { Start: [], Stop: [] }, - promptBody: 'Review the code.' - } + promptBody: 'Review the code.', + }; const mockTaskSource: TaskSource = { - watchTasks: async function* () { yield task }, + watchTasks: async function* () { + yield task; + }, completeTask: vi.fn().mockResolvedValue(undefined), failTask: vi.fn().mockResolvedValue(undefined), - setState: vi.fn().mockResolvedValue(undefined) - } + setState: vi.fn().mockResolvedValue(undefined), + }; const mockEngine: Engine = { execute: vi.fn().mockResolvedValue({ success: true, skipFulfill: false, lastMessage: 'Done', - stats: null - }) - } + stats: null, + }), + }; // When orchestrating const result = await orchestrate({ @@ -106,22 +110,25 @@ describe('Orchestrator', () => { agent, taskSource: mockTaskSource, engine: mockEngine, - projectDir: '/test/project' - }) + projectDir: '/test/project', + }); // Then task fails, engine not called - expect(result.outcome).toBe('failed') - expect(mockTaskSource.failTask).toHaveBeenCalledWith('task-2', expect.stringContaining('language')) - expect(mockEngine.execute).not.toHaveBeenCalled() - }) + expect(result.outcome).toBe('failed'); + expect(mockTaskSource.failTask).toHaveBeenCalledWith( + 'task-2', + expect.stringContaining('language') + ); + expect(mockEngine.execute).not.toHaveBeenCalled(); + }); it('should pass setState callback to engine', async () => { const task: Task = { id: 'task-1', agentId: 'agent-1', taskState: { step: 1 }, - metadata: {} - } + metadata: {}, + }; const agent: AgentDefinition = { name: 'test', @@ -130,53 +137,55 @@ describe('Orchestrator', () => { toolClasses: [], template: { parameters: {} }, hooks: { Start: [], Stop: [] }, - promptBody: 'Test' - } + promptBody: 'Test', + }; const mockTaskSource: TaskSource = { - watchTasks: async function* () { yield task }, + watchTasks: async function* () { + yield task; + }, completeTask: vi.fn().mockResolvedValue(undefined), failTask: vi.fn().mockResolvedValue(undefined), - setState: vi.fn().mockResolvedValue(undefined) - } + setState: vi.fn().mockResolvedValue(undefined), + }; - let capturedSetState: ((state: Record) => Promise) | null = null + let capturedSetState: ((state: Record) => Promise) | null = null; const mockEngine: Engine = { execute: vi.fn().mockImplementation(async (context) => { - capturedSetState = context.setState + capturedSetState = context.setState; return { success: true, skipFulfill: false, lastMessage: 'Done', - stats: null - } - }) - } + stats: null, + }; + }), + }; await orchestrate({ task, agent, taskSource: mockTaskSource, engine: mockEngine, - projectDir: '/test/project' - }) + projectDir: '/test/project', + }); // Engine receives setState callback - expect(capturedSetState).toBeDefined() + expect(capturedSetState).toBeDefined(); // Calling setState persists to task source - await capturedSetState!({ step: 2 }) - expect(mockTaskSource.setState).toHaveBeenCalledWith('task-1', { step: 2 }) - }) + await capturedSetState!({ step: 2 }); + expect(mockTaskSource.setState).toHaveBeenCalledWith('task-1', { step: 2 }); + }); it('should fail task when Start hook returns fatal error', async () => { const task: Task = { id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - } + metadata: {}, + }; const agent: AgentDefinition = { name: 'test', @@ -186,32 +195,34 @@ describe('Orchestrator', () => { template: { parameters: {} }, hooks: { Start: [{ name: 'fatal-hook', scriptPath: '/fatal.mjs', timeout: 30000 }], - Stop: [] + Stop: [], }, - promptBody: 'Test' - } + promptBody: 'Test', + }; const mockTaskSource: TaskSource = { - watchTasks: async function* () { yield task }, + watchTasks: async function* () { + yield task; + }, completeTask: vi.fn().mockResolvedValue(undefined), failTask: vi.fn().mockResolvedValue(undefined), - setState: vi.fn().mockResolvedValue(undefined) - } + setState: vi.fn().mockResolvedValue(undefined), + }; const mockEngine: Engine = { execute: vi.fn().mockResolvedValue({ success: true, skipFulfill: false, lastMessage: 'Done', - stats: null - }) - } + stats: null, + }), + }; const mockExec = vi.fn().mockResolvedValue({ exitCode: 2, // Fatal stdout: '', - stderr: 'Fatal error' - }) + stderr: 'Fatal error', + }); const result = await orchestrate({ task, @@ -219,21 +230,24 @@ describe('Orchestrator', () => { taskSource: mockTaskSource, engine: mockEngine, projectDir: '/test/project', - hookExec: mockExec - }) + hookExec: mockExec, + }); - expect(result.outcome).toBe('failed') - expect(mockTaskSource.failTask).toHaveBeenCalledWith('task-1', expect.stringContaining('fatal-hook')) - expect(mockEngine.execute).not.toHaveBeenCalled() - }) + expect(result.outcome).toBe('failed'); + expect(mockTaskSource.failTask).toHaveBeenCalledWith( + 'task-1', + expect.stringContaining('fatal-hook') + ); + expect(mockEngine.execute).not.toHaveBeenCalled(); + }); it('should fail task when Stop hook returns fatal error', async () => { const task: Task = { id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - } + metadata: {}, + }; const agent: AgentDefinition = { name: 'test', @@ -243,32 +257,34 @@ describe('Orchestrator', () => { template: { parameters: {} }, hooks: { Start: [], - Stop: [{ name: 'fatal-hook', scriptPath: '/fatal.mjs', timeout: 30000 }] + Stop: [{ name: 'fatal-hook', scriptPath: '/fatal.mjs', timeout: 30000 }], }, - promptBody: 'Test' - } + promptBody: 'Test', + }; const mockTaskSource: TaskSource = { - watchTasks: async function* () { yield task }, + watchTasks: async function* () { + yield task; + }, completeTask: vi.fn().mockResolvedValue(undefined), failTask: vi.fn().mockResolvedValue(undefined), - setState: vi.fn().mockResolvedValue(undefined) - } + setState: vi.fn().mockResolvedValue(undefined), + }; const mockEngine: Engine = { execute: vi.fn().mockResolvedValue({ success: true, skipFulfill: false, lastMessage: 'Done', - stats: null - }) - } + stats: null, + }), + }; const mockExec = vi.fn().mockResolvedValue({ exitCode: 2, // Fatal stdout: '', - stderr: 'Fatal error' - }) + stderr: 'Fatal error', + }); const result = await orchestrate({ task, @@ -276,20 +292,23 @@ describe('Orchestrator', () => { taskSource: mockTaskSource, engine: mockEngine, projectDir: '/test/project', - hookExec: mockExec - }) + hookExec: mockExec, + }); - expect(result.outcome).toBe('failed') - expect(mockTaskSource.failTask).toHaveBeenCalledWith('task-1', expect.stringContaining('fatal-hook')) - }) + expect(result.outcome).toBe('failed'); + expect(mockTaskSource.failTask).toHaveBeenCalledWith( + 'task-1', + expect.stringContaining('fatal-hook') + ); + }); it('should support follow-up loop when Stop hook returns exit code 1', async () => { const task: Task = { id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - } + metadata: {}, + }; const agent: AgentDefinition = { name: 'test', @@ -299,37 +318,41 @@ describe('Orchestrator', () => { template: { parameters: {} }, hooks: { Start: [], - Stop: [{ name: 'lint', scriptPath: '/lint.mjs', timeout: 30000 }] + Stop: [{ name: 'lint', scriptPath: '/lint.mjs', timeout: 30000 }], }, - promptBody: 'Test' - } + promptBody: 'Test', + }; const mockTaskSource: TaskSource = { - watchTasks: async function* () { yield task }, + watchTasks: async function* () { + yield task; + }, completeTask: vi.fn().mockResolvedValue(undefined), failTask: vi.fn().mockResolvedValue(undefined), - setState: vi.fn().mockResolvedValue(undefined) - } + setState: vi.fn().mockResolvedValue(undefined), + }; const mockEngine: Engine = { - execute: vi.fn() + execute: vi + .fn() .mockResolvedValueOnce({ success: true, skipFulfill: false, lastMessage: 'First run', - stats: null + stats: null, }) .mockResolvedValueOnce({ success: true, skipFulfill: false, lastMessage: 'Second run', - stats: null - }) - } + stats: null, + }), + }; - const mockExec = vi.fn() + const mockExec = vi + .fn() .mockResolvedValueOnce({ exitCode: 1, stdout: 'Fix lint issues', stderr: '' }) - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); const result = await orchestrate({ task, @@ -337,11 +360,11 @@ describe('Orchestrator', () => { taskSource: mockTaskSource, engine: mockEngine, projectDir: '/test/project', - hookExec: mockExec - }) - - expect(result.outcome).toBe('completed') - expect(mockEngine.execute).toHaveBeenCalledTimes(2) - }) - }) -}) + hookExec: mockExec, + }); + + expect(result.outcome).toBe('completed'); + expect(mockEngine.execute).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/orchestrator/packages/core/src/orchestrator.ts b/orchestrator/packages/core/src/orchestrator.ts index ce781bc..4ad7435 100644 --- a/orchestrator/packages/core/src/orchestrator.ts +++ b/orchestrator/packages/core/src/orchestrator.ts @@ -1,75 +1,75 @@ -import type { AgentDefinition } from './types.js' -import { validateTaskState } from './validator.js' -import { executeHooks, type HookExecutionContext } from './hook-executor.js' -import { renderPrompt } from './handlebars-renderer.js' -import type { Task, TaskSource } from '@orchestrator/task-source' -import type { Engine, EngineContext, EngineResult } from '@orchestrator/engine' +import type { AgentDefinition } from './types.js'; +import { validateTaskState } from './validator.js'; +import { executeHooks, type HookExecutionContext } from './hook-executor.js'; +import { renderPrompt } from './handlebars-renderer.js'; +import type { Task, TaskSource } from '@orchestrator/task-source'; +import type { Engine, EngineContext, EngineResult } from '@orchestrator/engine'; type OrchestrationResult = { - outcome: 'completed' | 'failed' | 'halted' + outcome: 'completed' | 'failed' | 'halted'; telemetry?: { - durationMs: number - inputTokens: number - outputTokens: number - cacheReadTokens: number - cacheCreationTokens: number - totalCostUsd: number - numTurns: number - } - error?: string -} - -type HookExecFn = ( - opts: { scriptPath: string; stdin: string; timeout: number } -) => Promise<{ exitCode: number; stdout: string; stderr: string }> + durationMs: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalCostUsd: number; + numTurns: number; + }; + error?: string; +}; + +type HookExecFn = (opts: { + scriptPath: string; + stdin: string; + timeout: number; +}) => Promise<{ exitCode: number; stdout: string; stderr: string }>; type OrchestrateOptions = { - task: Task - agent: AgentDefinition - taskSource: TaskSource - engine: Engine - projectDir: string - hookExec?: HookExecFn -} + task: Task; + agent: AgentDefinition; + taskSource: TaskSource; + engine: Engine; + projectDir: string; + hookExec?: HookExecFn; +}; /** * Execute the full orchestration lifecycle for a task. * FR-14: Orchestration Lifecycle * US-3: Agent Operator - Dispatch agent on task */ -export const orchestrate = async ( - options: OrchestrateOptions -): Promise => { - const { task, agent, taskSource, engine, projectDir, hookExec } = options +export const orchestrate = async (options: OrchestrateOptions): Promise => { + const { task, agent, taskSource, engine, projectDir, hookExec } = options; - const startTime = Date.now() - let totalTelemetry: EngineResult['stats'] = null - let numTurns = 0 + const startTime = Date.now(); + let totalTelemetry: EngineResult['stats'] = null; + let numTurns = 0; // Step 1: Validate taskState against agent parameter schema - const validation = validateTaskState(task.taskState, agent.template.parameters) + const validation = validateTaskState(task.taskState, agent.template.parameters); if (!validation.valid) { - await taskSource.failTask(task.id, validation.errors.join('; ')) - return { outcome: 'failed', error: validation.errors.join('; ') } + await taskSource.failTask(task.id, validation.errors.join('; ')); + return { outcome: 'failed', error: validation.errors.join('; ') }; } // Step 2: Execute pre-task hooks const hookContext: HookExecutionContext = { projectDir, params: task.taskState, - taskState: task.taskState - } + taskState: task.taskState, + }; - const defaultHookExec: HookExecFn = async () => ({ exitCode: 0, stdout: '', stderr: '' }) - const execFn = hookExec ?? defaultHookExec + const defaultHookExec: HookExecFn = async () => ({ exitCode: 0, stdout: '', stderr: '' }); + const execFn = hookExec ?? defaultHookExec; - const startHookResults = await executeHooks(agent.hooks.Start, 'Start', hookContext, execFn) + const startHookResults = await executeHooks(agent.hooks.Start, 'Start', hookContext, execFn); for (const hook of startHookResults) { if (hook.fatal) { - await taskSource.failTask(task.id, `Start hook ${hook.hookName} failed: ${hook.stderr}`) - return { outcome: 'failed', error: hook.stderr } + await taskSource.failTask(task.id, `Start hook ${hook.hookName} failed: ${hook.stderr}`); + return { outcome: 'failed', error: hook.stderr }; } } @@ -81,95 +81,97 @@ export const orchestrate = async ( taskState: task.taskState, metadata: task.metadata, setState: (newState: Record) => taskSource.setState(task.id, newState), - verbose: false - } + verbose: false, + }; // Main execution loop (handles follow-ups) - let maxFollowUps = 10 - let lastMessage = '' + let maxFollowUps = 10; + let lastMessage = ''; while (maxFollowUps-- > 0) { - numTurns++ + numTurns++; - const engineResult: EngineResult = await engine.execute(engineContext) + const engineResult: EngineResult = await engine.execute(engineContext); if (engineResult.stats) { if (!totalTelemetry) { - totalTelemetry = { ...engineResult.stats } + totalTelemetry = { ...engineResult.stats }; } else { - totalTelemetry.durationMs += engineResult.stats.durationMs - totalTelemetry.inputTokens += engineResult.stats.inputTokens - totalTelemetry.outputTokens += engineResult.stats.outputTokens - totalTelemetry.cacheReadTokens += engineResult.stats.cacheReadTokens - totalTelemetry.cacheCreationTokens += engineResult.stats.cacheCreationTokens - totalTelemetry.totalCostUsd += engineResult.stats.totalCostUsd - totalTelemetry.numTurns += engineResult.stats.numTurns + totalTelemetry.durationMs += engineResult.stats.durationMs; + totalTelemetry.inputTokens += engineResult.stats.inputTokens; + totalTelemetry.outputTokens += engineResult.stats.outputTokens; + totalTelemetry.cacheReadTokens += engineResult.stats.cacheReadTokens; + totalTelemetry.cacheCreationTokens += engineResult.stats.cacheCreationTokens; + totalTelemetry.totalCostUsd += engineResult.stats.totalCostUsd; + totalTelemetry.numTurns += engineResult.stats.numTurns; } } - lastMessage = engineResult.lastMessage || lastMessage + lastMessage = engineResult.lastMessage || lastMessage; // Step 4: Execute post-task hooks - const stopHookResults = await executeHooks(agent.hooks.Stop, 'Stop', hookContext, execFn) + const stopHookResults = await executeHooks(agent.hooks.Stop, 'Stop', hookContext, execFn); - let needsFollowUp = false - let followUpMessage = '' + let needsFollowUp = false; + let followUpMessage = ''; for (const hook of stopHookResults) { if (hook.needsFollowUp) { - needsFollowUp = true - followUpMessage = hook.stdout - break + needsFollowUp = true; + followUpMessage = hook.stdout; + break; } if (hook.fatal) { - await taskSource.failTask(task.id, `Stop hook ${hook.hookName} failed: ${hook.stderr}`) - return { outcome: 'failed', error: hook.stderr } + await taskSource.failTask(task.id, `Stop hook ${hook.hookName} failed: ${hook.stderr}`); + return { outcome: 'failed', error: hook.stderr }; } } if (!needsFollowUp) { - break + break; } if (engine.sendFollowUp) { - const followUpResult = await engine.sendFollowUp(followUpMessage) + const followUpResult = await engine.sendFollowUp(followUpMessage); if (followUpResult.stats) { if (!totalTelemetry) { - totalTelemetry = { ...followUpResult.stats } + totalTelemetry = { ...followUpResult.stats }; } else { - totalTelemetry.durationMs += followUpResult.stats.durationMs - totalTelemetry.inputTokens += followUpResult.stats.inputTokens - totalTelemetry.outputTokens += followUpResult.stats.outputTokens - totalTelemetry.cacheReadTokens += followUpResult.stats.cacheReadTokens - totalTelemetry.cacheCreationTokens += followUpResult.stats.cacheCreationTokens - totalTelemetry.totalCostUsd += followUpResult.stats.totalCostUsd - totalTelemetry.numTurns += followUpResult.stats.numTurns + totalTelemetry.durationMs += followUpResult.stats.durationMs; + totalTelemetry.inputTokens += followUpResult.stats.inputTokens; + totalTelemetry.outputTokens += followUpResult.stats.outputTokens; + totalTelemetry.cacheReadTokens += followUpResult.stats.cacheReadTokens; + totalTelemetry.cacheCreationTokens += followUpResult.stats.cacheCreationTokens; + totalTelemetry.totalCostUsd += followUpResult.stats.totalCostUsd; + totalTelemetry.numTurns += followUpResult.stats.numTurns; } } - numTurns++ + numTurns++; } } // Step 5: Report success - await taskSource.completeTask(task.id) + await taskSource.completeTask(task.id); - const durationMs = Date.now() - startTime + const durationMs = Date.now() - startTime; return { outcome: 'completed', - telemetry: totalTelemetry ? { - ...totalTelemetry, - durationMs, - numTurns - } : { - durationMs, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - totalCostUsd: 0, - numTurns - } - } -} + telemetry: totalTelemetry + ? { + ...totalTelemetry, + durationMs, + numTurns, + } + : { + durationMs, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + numTurns, + }, + }; +}; diff --git a/orchestrator/packages/core/src/repo-installer.spec.ts b/orchestrator/packages/core/src/repo-installer.spec.ts index 474dbd0..adb718b 100644 --- a/orchestrator/packages/core/src/repo-installer.spec.ts +++ b/orchestrator/packages/core/src/repo-installer.spec.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, vi } from 'vitest' -import { installRepoScripts } from './repo-installer.js' -import { mkdir, writeFile, readFile, stat } from 'node:fs/promises' +import { describe, it, expect, vi } from 'vitest'; +import { installRepoScripts } from './repo-installer.js'; +import { mkdir, writeFile, readFile, stat } from 'node:fs/promises'; -vi.mock('node:fs/promises') +vi.mock('node:fs/promises'); describe('Repo Script Installer - US-5', () => { describe('First run installs repo scripts into working repository', () => { @@ -13,37 +13,45 @@ describe('Repo Script Installer - US-5', () => { name: 'test-agent', hooks: { Start: [ - { name: 'validate-args', scriptPath: 'hooks/Start.d/validate-args.mjs', isRepoScript: true } + { + name: 'validate-args', + scriptPath: 'hooks/Start.d/validate-args.mjs', + isRepoScript: true, + }, ], Stop: [ - { name: 'check-new-tests', scriptPath: 'hooks/Stop.d/check-new-tests.mjs', isRepoScript: true } - ] - } - } - ] + { + name: 'check-new-tests', + scriptPath: 'hooks/Stop.d/check-new-tests.mjs', + isRepoScript: true, + }, + ], + }, + }, + ]; - const mockScriptContent = 'export default async () => { return { exitCode: 0 } }' + const mockScriptContent = 'export default async () => { return { exitCode: 0 } }'; - vi.mocked(readFile).mockResolvedValue(mockScriptContent) - vi.mocked(stat).mockRejectedValue(new Error('File not found')) - vi.mocked(mkdir).mockResolvedValue(undefined) - vi.mocked(writeFile).mockResolvedValue(undefined) + vi.mocked(readFile).mockResolvedValue(mockScriptContent); + vi.mocked(stat).mockRejectedValue(new Error('File not found')); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); // When the orchestrator runs against the working repository for the first time - await installRepoScripts('/test/project', agents, '/orchestrator/packages') + await installRepoScripts('/test/project', agents, '/orchestrator/packages'); // Then each repo script is hard-copied to .ai//hooks/.d/.mjs expect(writeFile).toHaveBeenCalledWith( '/test/project/.ai/test-agent/hooks/Start.d/validate-args.mjs', mockScriptContent, 'utf-8' - ) + ); expect(writeFile).toHaveBeenCalledWith( '/test/project/.ai/test-agent/hooks/Stop.d/check-new-tests.mjs', mockScriptContent, 'utf-8' - ) - }) + ); + }); it('should create no symlinks - only hard copies', async () => { const agents = [ @@ -51,51 +59,53 @@ describe('Repo Script Installer - US-5', () => { name: 'test-agent', hooks: { Start: [{ name: 'test', scriptPath: 'hooks/Start.d/test.mjs', isRepoScript: true }], - Stop: [] - } - } - ] + Stop: [], + }, + }, + ]; - vi.mocked(readFile).mockResolvedValue('content') - vi.mocked(stat).mockRejectedValue(new Error('not found')) + vi.mocked(readFile).mockResolvedValue('content'); + vi.mocked(stat).mockRejectedValue(new Error('not found')); - await installRepoScripts('/test/project', agents, '/orchestrator/packages') + await installRepoScripts('/test/project', agents, '/orchestrator/packages'); // Verify copy was made (not symlink) - expect(writeFile).toHaveBeenCalled() + expect(writeFile).toHaveBeenCalled(); expect(writeFile).toHaveBeenCalledWith( expect.stringContaining('.ai'), expect.any(String), 'utf-8' - ) - }) + ); + }); it('should log each installed path', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const agents = [ { name: 'test-agent', hooks: { - Start: [{ name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }], - Stop: [] - } - } - ] + Start: [ + { name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }, + ], + Stop: [], + }, + }, + ]; - vi.mocked(readFile).mockResolvedValue('content') - vi.mocked(stat).mockRejectedValue(new Error('not found')) + vi.mocked(readFile).mockResolvedValue('content'); + vi.mocked(stat).mockRejectedValue(new Error('not found')); - await installRepoScripts('/test/project', agents, '/orchestrator/packages') + await installRepoScripts('/test/project', agents, '/orchestrator/packages'); // Then the orchestrator logs each installed path expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('test-agent/hooks/Start.d/validate.mjs') - ) + ); - consoleSpy.mockRestore() - }) - }) + consoleSpy.mockRestore(); + }); + }); describe('Idempotent installation', () => { it('should not overwrite existing scripts', async () => { @@ -105,55 +115,57 @@ describe('Repo Script Installer - US-5', () => { { name: 'test-agent', hooks: { - Start: [{ name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }], - Stop: [] - } - } - ] + Start: [ + { name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }, + ], + Stop: [], + }, + }, + ]; - vi.mocked(readFile).mockResolvedValue('new content') - vi.mocked(stat).mockResolvedValue({ isFile: () => true } as any) + vi.mocked(readFile).mockResolvedValue('new content'); + vi.mocked(stat).mockResolvedValue({ isFile: () => true } as any); // Clear previous calls - vi.clearAllMocks() + vi.clearAllMocks(); // When the orchestrator runs again - await installRepoScripts('/test/project', agents, '/orchestrator/packages') + await installRepoScripts('/test/project', agents, '/orchestrator/packages'); // Then the existing script is not overwritten - expect(writeFile).not.toHaveBeenCalled() - }) + expect(writeFile).not.toHaveBeenCalled(); + }); it('should log that script is already present', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const agents = [ { name: 'test-agent', hooks: { - Start: [{ name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }], - Stop: [] - } - } - ] + Start: [ + { name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }, + ], + Stop: [], + }, + }, + ]; - vi.mocked(readFile).mockResolvedValue('content') - vi.mocked(stat).mockResolvedValue({ isFile: () => true } as any) + vi.mocked(readFile).mockResolvedValue('content'); + vi.mocked(stat).mockResolvedValue({ isFile: () => true } as any); - vi.clearAllMocks() + vi.clearAllMocks(); - await installRepoScripts('/test/project', agents, '/orchestrator/packages') + await installRepoScripts('/test/project', agents, '/orchestrator/packages'); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Already present:') - ) + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Already present:')); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('test-agent/hooks/Start.d/validate.mjs') - ) + ); - consoleSpy.mockRestore() - }) - }) + consoleSpy.mockRestore(); + }); + }); describe('Framework hooks not installed', () => { it('should skip hooks where isRepoScript is false', async () => { @@ -162,20 +174,24 @@ describe('Repo Script Installer - US-5', () => { name: 'test-agent', hooks: { Start: [ - { name: 'validate-args', scriptPath: 'hooks/Start.d/validate-args.mjs', isRepoScript: false } + { + name: 'validate-args', + scriptPath: 'hooks/Start.d/validate-args.mjs', + isRepoScript: false, + }, ], - Stop: [] - } - } - ] + Stop: [], + }, + }, + ]; - vi.clearAllMocks() + vi.clearAllMocks(); - await installRepoScripts('/test/project', agents, '/orchestrator/packages') + await installRepoScripts('/test/project', agents, '/orchestrator/packages'); // Framework hooks run from the orchestrator's packages/ context // They should not be installed to the working repo - expect(writeFile).not.toHaveBeenCalled() - }) - }) -}) + expect(writeFile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/orchestrator/packages/core/src/repo-installer.ts b/orchestrator/packages/core/src/repo-installer.ts index f33b242..949cd9d 100644 --- a/orchestrator/packages/core/src/repo-installer.ts +++ b/orchestrator/packages/core/src/repo-installer.ts @@ -1,13 +1,13 @@ -import { mkdir, writeFile, readFile, stat } from 'node:fs/promises' -import { join, resolve } from 'node:path' +import { mkdir, writeFile, readFile, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; export type AgentWithRepoScripts = { - name: string + name: string; hooks: { - Start: Array<{ name: string; scriptPath: string; isRepoScript: boolean }> - Stop: Array<{ name: string; scriptPath: string; isRepoScript: boolean }> - } -} + Start: Array<{ name: string; scriptPath: string; isRepoScript: boolean }>; + Stop: Array<{ name: string; scriptPath: string; isRepoScript: boolean }>; + }; +}; /** * Install repo scripts to the working repository. @@ -26,43 +26,43 @@ export const installRepoScripts = async ( ): Promise => { for (const agent of agents) { for (const lifecycle of ['Start', 'Stop'] as const) { - const hooks = lifecycle === 'Start' ? agent.hooks.Start : agent.hooks.Stop + const hooks = lifecycle === 'Start' ? agent.hooks.Start : agent.hooks.Stop; for (const hook of hooks) { // Skip framework hooks - only install repo scripts if (!hook.isRepoScript) { - continue + continue; } // FR-9: Hook execution order within each .d/ directory: alphabetical by filename // Target path: .ai//hooks/.d/.mjs - const targetDir = resolve(projectDir, '.ai', agent.name, 'hooks', `${lifecycle}.d`) - const targetPath = resolve(targetDir, `${hook.name}.mjs`) + const targetDir = resolve(projectDir, '.ai', agent.name, 'hooks', `${lifecycle}.d`); + const targetPath = resolve(targetDir, `${hook.name}.mjs`); // Check if script already exists (US-5: idempotency) try { - await stat(targetPath) - console.log(`Already present: ${targetPath}`) - continue + await stat(targetPath); + console.log(`Already present: ${targetPath}`); + continue; } catch { // File doesn't exist, proceed with installation } // FR-9: Repo scripts are provided in agent package at hooks/.d/.mjs // Source path: //hooks/.d/.mjs - const sourcePath = resolve(orchestratorPackagesPath, agent.name, hook.scriptPath) + const sourcePath = resolve(orchestratorPackagesPath, agent.name, hook.scriptPath); // Read the source script content - const content = await readFile(sourcePath, 'utf-8') + const content = await readFile(sourcePath, 'utf-8'); // Create target directory - await mkdir(targetDir, { recursive: true }) + await mkdir(targetDir, { recursive: true }); // FR-8: Repo scripts are hard-copied (never symlinked) - await writeFile(targetPath, content, 'utf-8') + await writeFile(targetPath, content, 'utf-8'); - console.log(`Installed: ${targetPath}`) + console.log(`Installed: ${targetPath}`); } } } -} +}; diff --git a/orchestrator/packages/core/src/types.ts b/orchestrator/packages/core/src/types.ts index 4b470c1..2395bd2 100644 --- a/orchestrator/packages/core/src/types.ts +++ b/orchestrator/packages/core/src/types.ts @@ -1,26 +1,26 @@ // FR-4: Agent Definition File types export type AgentDefinition = { - name: string - description: string - tools: string[] - toolClasses: string[] - template: Template - hooks: Hooks - promptBody: string -} + name: string; + description: string; + tools: string[]; + toolClasses: string[]; + template: Template; + hooks: Hooks; + promptBody: string; +}; export type Template = { - parameters: Record -} + parameters: Record; +}; export type Hooks = { - Start: HookSpec[] - Stop: HookSpec[] -} + Start: HookSpec[]; + Stop: HookSpec[]; +}; export type HookSpec = { - name: string - scriptPath: string - timeout?: number -} + name: string; + scriptPath: string; + timeout?: number; +}; diff --git a/orchestrator/packages/core/src/validator.spec.ts b/orchestrator/packages/core/src/validator.spec.ts index 394f426..cffb4d7 100644 --- a/orchestrator/packages/core/src/validator.spec.ts +++ b/orchestrator/packages/core/src/validator.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' -import { validateTaskState } from './validator.js' +import { describe, it, expect } from 'vitest'; +import { validateTaskState } from './validator.js'; describe('Template Parameters Validator - US-6', () => { describe('Required parameter validation', () => { @@ -8,62 +8,62 @@ describe('Template Parameters Validator - US-6', () => { const schema = { language: { name: 'string' }, testFramework: { name: 'string' }, - testStyle: { name: 'string' } - } + testStyle: { name: 'string' }, + }; // And a UoW whose taskState satisfies all required parameters const taskState = { language: { name: 'python', prompt: 'Write Python code' }, testFramework: { name: 'pytest', prompt: 'Use pytest' }, - testStyle: { name: 'gherkin', prompt: 'BDD style' } - } + testStyle: { name: 'gherkin', prompt: 'BDD style' }, + }; // When the orchestrator dispatches - const result = validateTaskState(taskState, schema) + const result = validateTaskState(taskState, schema); // Then validation passes - expect(result.valid).toBe(true) - expect(result.errors).toHaveLength(0) - }) + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); it('should fail when required parameter is missing', () => { // Given a UoW whose taskState is missing a required parameter const schema = { language: { name: 'string' }, - testFramework: { name: 'string' } - } + testFramework: { name: 'string' }, + }; const taskState = { - language: { name: 'python' } + language: { name: 'python' }, // testFramework is missing - } + }; // When validate-args runs - const result = validateTaskState(taskState, schema) + const result = validateTaskState(taskState, schema); // Then validation fails - expect(result.valid).toBe(false) - expect(result.errors).toContain('Missing required parameter: testFramework') - }) + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing required parameter: testFramework'); + }); it('should fail when required field is empty string', () => { // Given a UoW taskState where a required field is present but set to empty string const schema = { - language: { name: 'string' } - } + language: { name: 'string' }, + }; const taskState = { - language: { name: '' } - } + language: { name: '' }, + }; // When validate-args runs - const result = validateTaskState(taskState, schema) + const result = validateTaskState(taskState, schema); // Then validation fails as if the field were absent - expect(result.valid).toBe(false) - expect(result.errors).toContain('Missing required parameter: language.name') - }) - }) + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing required parameter: language.name'); + }); + }); describe('Optional parameter validation (ending with ?)', () => { it('should pass when optional parameter is absent', () => { @@ -71,86 +71,86 @@ describe('Template Parameters Validator - US-6', () => { const schema = { 'context?': { prDescription: 'string', - 'additionalNotes?': 'string' - } - } + 'additionalNotes?': 'string', + }, + }; // When taskState omits the optional parameter entirely - const taskState = {} + const taskState = {}; // Then no validation error is raised - const result = validateTaskState(taskState, schema) - expect(result.valid).toBe(true) - }) + const result = validateTaskState(taskState, schema); + expect(result.valid).toBe(true); + }); it('should pass when optional parameter is provided with required sub-fields', () => { // Given a template parameter declared as optional const schema = { 'context?': { prDescription: 'string', - 'additionalNotes?': 'string' - } - } + 'additionalNotes?': 'string', + }, + }; // When taskState provides that optional parameter const taskState = { context: { prDescription: 'Fix bug in auth', - additionalNotes: 'Urgent' - } - } + additionalNotes: 'Urgent', + }, + }; // Then all non-? sub-fields must be present and non-empty - const result = validateTaskState(taskState, schema) - expect(result.valid).toBe(true) - }) + const result = validateTaskState(taskState, schema); + expect(result.valid).toBe(true); + }); it('should fail when optional parameter is provided but missing required sub-field', () => { // Given a template parameter that is an optional object const schema = { 'context?': { prDescription: 'string', - 'additionalNotes?': 'string' - } - } + 'additionalNotes?': 'string', + }, + }; // And the UoW taskState provides that object // But the object is missing a required sub-field const taskState = { context: { // prDescription is missing (required) - additionalNotes: 'Some notes' - } - } + additionalNotes: 'Some notes', + }, + }; // When validate-args runs - const result = validateTaskState(taskState, schema) + const result = validateTaskState(taskState, schema); // Then validation fails identifying the missing sub-field by its dot-notation path - expect(result.valid).toBe(false) - expect(result.errors).toContain('Missing required parameter: context.prDescription') - }) - }) + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing required parameter: context.prDescription'); + }); + }); describe('Nested parameter validation', () => { it('should validate nested required parameters', () => { const schema = { language: { name: 'string', - version: 'string' - } - } + version: 'string', + }, + }; const taskState = { language: { name: 'python', // version is missing - } - } - - const result = validateTaskState(taskState, schema) - expect(result.valid).toBe(false) - expect(result.errors).toContain('Missing required parameter: language.version') - }) - }) -}) + }, + }; + + const result = validateTaskState(taskState, schema); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing required parameter: language.version'); + }); + }); +}); diff --git a/orchestrator/packages/core/src/validator.ts b/orchestrator/packages/core/src/validator.ts index cc0fd8b..53f2501 100644 --- a/orchestrator/packages/core/src/validator.ts +++ b/orchestrator/packages/core/src/validator.ts @@ -1,7 +1,7 @@ export type ValidationResult = { - valid: boolean - errors: string[] -} + valid: boolean; + errors: string[]; +}; /** * Validate taskState against template.parameters schema. @@ -12,75 +12,71 @@ export const validateTaskState = ( taskState: Record, schema: Record ): ValidationResult => { - const errors: string[] = [] + const errors: string[] = []; - const validateValue = ( - value: unknown, - schemaNode: unknown, - path: string - ): void => { + const validateValue = (value: unknown, schemaNode: unknown, path: string): void => { // If schemaNode is not an object, it's a scalar type hint - just check existence if (typeof schemaNode !== 'object' || schemaNode === null) { if (value === undefined || value === null || value === '') { - errors.push(`Missing required parameter: ${path}`) + errors.push(`Missing required parameter: ${path}`); } - return + return; } // Schema node is an object - validate recursively - const schemaObj = schemaNode as Record + const schemaObj = schemaNode as Record; // If value is missing/null/empty, check if path is optional if (value === undefined || value === null || value === '') { - return // Handled by parent check + return; // Handled by parent check } // Value exists - validate its structure if (typeof value !== 'object' || value === null) { // Value is scalar but schema expects object - already caught above - return + return; } - const valueObj = value as Record + const valueObj = value as Record; // Recursively validate each schema key for (const [key, subSchema] of Object.entries(schemaObj)) { - const isOptional = key.endsWith('?') - const baseKey = isOptional ? key.slice(0, -1) : key - const fullPath = path ? `${path}.${baseKey}` : baseKey + const isOptional = key.endsWith('?'); + const baseKey = isOptional ? key.slice(0, -1) : key; + const fullPath = path ? `${path}.${baseKey}` : baseKey; // Check if the key exists in value (with or without ? suffix) - const subValue = valueObj[baseKey] ?? valueObj[key] + const subValue = valueObj[baseKey] ?? valueObj[key]; if (subValue === undefined || subValue === null || subValue === '') { if (!isOptional) { - errors.push(`Missing required parameter: ${fullPath}`) + errors.push(`Missing required parameter: ${fullPath}`); } } else { - validateValue(subValue, subSchema, fullPath) + validateValue(subValue, subSchema, fullPath); } } - } + }; // Validate each top-level schema parameter for (const [key, schemaNode] of Object.entries(schema)) { - const isOptional = key.endsWith('?') - const baseKey = isOptional ? key.slice(0, -1) : key + const isOptional = key.endsWith('?'); + const baseKey = isOptional ? key.slice(0, -1) : key; // Check if parameter exists in taskState (with or without ? suffix) - const value = taskState[baseKey] ?? taskState[key] + const value = taskState[baseKey] ?? taskState[key]; if (value === undefined || value === null || value === '') { if (!isOptional) { - errors.push(`Missing required parameter: ${baseKey}`) + errors.push(`Missing required parameter: ${baseKey}`); } } else { - validateValue(value, schemaNode, baseKey) + validateValue(value, schemaNode, baseKey); } } return { valid: errors.length === 0, - errors - } -} + errors, + }; +}; diff --git a/orchestrator/packages/core/vite.config.ts b/orchestrator/packages/core/vite.config.ts index f904168..2006867 100644 --- a/orchestrator/packages/core/vite.config.ts +++ b/orchestrator/packages/core/vite.config.ts @@ -1,10 +1,10 @@ -import pkg from './package.json' +import pkg from './package.json'; // @ts-ignore -import tsconfig from './tsconfig.json' -import base from '../../vite.base' +import tsconfig from './tsconfig.json'; +import base from '../../vite.base'; export default base({ name: 'core', pkg, - tsconfig -}) \ No newline at end of file + tsconfig, +}); diff --git a/orchestrator/packages/engine/src/index.ts b/orchestrator/packages/engine/src/index.ts index 11613c8..62efe00 100644 --- a/orchestrator/packages/engine/src/index.ts +++ b/orchestrator/packages/engine/src/index.ts @@ -1,3 +1,3 @@ -export * from './types.js' -export * from './interface.js' -export * from './test-engine.js' +export * from './types.js'; +export * from './interface.js'; +export * from './test-engine.js'; diff --git a/orchestrator/packages/engine/src/interface.spec.ts b/orchestrator/packages/engine/src/interface.spec.ts index 704f68b..7c15685 100644 --- a/orchestrator/packages/engine/src/interface.spec.ts +++ b/orchestrator/packages/engine/src/interface.spec.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi } from 'vitest' -import { Engine } from './interface.js' -import { EngineContext, EngineResult } from './types.js' +import { describe, it, expect, vi } from 'vitest'; +import { Engine } from './interface.js'; +import { EngineContext, EngineResult } from './types.js'; describe('Engine Interface', () => { describe('FR-2: Engine Interface', () => { @@ -18,13 +18,13 @@ describe('Engine Interface', () => { cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0.01, - numTurns: 1 - } - } + numTurns: 1, + }, + }; } } - const engine = new MockEngine() + const engine = new MockEngine(); const context: EngineContext = { taskId: 'task-1', workingDir: '/test', @@ -32,13 +32,13 @@ describe('Engine Interface', () => { taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), - verbose: false - } + verbose: false, + }; - const result = await engine.execute(context) - expect(result.success).toBe(true) - expect(result.lastMessage).toContain('test-agent') - }) + const result = await engine.execute(context); + expect(result.success).toBe(true); + expect(result.lastMessage).toContain('test-agent'); + }); it('should require optional sendFollowUp method', async () => { class MockEngineWithFollowUp implements Engine { @@ -47,8 +47,8 @@ describe('Engine Interface', () => { success: true, skipFulfill: false, lastMessage: 'Initial', - stats: null - } + stats: null, + }; } async sendFollowUp(message: string): Promise { @@ -56,19 +56,19 @@ describe('Engine Interface', () => { success: true, skipFulfill: false, lastMessage: `Follow-up: ${message}`, - stats: null - } + stats: null, + }; } } - const engine = new MockEngineWithFollowUp() + const engine = new MockEngineWithFollowUp(); // sendFollowUp is optional - check if it exists if ('sendFollowUp' in engine) { - const result = await engine.sendFollowUp('Fix the lint errors') - expect(result.lastMessage).toContain('Follow-up') + const result = await engine.sendFollowUp('Fix the lint errors'); + expect(result.lastMessage).toContain('Follow-up'); } - }) + }); it('should allow engine without sendFollowUp', async () => { class MockEngine implements Engine { @@ -77,12 +77,12 @@ describe('Engine Interface', () => { success: true, skipFulfill: false, lastMessage: 'Done', - stats: null - } + stats: null, + }; } } - const engine: Engine = new MockEngine() + const engine: Engine = new MockEngine(); const context: EngineContext = { taskId: 'task-1', workingDir: '/test', @@ -90,13 +90,13 @@ describe('Engine Interface', () => { taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), - verbose: false - } - const result = await engine.execute(context) + verbose: false, + }; + const result = await engine.execute(context); - expect(result.success).toBe(true) + expect(result.success).toBe(true); // sendFollowUp is optional, so engine doesn't need it - expect('sendFollowUp' in engine).toBe(false) - }) - }) -}) + expect('sendFollowUp' in engine).toBe(false); + }); + }); +}); diff --git a/orchestrator/packages/engine/src/interface.ts b/orchestrator/packages/engine/src/interface.ts index 88fa986..caf095b 100644 --- a/orchestrator/packages/engine/src/interface.ts +++ b/orchestrator/packages/engine/src/interface.ts @@ -1,10 +1,10 @@ -import { EngineContext, EngineResult } from './types.js' +import { EngineContext, EngineResult } from './types.js'; // FR-2: Engine Interface export type Engine = { // Execute a task - execute: (context: EngineContext) => Promise + execute: (context: EngineContext) => Promise; // Optional method for follow-up execution - sendFollowUp?: (message: string) => Promise -} + sendFollowUp?: (message: string) => Promise; +}; diff --git a/orchestrator/packages/engine/src/test-engine.spec.ts b/orchestrator/packages/engine/src/test-engine.spec.ts index 979159f..5539bb3 100644 --- a/orchestrator/packages/engine/src/test-engine.spec.ts +++ b/orchestrator/packages/engine/src/test-engine.spec.ts @@ -1,11 +1,11 @@ -import { describe, it, expect, vi } from 'vitest' -import { TestEngine } from './test-engine.js' -import type { EngineContext } from './types.js' +import { describe, it, expect, vi } from 'vitest'; +import { TestEngine } from './test-engine.js'; +import type { EngineContext } from './types.js'; describe('Test Engine', () => { describe('Basic execution', () => { it('should execute and return success result', async () => { - const engine = new TestEngine() + const engine = new TestEngine(); const context: EngineContext = { taskId: 'task-1', @@ -14,19 +14,19 @@ describe('Test Engine', () => { taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), - verbose: false - } + verbose: false, + }; - const result = await engine.execute(context) + const result = await engine.execute(context); - expect(result.success).toBe(true) - expect(result.lastMessage).toContain('complete') - expect(result.stats).toBeDefined() - expect(result.stats?.durationMs).toBeGreaterThanOrEqual(0) - }) + expect(result.success).toBe(true); + expect(result.lastMessage).toContain('complete'); + expect(result.stats).toBeDefined(); + expect(result.stats?.durationMs).toBeGreaterThanOrEqual(0); + }); it('should include execution telemetry', async () => { - const engine = new TestEngine() + const engine = new TestEngine(); const context: EngineContext = { taskId: 'task-1', @@ -35,10 +35,10 @@ describe('Test Engine', () => { taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), - verbose: false - } + verbose: false, + }; - const result = await engine.execute(context) + const result = await engine.execute(context); expect(result.stats).toMatchObject({ durationMs: expect.any(Number), @@ -47,28 +47,28 @@ describe('Test Engine', () => { cacheReadTokens: expect.any(Number), cacheCreationTokens: expect.any(Number), totalCostUsd: expect.any(Number), - numTurns: expect.any(Number) - }) - }) + numTurns: expect.any(Number), + }); + }); it('should support follow-up execution', async () => { - const engine = new TestEngine() + const engine = new TestEngine(); - const followUpResult = await engine.sendFollowUp?.('Fix the lint errors') + const followUpResult = await engine.sendFollowUp?.('Fix the lint errors'); - expect(followUpResult).toBeDefined() - expect(followUpResult?.success).toBe(true) - expect(followUpResult?.lastMessage).toContain('Follow-up') - }) - }) + expect(followUpResult).toBeDefined(); + expect(followUpResult?.success).toBe(true); + expect(followUpResult?.lastMessage).toContain('Follow-up'); + }); + }); describe('Configurable behavior', () => { it('should support custom response configuration', async () => { const engine = new TestEngine({ success: true, lastMessage: 'Custom message', - simulateError: false - }) + simulateError: false, + }); const context: EngineContext = { taskId: 'task-1', @@ -77,21 +77,21 @@ describe('Test Engine', () => { taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), - verbose: false - } + verbose: false, + }; - const result = await engine.execute(context) + const result = await engine.execute(context); - expect(result.success).toBe(true) - expect(result.lastMessage).toContain('Custom message') - }) + expect(result.success).toBe(true); + expect(result.lastMessage).toContain('Custom message'); + }); it('should simulate failures when configured', async () => { const engine = new TestEngine({ success: false, lastMessage: 'Execution failed', - simulateError: false - }) + simulateError: false, + }); const context: EngineContext = { taskId: 'task-1', @@ -100,19 +100,19 @@ describe('Test Engine', () => { taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), - verbose: false - } + verbose: false, + }; - const result = await engine.execute(context) + const result = await engine.execute(context); - expect(result.success).toBe(false) - expect(result.lastMessage).toContain('Execution failed') - }) + expect(result.success).toBe(false); + expect(result.lastMessage).toContain('Execution failed'); + }); it('should simulate delays for realistic timing', async () => { const engine = new TestEngine({ - simulateDelay: 100 - }) + simulateDelay: 100, + }); const context: EngineContext = { taskId: 'task-1', @@ -121,14 +121,14 @@ describe('Test Engine', () => { taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), - verbose: false - } + verbose: false, + }; - const start = Date.now() - await engine.execute(context) - const duration = Date.now() - start + const start = Date.now(); + await engine.execute(context); + const duration = Date.now() - start; - expect(duration).toBeGreaterThanOrEqual(95) - }) - }) -}) + expect(duration).toBeGreaterThanOrEqual(95); + }); + }); +}); diff --git a/orchestrator/packages/engine/src/test-engine.ts b/orchestrator/packages/engine/src/test-engine.ts index 9b4b674..810b98d 100644 --- a/orchestrator/packages/engine/src/test-engine.ts +++ b/orchestrator/packages/engine/src/test-engine.ts @@ -1,20 +1,20 @@ -import type { Engine } from './interface.js' -import type { EngineContext, EngineResult } from './types.js' +import type { Engine } from './interface.js'; +import type { EngineContext, EngineResult } from './types.js'; export type TestEngineConfig = { - success?: boolean - lastMessage?: string - simulateError?: boolean - simulateDelay?: number // milliseconds - mockStats?: Partial -} + success?: boolean; + lastMessage?: string; + simulateError?: boolean; + simulateDelay?: number; // milliseconds + mockStats?: Partial; +}; /** * Test Engine implementation for testing and development. * Simulates engine execution with configurable behavior. */ export class TestEngine implements Engine { - #config: TestEngineConfig + #config: TestEngineConfig; constructor(config: TestEngineConfig = {}) { this.#config = { @@ -23,22 +23,22 @@ export class TestEngine implements Engine { simulateError: false, simulateDelay: 0, mockStats: undefined, - ...config - } + ...config, + }; } async execute(context: EngineContext): Promise { // Apply simulated delay if configured if (this.#config.simulateDelay && this.#config.simulateDelay > 0) { - await new Promise((resolve) => setTimeout(resolve, this.#config.simulateDelay)) + await new Promise((resolve) => setTimeout(resolve, this.#config.simulateDelay)); } // Simulate error if configured if (this.#config.simulateError) { - throw new Error('Simulated engine error') + throw new Error('Simulated engine error'); } - const startTime = Date.now() + const startTime = Date.now(); const defaultStats: EngineResult['stats'] = { durationMs: 0, @@ -47,27 +47,27 @@ export class TestEngine implements Engine { cacheReadTokens: 10, cacheCreationTokens: 5, totalCostUsd: 0.005, - numTurns: 1 - } + numTurns: 1, + }; const stats: EngineResult['stats'] = this.#config.mockStats ? { ...defaultStats, ...this.#config.mockStats } - : defaultStats + : defaultStats; - stats.durationMs = Date.now() - startTime + stats.durationMs = Date.now() - startTime; return { success: this.#config.success ?? true, skipFulfill: false, lastMessage: `${this.#config.lastMessage} (task: ${context.taskId}, agent: ${context.agentName})`, - stats - } + stats, + }; } async sendFollowUp(message: string): Promise { // Apply simulated delay if configured if (this.#config.simulateDelay && this.#config.simulateDelay > 0) { - await new Promise((resolve) => setTimeout(resolve, this.#config.simulateDelay)) + await new Promise((resolve) => setTimeout(resolve, this.#config.simulateDelay)); } const stats: EngineResult['stats'] = { @@ -77,21 +77,21 @@ export class TestEngine implements Engine { cacheReadTokens: 5, cacheCreationTokens: 2, totalCostUsd: 0.0025, - numTurns: 1 - } + numTurns: 1, + }; return { success: this.#config.success ?? true, skipFulfill: false, lastMessage: `Follow-up: ${message}`, - stats - } + stats, + }; } /** * Update engine configuration. */ setConfig(config: Partial): void { - this.#config = { ...this.#config, ...config } + this.#config = { ...this.#config, ...config }; } } diff --git a/orchestrator/packages/engine/src/types.spec.ts b/orchestrator/packages/engine/src/types.spec.ts index 3560720..782a0a3 100644 --- a/orchestrator/packages/engine/src/types.spec.ts +++ b/orchestrator/packages/engine/src/types.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from 'vitest' -import { EngineResult, ExecutionStats, EngineContext } from './types.js' +import { describe, it, expect, vi } from 'vitest'; +import { EngineResult, ExecutionStats, EngineContext } from './types.js'; describe('Engine Types', () => { describe('ExecutionStats', () => { @@ -12,18 +12,18 @@ describe('Engine Types', () => { cacheReadTokens: 100, cacheCreationTokens: 50, totalCostUsd: 0.05, - numTurns: 3 - } + numTurns: 3, + }; - expect(stats.durationMs).toBe(5000) - expect(stats.inputTokens).toBe(1000) - expect(stats.outputTokens).toBe(500) - expect(stats.cacheReadTokens).toBe(100) - expect(stats.cacheCreationTokens).toBe(50) - expect(stats.totalCostUsd).toBe(0.05) - expect(stats.numTurns).toBe(3) - }) - }) + expect(stats.durationMs).toBe(5000); + expect(stats.inputTokens).toBe(1000); + expect(stats.outputTokens).toBe(500); + expect(stats.cacheReadTokens).toBe(100); + expect(stats.cacheCreationTokens).toBe(50); + expect(stats.totalCostUsd).toBe(0.05); + expect(stats.numTurns).toBe(3); + }); + }); describe('EngineResult', () => { it('should contain success, skipFulfill, lastMessage, and stats', () => { @@ -39,29 +39,29 @@ describe('Engine Types', () => { cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0.03, - numTurns: 1 - } - } + numTurns: 1, + }, + }; - expect(result.success).toBe(true) - expect(result.skipFulfill).toBe(false) - expect(result.lastMessage).toBe('Task completed') - expect(result.stats).toBeDefined() - }) + expect(result.success).toBe(true); + expect(result.skipFulfill).toBe(false); + expect(result.lastMessage).toBe('Task completed'); + expect(result.stats).toBeDefined(); + }); it('should allow null stats and lastMessage', () => { const result: EngineResult = { success: false, skipFulfill: false, lastMessage: null, - stats: null - } + stats: null, + }; - expect(result.success).toBe(false) - expect(result.lastMessage).toBeNull() - expect(result.stats).toBeNull() - }) - }) + expect(result.success).toBe(false); + expect(result.lastMessage).toBeNull(); + expect(result.stats).toBeNull(); + }); + }); describe('EngineContext', () => { it('should contain taskId, workingDir, agentName, taskState, metadata, setState, and verbose', () => { @@ -72,16 +72,16 @@ describe('Engine Types', () => { taskState: { step: 1 }, metadata: { priority: 'high' }, setState: vi.fn().mockResolvedValue(undefined), - verbose: true - } + verbose: true, + }; - expect(context.taskId).toBe('task-123') - expect(context.workingDir).toBe('/home/user/project') - expect(context.agentName).toBe('reviewer') - expect(context.taskState).toEqual({ step: 1 }) - expect(context.metadata).toEqual({ priority: 'high' }) - expect(context.setState).toBeDefined() - expect(context.verbose).toBe(true) - }) - }) -}) + expect(context.taskId).toBe('task-123'); + expect(context.workingDir).toBe('/home/user/project'); + expect(context.agentName).toBe('reviewer'); + expect(context.taskState).toEqual({ step: 1 }); + expect(context.metadata).toEqual({ priority: 'high' }); + expect(context.setState).toBeDefined(); + expect(context.verbose).toBe(true); + }); + }); +}); diff --git a/orchestrator/packages/engine/src/types.ts b/orchestrator/packages/engine/src/types.ts index d34457d..f2287a8 100644 --- a/orchestrator/packages/engine/src/types.ts +++ b/orchestrator/packages/engine/src/types.ts @@ -1,28 +1,28 @@ export type EngineContext = { - taskId: string - workingDir: string - agentName: string - taskState: Record - metadata: Record - setState: (newState: Record) => Promise - verbose: boolean -} + taskId: string; + workingDir: string; + agentName: string; + taskState: Record; + metadata: Record; + setState: (newState: Record) => Promise; + verbose: boolean; +}; // FR-2: EngineResult MUST contain export type EngineResult = { - success: boolean - skipFulfill: boolean - lastMessage: string | null - stats: ExecutionStats | null -} + success: boolean; + skipFulfill: boolean; + lastMessage: string | null; + stats: ExecutionStats | null; +}; // FR-2: ExecutionStats MUST contain export type ExecutionStats = { - durationMs: number - inputTokens: number - outputTokens: number - cacheReadTokens: number - cacheCreationTokens: number - totalCostUsd: number - numTurns: number -} + durationMs: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalCostUsd: number; + numTurns: number; +}; diff --git a/orchestrator/packages/engine/vite.config.ts b/orchestrator/packages/engine/vite.config.ts index 091920a..7721d2a 100644 --- a/orchestrator/packages/engine/vite.config.ts +++ b/orchestrator/packages/engine/vite.config.ts @@ -1,10 +1,10 @@ -import pkg from './package.json' +import pkg from './package.json'; // @ts-ignore -import tsconfig from './tsconfig.json' -import base from '../../vite.base' +import tsconfig from './tsconfig.json'; +import base from '../../vite.base'; export default base({ name: 'engine', pkg, - tsconfig -}) \ No newline at end of file + tsconfig, +}); diff --git a/orchestrator/packages/task-source-memory/src/index.ts b/orchestrator/packages/task-source-memory/src/index.ts index 384776a..733ce6c 100644 --- a/orchestrator/packages/task-source-memory/src/index.ts +++ b/orchestrator/packages/task-source-memory/src/index.ts @@ -1 +1 @@ -export { MemoryTaskSource } from './memory-task-source.js' +export { MemoryTaskSource } from './memory-task-source.js'; diff --git a/orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts b/orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts index 56f8c9b..aa7d000 100644 --- a/orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts +++ b/orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts @@ -1,187 +1,187 @@ -import { describe, it, expect } from 'vitest' -import { MemoryTaskSource } from './memory-task-source.js' +import { describe, it, expect } from 'vitest'; +import { MemoryTaskSource } from './memory-task-source.js'; describe('MemoryTaskSource', () => { describe('watchTasks', () => { it('should yield tasks with id, agentId, taskState, and metadata', async () => { - const source = new MemoryTaskSource() + const source = new MemoryTaskSource(); await source.addTask({ id: 'task-1', agentId: 'agent-1', taskState: { foo: 'bar' }, - metadata: { tags: ['bug'] } - }) + metadata: { tags: ['bug'] }, + }); - const tasks: string[] = [] + const tasks: string[] = []; for await (const task of source.watchTasks()) { - tasks.push(task.id) - expect(task.id).toBe('task-1') - expect(task.agentId).toBe('agent-1') - expect(task.taskState).toEqual({ foo: 'bar' }) - expect(task.metadata).toEqual({ tags: ['bug'] }) - break + tasks.push(task.id); + expect(task.id).toBe('task-1'); + expect(task.agentId).toBe('agent-1'); + expect(task.taskState).toEqual({ foo: 'bar' }); + expect(task.metadata).toEqual({ tags: ['bug'] }); + break; } - expect(tasks).toHaveLength(1) - }) + expect(tasks).toHaveLength(1); + }); it('should mark task as IN_PROGRESS when yielded', async () => { - const source = new MemoryTaskSource() + const source = new MemoryTaskSource(); await source.addTask({ id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - }) + metadata: {}, + }); for await (const task of source.watchTasks()) { - const internal = source.getInternalTask(task.id) - expect(internal?.status).toBe('IN_PROGRESS') - break + const internal = source.getInternalTask(task.id); + expect(internal?.status).toBe('IN_PROGRESS'); + break; } - }) + }); it('should not yield the same task twice', async () => { - const source = new MemoryTaskSource() + const source = new MemoryTaskSource(); await source.addTask({ id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - }) + metadata: {}, + }); - let yieldedCount = 0 + let yieldedCount = 0; for await (const _task of source.watchTasks()) { - yieldedCount++ + yieldedCount++; } - expect(yieldedCount).toBe(1) - }) - }) + expect(yieldedCount).toBe(1); + }); + }); describe('completeTask', () => { it('should mark task as COMPLETED', async () => { - const source = new MemoryTaskSource() + const source = new MemoryTaskSource(); await source.addTask({ id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - }) + metadata: {}, + }); - await source.completeTask('task-1') + await source.completeTask('task-1'); - const internal = source.getInternalTask('task-1') - expect(internal?.status).toBe('COMPLETED') - }) + const internal = source.getInternalTask('task-1'); + expect(internal?.status).toBe('COMPLETED'); + }); it('should throw if task not found', async () => { - const source = new MemoryTaskSource() + const source = new MemoryTaskSource(); - await expect(source.completeTask('unknown')).rejects.toThrow('Task unknown not found') - }) - }) + await expect(source.completeTask('unknown')).rejects.toThrow('Task unknown not found'); + }); + }); describe('failTask', () => { it('should mark task as FAILED with error message', async () => { - const source = new MemoryTaskSource() + const source = new MemoryTaskSource(); await source.addTask({ id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - }) + metadata: {}, + }); - await source.failTask('task-1', 'Test error') + await source.failTask('task-1', 'Test error'); - const internal = source.getInternalTask('task-1') - expect(internal?.status).toBe('FAILED') - expect(internal?.error).toBe('Test error') - }) + const internal = source.getInternalTask('task-1'); + expect(internal?.status).toBe('FAILED'); + expect(internal?.error).toBe('Test error'); + }); it('should throw if task not found', async () => { - const source = new MemoryTaskSource() + const source = new MemoryTaskSource(); - await expect(source.failTask('unknown', 'error')).rejects.toThrow('Task unknown not found') - }) - }) + await expect(source.failTask('unknown', 'error')).rejects.toThrow('Task unknown not found'); + }); + }); describe('setState', () => { it('should update taskState', async () => { - const source = new MemoryTaskSource() + const source = new MemoryTaskSource(); await source.addTask({ id: 'task-1', agentId: 'agent-1', taskState: { foo: 'bar' }, - metadata: {} - }) + metadata: {}, + }); - await source.setState('task-1', { foo: 'baz', newField: 'value' }) + await source.setState('task-1', { foo: 'baz', newField: 'value' }); - const internal = source.getInternalTask('task-1') - expect(internal?.taskState).toEqual({ foo: 'baz', newField: 'value' }) - }) + const internal = source.getInternalTask('task-1'); + expect(internal?.taskState).toEqual({ foo: 'baz', newField: 'value' }); + }); it('should throw if task not found', async () => { - const source = new MemoryTaskSource() + const source = new MemoryTaskSource(); - await expect(source.setState('unknown', {})).rejects.toThrow('Task unknown not found') - }) - }) + await expect(source.setState('unknown', {})).rejects.toThrow('Task unknown not found'); + }); + }); describe('orchestration lifecycle', () => { it('should support full task lifecycle: add → watch → setState → complete', async () => { - const source = new MemoryTaskSource() + const source = new MemoryTaskSource(); // Add task await source.addTask({ id: 'task-1', agentId: 'agent-1', taskState: { step: 1 }, - metadata: { priority: 'high' } - }) + metadata: { priority: 'high' }, + }); // Watch task for await (const task of source.watchTasks()) { - expect(task.id).toBe('task-1') + expect(task.id).toBe('task-1'); // Engine updates state - await source.setState(task.id, { step: 2 }) + await source.setState(task.id, { step: 2 }); - const internal = source.getInternalTask(task.id) - expect(internal?.taskState).toEqual({ step: 2 }) + const internal = source.getInternalTask(task.id); + expect(internal?.taskState).toEqual({ step: 2 }); // Complete task - await source.completeTask(task.id) + await source.completeTask(task.id); - expect(source.getInternalTask(task.id)?.status).toBe('COMPLETED') - break + expect(source.getInternalTask(task.id)?.status).toBe('COMPLETED'); + break; } - }) + }); it('should support failed task lifecycle: add → watch → fail', async () => { - const source = new MemoryTaskSource() + const source = new MemoryTaskSource(); await source.addTask({ id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - }) + metadata: {}, + }); for await (const task of source.watchTasks()) { - await source.failTask(task.id, 'Execution failed') + await source.failTask(task.id, 'Execution failed'); - expect(source.getInternalTask(task.id)?.status).toBe('FAILED') - expect(source.getInternalTask(task.id)?.error).toBe('Execution failed') - break + expect(source.getInternalTask(task.id)?.status).toBe('FAILED'); + expect(source.getInternalTask(task.id)?.error).toBe('Execution failed'); + break; } - }) - }) -}) + }); + }); +}); diff --git a/orchestrator/packages/task-source-memory/src/memory-task-source.ts b/orchestrator/packages/task-source-memory/src/memory-task-source.ts index 3d22e46..078ae3d 100644 --- a/orchestrator/packages/task-source-memory/src/memory-task-source.ts +++ b/orchestrator/packages/task-source-memory/src/memory-task-source.ts @@ -1,100 +1,100 @@ -import type { TaskSource, Task } from '@orchestrator/task-source' +import type { TaskSource, Task } from '@orchestrator/task-source'; type InternalTask = { - id: string - agentId: string - taskState: Record - metadata: Record - status: 'OPEN' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED' - error?: string -} + id: string; + agentId: string; + taskState: Record; + metadata: Record; + status: 'OPEN' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED'; + error?: string; +}; export class MemoryTaskSource implements TaskSource { - #tasks: Map = new Map() - #pending: Set = new Set() - #claimed: Set = new Set() + #tasks: Map = new Map(); + #pending: Set = new Set(); + #claimed: Set = new Set(); async addTask(task: Omit): Promise { const internalTask: InternalTask = { ...task, - status: 'OPEN' - } - this.#tasks.set(task.id, internalTask) - this.#pending.add(task.id) + status: 'OPEN', + }; + this.#tasks.set(task.id, internalTask); + this.#pending.add(task.id); } async *watchTasks(): AsyncGenerator { - const maxIterations = 100 - let iterations = 0 + const maxIterations = 100; + let iterations = 0; while ((this.#pending.size > 0 || this.#claimed.size > 0) && iterations < maxIterations) { for (const taskId of this.#pending) { if (!this.#claimed.has(taskId)) { - const task = this.#tasks.get(taskId) + const task = this.#tasks.get(taskId); if (task && task.status === 'OPEN') { - this.#claimed.add(taskId) - task.status = 'IN_PROGRESS' + this.#claimed.add(taskId); + task.status = 'IN_PROGRESS'; yield { id: task.id, agentId: task.agentId, taskState: task.taskState, - metadata: task.metadata - } + metadata: task.metadata, + }; - return + return; } } } - await new Promise((resolve) => setTimeout(resolve, 50)) - iterations++ + await new Promise((resolve) => setTimeout(resolve, 50)); + iterations++; } } async completeTask(taskId: string): Promise { - const task = this.#tasks.get(taskId) + const task = this.#tasks.get(taskId); if (!task) { - throw new Error(`Task ${taskId} not found`) + throw new Error(`Task ${taskId} not found`); } - task.status = 'COMPLETED' - this.#pending.delete(taskId) - this.#claimed.delete(taskId) + task.status = 'COMPLETED'; + this.#pending.delete(taskId); + this.#claimed.delete(taskId); } async failTask(taskId: string, error: string): Promise { - const task = this.#tasks.get(taskId) + const task = this.#tasks.get(taskId); if (!task) { - throw new Error(`Task ${taskId} not found`) + throw new Error(`Task ${taskId} not found`); } - task.status = 'FAILED' - task.error = error - this.#pending.delete(taskId) - this.#claimed.delete(taskId) + task.status = 'FAILED'; + task.error = error; + this.#pending.delete(taskId); + this.#claimed.delete(taskId); } async setState(taskId: string, taskState: Record): Promise { - const task = this.#tasks.get(taskId) + const task = this.#tasks.get(taskId); if (!task) { - throw new Error(`Task ${taskId} not found`) + throw new Error(`Task ${taskId} not found`); } - task.taskState = { ...taskState } + task.taskState = { ...taskState }; } getInternalTask(taskId: string): InternalTask | undefined { - return this.#tasks.get(taskId) + return this.#tasks.get(taskId); } getAllTasks(): InternalTask[] { - return Array.from(this.#tasks.values()) + return Array.from(this.#tasks.values()); } clear(): void { - this.#tasks.clear() - this.#pending.clear() - this.#claimed.clear() + this.#tasks.clear(); + this.#pending.clear(); + this.#claimed.clear(); } } diff --git a/orchestrator/packages/task-source-memory/vite.config.ts b/orchestrator/packages/task-source-memory/vite.config.ts index 77415e8..430c001 100644 --- a/orchestrator/packages/task-source-memory/vite.config.ts +++ b/orchestrator/packages/task-source-memory/vite.config.ts @@ -1,10 +1,10 @@ -import pkg from './package.json' +import pkg from './package.json'; // @ts-ignore -import tsconfig from './tsconfig.json' -import base from '../../vite.base' +import tsconfig from './tsconfig.json'; +import base from '../../vite.base'; export default base({ name: 'tast-source-memory', pkg, - tsconfig -}) \ No newline at end of file + tsconfig, +}); diff --git a/orchestrator/packages/task-source/src/index.ts b/orchestrator/packages/task-source/src/index.ts index dca6fdc..ad4feed 100644 --- a/orchestrator/packages/task-source/src/index.ts +++ b/orchestrator/packages/task-source/src/index.ts @@ -1,2 +1,2 @@ -export * from './types.js' -export * from './interface.js' +export * from './types.js'; +export * from './interface.js'; diff --git a/orchestrator/packages/task-source/src/interface.spec.ts b/orchestrator/packages/task-source/src/interface.spec.ts index c49f437..bf9b54f 100644 --- a/orchestrator/packages/task-source/src/interface.spec.ts +++ b/orchestrator/packages/task-source/src/interface.spec.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi } from 'vitest' -import { TaskSource } from './interface.js' -import type { Task } from './types.js' +import { describe, it, expect, vi } from 'vitest'; +import { TaskSource } from './interface.js'; +import type { Task } from './types.js'; describe('TaskSource Interface', () => { describe('FR-1: Task Source Interface', () => { @@ -11,31 +11,28 @@ describe('TaskSource Interface', () => { id: 'task-1', agentId: 'agent-1', taskState: { step: 1 }, - metadata: { priority: 'high' } - } + metadata: { priority: 'high' }, + }; } - async completeTask(_taskId: string): Promise { - } + async completeTask(_taskId: string): Promise {} - async failTask(_taskId: string, _error: string): Promise { - } + async failTask(_taskId: string, _error: string): Promise {} - async setState(_taskId: string, _taskState: Record): Promise { - } + async setState(_taskId: string, _taskState: Record): Promise {} } - const source = new MockTaskSource() - const tasks = source.watchTasks() + const source = new MockTaskSource(); + const tasks = source.watchTasks(); for await (const task of tasks) { - expect(task.id).toBe('task-1') - expect(task.agentId).toBe('agent-1') - expect(task.taskState).toEqual({ step: 1 }) - expect(task.metadata).toEqual({ priority: 'high' }) - break + expect(task.id).toBe('task-1'); + expect(task.agentId).toBe('agent-1'); + expect(task.taskState).toEqual({ step: 1 }); + expect(task.metadata).toEqual({ priority: 'high' }); + break; } - }) + }); it('should require completeTask method', async () => { const source: TaskSource = { @@ -44,19 +41,16 @@ describe('TaskSource Interface', () => { id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - } - }, - async completeTask(_taskId: string): Promise { + metadata: {}, + }; }, - async failTask(_taskId: string, _error: string): Promise { - }, - async setState(_taskId: string, _taskState: Record): Promise { - } - } + async completeTask(_taskId: string): Promise {}, + async failTask(_taskId: string, _error: string): Promise {}, + async setState(_taskId: string, _taskState: Record): Promise {}, + }; - await source.completeTask('task-1') - }) + await source.completeTask('task-1'); + }); it('should require failTask method', async () => { const source: TaskSource = { @@ -65,19 +59,16 @@ describe('TaskSource Interface', () => { id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - } - }, - async completeTask(_taskId: string): Promise { + metadata: {}, + }; }, - async failTask(_taskId: string, _error: string): Promise { - }, - async setState(_taskId: string, _taskState: Record): Promise { - } - } + async completeTask(_taskId: string): Promise {}, + async failTask(_taskId: string, _error: string): Promise {}, + async setState(_taskId: string, _taskState: Record): Promise {}, + }; - await source.failTask('task-1', 'Test error') - }) + await source.failTask('task-1', 'Test error'); + }); it('should require setState method', async () => { const source: TaskSource = { @@ -86,18 +77,15 @@ describe('TaskSource Interface', () => { id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - } - }, - async completeTask(_taskId: string): Promise { + metadata: {}, + }; }, - async failTask(_taskId: string, _error: string): Promise { - }, - async setState(_taskId: string, _taskState: Record): Promise { - } - } + async completeTask(_taskId: string): Promise {}, + async failTask(_taskId: string, _error: string): Promise {}, + async setState(_taskId: string, _taskState: Record): Promise {}, + }; - await source.setState('task-1', { step: 2 }) - }) - }) -}) + await source.setState('task-1', { step: 2 }); + }); + }); +}); diff --git a/orchestrator/packages/task-source/src/interface.ts b/orchestrator/packages/task-source/src/interface.ts index e74e7d3..88d6055 100644 --- a/orchestrator/packages/task-source/src/interface.ts +++ b/orchestrator/packages/task-source/src/interface.ts @@ -1,13 +1,13 @@ -import { Task } from './types.js' +import { Task } from './types.js'; export type TaskSource = { // Yield tasks with ALL data needed - watchTasks: () => AsyncGenerator + watchTasks: () => AsyncGenerator; // Report completion/failure - completeTask: (taskId: string) => Promise - failTask: (taskId: string, error: string) => Promise + completeTask: (taskId: string) => Promise; + failTask: (taskId: string, error: string) => Promise; // Engine calls this to persist state updates during execution - setState: (taskId: string, taskState: Record) => Promise -} + setState: (taskId: string, taskState: Record) => Promise; +}; diff --git a/orchestrator/packages/task-source/src/types.spec.ts b/orchestrator/packages/task-source/src/types.spec.ts index 19ddd67..bc566ab 100644 --- a/orchestrator/packages/task-source/src/types.spec.ts +++ b/orchestrator/packages/task-source/src/types.spec.ts @@ -1,16 +1,16 @@ -import { describe, it, expect } from 'vitest' -import { TaskStatus, Task } from './types.js' +import { describe, it, expect } from 'vitest'; +import { TaskStatus, Task } from './types.js'; describe('TaskSource Types', () => { describe('TaskStatus enum', () => { it('should have all required status values', () => { - expect(TaskStatus.OPEN).toBe('OPEN') - expect(TaskStatus.IN_PROGRESS).toBe('IN_PROGRESS') - expect(TaskStatus.COMPLETED).toBe('COMPLETED') - expect(TaskStatus.FAILED).toBe('FAILED') - expect(TaskStatus.CANCELLED).toBe('CANCELLED') - }) - }) + expect(TaskStatus.OPEN).toBe('OPEN'); + expect(TaskStatus.IN_PROGRESS).toBe('IN_PROGRESS'); + expect(TaskStatus.COMPLETED).toBe('COMPLETED'); + expect(TaskStatus.FAILED).toBe('FAILED'); + expect(TaskStatus.CANCELLED).toBe('CANCELLED'); + }); + }); describe('Task type', () => { it('should create a valid Task with required fields', () => { @@ -18,25 +18,25 @@ describe('TaskSource Types', () => { id: 'task-123', agentId: 'agent-1', taskState: { language: 'Python', step: 1 }, - metadata: { priority: 'high', tags: ['bug'] } - } + metadata: { priority: 'high', tags: ['bug'] }, + }; - expect(task.id).toBe('task-123') - expect(task.agentId).toBe('agent-1') - expect(task.taskState).toEqual({ language: 'Python', step: 1 }) - expect(task.metadata).toEqual({ priority: 'high', tags: ['bug'] }) - }) + expect(task.id).toBe('task-123'); + expect(task.agentId).toBe('agent-1'); + expect(task.taskState).toEqual({ language: 'Python', step: 1 }); + expect(task.metadata).toEqual({ priority: 'high', tags: ['bug'] }); + }); it('should allow empty objects for taskState and metadata', () => { const task: Task = { id: 'task-1', agentId: 'agent-1', taskState: {}, - metadata: {} - } + metadata: {}, + }; - expect(task.taskState).toEqual({}) - expect(task.metadata).toEqual({}) - }) - }) -}) + expect(task.taskState).toEqual({}); + expect(task.metadata).toEqual({}); + }); + }); +}); diff --git a/orchestrator/packages/task-source/src/types.ts b/orchestrator/packages/task-source/src/types.ts index c80f378..aca24b0 100644 --- a/orchestrator/packages/task-source/src/types.ts +++ b/orchestrator/packages/task-source/src/types.ts @@ -4,46 +4,46 @@ export const TaskStatus = { IN_PROGRESS: 'IN_PROGRESS', COMPLETED: 'COMPLETED', FAILED: 'FAILED', - CANCELLED: 'CANCELLED' -} as const + CANCELLED: 'CANCELLED', +} as const; -export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus] +export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus]; // Minimal Task type - orchestrator treats metadata as opaque export type Task = { - id: string - agentId: string - taskState: Record - metadata: Record -} + id: string; + agentId: string; + taskState: Record; + metadata: Record; +}; // FR-1: TaskDetail extends Task export type TaskDetail = Task & { - dependencies: DependencyRef[] - notes: NoteEntry[] - acceptanceCriteria: ACEntry[] - retro: RetroEntry[] -} + dependencies: DependencyRef[]; + notes: NoteEntry[]; + acceptanceCriteria: ACEntry[]; + retro: RetroEntry[]; +}; export type DependencyRef = { - taskId: string - type: string -} + taskId: string; + type: string; +}; export type NoteEntry = { - id: string - content: string - createdAt: Date -} + id: string; + content: string; + createdAt: Date; +}; export type ACEntry = { - id: string - criteria: string - satisfied: boolean -} + id: string; + criteria: string; + satisfied: boolean; +}; export type RetroEntry = { - id: string - content: string - createdAt: Date -} + id: string; + content: string; + createdAt: Date; +}; diff --git a/orchestrator/packages/task-source/vite.config.ts b/orchestrator/packages/task-source/vite.config.ts index 678fc45..1c84696 100644 --- a/orchestrator/packages/task-source/vite.config.ts +++ b/orchestrator/packages/task-source/vite.config.ts @@ -1,10 +1,10 @@ -import pkg from './package.json' +import pkg from './package.json'; // @ts-ignore -import tsconfig from './tsconfig.json' -import base from '../../vite.base' +import tsconfig from './tsconfig.json'; +import base from '../../vite.base'; export default base({ name: 'task-source', pkg, - tsconfig -}) \ No newline at end of file + tsconfig, +}); diff --git a/orchestrator/prettier.config.mjs b/orchestrator/prettier.config.mjs new file mode 100644 index 0000000..e7e4a47 --- /dev/null +++ b/orchestrator/prettier.config.mjs @@ -0,0 +1,10 @@ +/** @type {import('prettier').Options} */ +const config = { + semi: true, + singleQuote: true, + tabWidth: 2, + trailingComma: 'es5', + printWidth: 100, +}; + +export default config; diff --git a/orchestrator/tsconfig.json b/orchestrator/tsconfig.json index 7082629..612b9ed 100644 --- a/orchestrator/tsconfig.json +++ b/orchestrator/tsconfig.json @@ -16,4 +16,4 @@ "path": "./packages/task-source-memory" } ] -} \ No newline at end of file +} diff --git a/orchestrator/tsconfig.test.json b/orchestrator/tsconfig.test.json index 7cad209..fab127b 100644 --- a/orchestrator/tsconfig.test.json +++ b/orchestrator/tsconfig.test.json @@ -5,9 +5,6 @@ "declaration": false, "declarationMap": false }, - "include": [ - "packages/*/src/**/*.ts", - "packages/*/src/**/*.spec.ts" - ], + "include": ["packages/*/src/**/*.ts", "packages/*/src/**/*.spec.ts"], "exclude": ["node_modules", "dist"] } diff --git a/orchestrator/vite.base.ts b/orchestrator/vite.base.ts index 6aa3f89..4c32fc7 100644 --- a/orchestrator/vite.base.ts +++ b/orchestrator/vite.base.ts @@ -1,37 +1,38 @@ -import { defineConfig } from 'vite' -import dts from 'vite-plugin-dts' +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; export default ({ name, tsconfig, pkg, }: { - name: string, - tsconfig: { references?: Array<{ path: string }> }, - pkg: Record, -}) => defineConfig({ - plugins: [ - dts({ - tsconfigPath: './tsconfig.json', - }), - ], - build: { - lib: { - entry: './src/index.ts', - name, - formats: ['es'], - fileName: 'index', + name: string; + tsconfig: { references?: Array<{ path: string }> }; + pkg: Record; +}) => + defineConfig({ + plugins: [ + dts({ + tsconfigPath: './tsconfig.json', + }), + ], + build: { + lib: { + entry: './src/index.ts', + name, + formats: ['es'], + fileName: 'index', + }, + rollupOptions: { + external: [ + ...Object.keys(('dependencies' in pkg && pkg.dependencies) ?? {}), + ...Object.keys(('peerDependencies' in pkg && pkg.peerDependencies) ?? {}), + ...(('references' in tsconfig && tsconfig.references) || []).map((x: any) => x.path), + /^node:.*$/, + /node_modules/, + ], + }, + target: 'node20', + emptyOutDir: true, }, - rollupOptions: { - external: [ - ...Object.keys(('dependencies' in pkg && pkg.dependencies) ?? {}), - ...Object.keys(('peerDependencies' in pkg && pkg.peerDependencies) ?? {}), - ...(('references' in tsconfig && tsconfig.references) || []).map((x: any) => x.path), - /^node:.*$/, - /node_modules/ - ], - }, - target: 'node20', - emptyOutDir: true, - }, -}) \ No newline at end of file + }); diff --git a/orchestrator/vitest.config.ts b/orchestrator/vitest.config.ts index 1fd34fe..a2bd6f4 100644 --- a/orchestrator/vitest.config.ts +++ b/orchestrator/vitest.config.ts @@ -1,9 +1,9 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, environment: 'node', - setupFiles: './vitest.setup.ts' + setupFiles: './vitest.setup.ts', }, -}) +}); diff --git a/orchestrator/vitest.setup.ts b/orchestrator/vitest.setup.ts index b70612c..2fe1bdf 100644 --- a/orchestrator/vitest.setup.ts +++ b/orchestrator/vitest.setup.ts @@ -1,4 +1,4 @@ console.log = () => {}; console.warn = () => {}; console.error = () => {}; -console.info = () => {}; \ No newline at end of file +console.info = () => {}; From 76b2b7443480a4837a838449e9fad5ff1b988779 Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Thu, 7 May 2026 23:28:16 -0500 Subject: [PATCH 09/33] move prds --- .vscode/settings.json | 18 +++++++++++------- orchestrator/oxlint.config.ts | 17 +++++++++++++---- orchestrator/package.json | 2 +- orchestrator/prettier.config.mjs | 4 ++-- .../orchestrator-prd-v1.md | 0 .../orchestrator-prd-v2.md | 0 .../orchestrator-prd-v3.md | 0 .../prd-lvl3-agent-definition-v4.md | 0 .../task-source-prd-v1.md | 0 .../task-source-prd-v2.md | 0 .../task-source-prd-v3.md | 0 11 files changed, 27 insertions(+), 14 deletions(-) rename orchestrator-prd-v1.md => prds/orchestrator-prd-v1.md (100%) rename orchestrator-prd-v2.md => prds/orchestrator-prd-v2.md (100%) rename orchestrator-prd-v3.md => prds/orchestrator-prd-v3.md (100%) rename prd-lvl3-agent-definition-v4.md => prds/prd-lvl3-agent-definition-v4.md (100%) rename task-source-prd-v1.md => prds/task-source-prd-v1.md (100%) rename task-source-prd-v2.md => prds/task-source-prd-v2.md (100%) rename task-source-prd-v3.md => prds/task-source-prd-v3.md (100%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 7c87547..2091e6b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,19 +1,17 @@ { - "go.goflags": [ - "-work" - ], + "go.goflags": ["-work"], "gopls": { "build.env": { "GOWORK": "${workspaceFolder}/go.work" } }, - "oxc.path.oxlint": "bifrost/ui/node_modules/.bin/oxlint", + "oxc.path.oxlint": "orchestrator/node_modules/.bin/oxlint", "files.exclude": { "**/node_modules": true, "**/__pycache__": true, "**/.venv": true, - // "**/dist": true, - "*/**/.claude": true, + "**/dist": true, + "*/**/.claude": true }, "python.analysis.extraPaths": [ "orchestrator/packages/engine-claude-code/src", @@ -21,5 +19,11 @@ "orchestrator/packages/interface-tasks/src", "orchestrator/packages/orchestrator/src", "orchestrator/packages/tasks-bifrost/src" - ] + ], + "[javascript][javascriptreact][typescript][typescriptreact][json][jsonc]": { + "editor.codeActionsOnSave": { + "source.fixAll.oxc": "explicit", + "source.fixAll.prettier": "always" + } + } } diff --git a/orchestrator/oxlint.config.ts b/orchestrator/oxlint.config.ts index b9f0ace..f7d1390 100644 --- a/orchestrator/oxlint.config.ts +++ b/orchestrator/oxlint.config.ts @@ -1,10 +1,19 @@ import { defineConfig } from 'oxlint'; export default defineConfig({ - plugins: ['typescript', 'react'], + categories: { + correctness: 'error', + suspicious: 'error', + pedantic: 'error', + perf: 'error', + style: 'warn', + restriction: 'error', + }, + plugins: ['typescript'], rules: { - 'no-console': 'warn', - 'no-unused-vars': 'error', - 'react-hooks/exhaustive-deps': 'error', + 'no-console': 'off', + }, + options: { + typeAware: true, }, }); diff --git a/orchestrator/package.json b/orchestrator/package.json index b0a2961..d7ce939 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -12,7 +12,7 @@ "test:typecheck": "tsc --project tsconfig.test.json --noEmit", "build": "npm run build -ws", "dev": "vitest --watch", - "lint": "oxlint", + "lint": "oxlint . -c oxlint.config.ts", "format": "prettier . -w" }, "devDependencies": { diff --git a/orchestrator/prettier.config.mjs b/orchestrator/prettier.config.mjs index e7e4a47..54c8d4d 100644 --- a/orchestrator/prettier.config.mjs +++ b/orchestrator/prettier.config.mjs @@ -1,9 +1,9 @@ /** @type {import('prettier').Options} */ const config = { semi: true, - singleQuote: true, + singleQuote: false, tabWidth: 2, - trailingComma: 'es5', + trailingComma: true, printWidth: 100, }; diff --git a/orchestrator-prd-v1.md b/prds/orchestrator-prd-v1.md similarity index 100% rename from orchestrator-prd-v1.md rename to prds/orchestrator-prd-v1.md diff --git a/orchestrator-prd-v2.md b/prds/orchestrator-prd-v2.md similarity index 100% rename from orchestrator-prd-v2.md rename to prds/orchestrator-prd-v2.md diff --git a/orchestrator-prd-v3.md b/prds/orchestrator-prd-v3.md similarity index 100% rename from orchestrator-prd-v3.md rename to prds/orchestrator-prd-v3.md diff --git a/prd-lvl3-agent-definition-v4.md b/prds/prd-lvl3-agent-definition-v4.md similarity index 100% rename from prd-lvl3-agent-definition-v4.md rename to prds/prd-lvl3-agent-definition-v4.md diff --git a/task-source-prd-v1.md b/prds/task-source-prd-v1.md similarity index 100% rename from task-source-prd-v1.md rename to prds/task-source-prd-v1.md diff --git a/task-source-prd-v2.md b/prds/task-source-prd-v2.md similarity index 100% rename from task-source-prd-v2.md rename to prds/task-source-prd-v2.md diff --git a/task-source-prd-v3.md b/prds/task-source-prd-v3.md similarity index 100% rename from task-source-prd-v3.md rename to prds/task-source-prd-v3.md From 1d6291531e6ca4e6d623ff132277f5f9b9af8c4c Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Thu, 7 May 2026 23:34:20 -0500 Subject: [PATCH 10/33] prettier --- bifrost/ui/prettier.config.mjs | 10 -- orchestrator/oxlint.config.ts | 18 +- orchestrator/packages/cli/src/config.spec.ts | 50 +++--- orchestrator/packages/cli/src/config.ts | 46 ++--- .../packages/cli/src/git-root.spec.ts | 22 +-- orchestrator/packages/cli/src/git-root.ts | 10 +- orchestrator/packages/cli/src/index.spec.ts | 34 ++-- orchestrator/packages/cli/src/index.ts | 20 +-- orchestrator/packages/cli/vite.config.ts | 8 +- .../packages/core/src/agent-parser.spec.ts | 52 +++--- .../packages/core/src/agent-parser.ts | 40 ++--- .../core/src/handlebars-renderer.spec.ts | 84 ++++----- .../packages/core/src/handlebars-renderer.ts | 2 +- .../packages/core/src/hook-executor.spec.ts | 158 ++++++++-------- .../packages/core/src/hook-executor.ts | 10 +- orchestrator/packages/core/src/index.ts | 14 +- .../packages/core/src/orchestrator.spec.ts | 170 +++++++++--------- .../packages/core/src/orchestrator.ts | 36 ++-- .../packages/core/src/repo-installer.spec.ts | 110 ++++++------ .../packages/core/src/repo-installer.ts | 16 +- .../packages/core/src/validator.spec.ts | 86 ++++----- orchestrator/packages/core/src/validator.ts | 18 +- orchestrator/packages/core/vite.config.ts | 8 +- orchestrator/packages/engine/src/index.ts | 6 +- .../packages/engine/src/interface.spec.ts | 42 ++--- orchestrator/packages/engine/src/interface.ts | 2 +- .../packages/engine/src/test-engine.spec.ts | 68 +++---- .../packages/engine/src/test-engine.ts | 16 +- .../packages/engine/src/types.spec.ts | 40 ++--- orchestrator/packages/engine/vite.config.ts | 8 +- .../packages/task-source-memory/src/index.ts | 2 +- .../src/memory-task-source.spec.ts | 124 ++++++------- .../src/memory-task-source.ts | 16 +- .../task-source-memory/vite.config.ts | 8 +- .../packages/task-source/src/index.ts | 4 +- .../task-source/src/interface.spec.ts | 48 ++--- .../packages/task-source/src/interface.ts | 2 +- .../packages/task-source/src/types.spec.ts | 46 ++--- .../packages/task-source/src/types.ts | 10 +- .../packages/task-source/vite.config.ts | 8 +- orchestrator/vite.base.ts | 20 +-- orchestrator/vitest.config.ts | 6 +- ...prettier.config.mjs => prettier.config.mjs | 2 +- 43 files changed, 745 insertions(+), 755 deletions(-) delete mode 100644 bifrost/ui/prettier.config.mjs rename orchestrator/prettier.config.mjs => prettier.config.mjs (86%) diff --git a/bifrost/ui/prettier.config.mjs b/bifrost/ui/prettier.config.mjs deleted file mode 100644 index 3183745..0000000 --- a/bifrost/ui/prettier.config.mjs +++ /dev/null @@ -1,10 +0,0 @@ -/** @type {import('prettier').Options} */ -const config = { - semi: true, - singleQuote: true, - tabWidth: 2, - trailingComma: 'es5', - printWidth: 100, -} - -export default config diff --git a/orchestrator/oxlint.config.ts b/orchestrator/oxlint.config.ts index f7d1390..146341f 100644 --- a/orchestrator/oxlint.config.ts +++ b/orchestrator/oxlint.config.ts @@ -1,17 +1,17 @@ -import { defineConfig } from 'oxlint'; +import { defineConfig } from "oxlint"; export default defineConfig({ categories: { - correctness: 'error', - suspicious: 'error', - pedantic: 'error', - perf: 'error', - style: 'warn', - restriction: 'error', + correctness: "error", + suspicious: "error", + pedantic: "error", + perf: "error", + style: "warn", + restriction: "error", }, - plugins: ['typescript'], + plugins: ["typescript"], rules: { - 'no-console': 'off', + "no-console": "off", }, options: { typeAware: true, diff --git a/orchestrator/packages/cli/src/config.spec.ts b/orchestrator/packages/cli/src/config.spec.ts index f4f4795..ce8ec40 100644 --- a/orchestrator/packages/cli/src/config.spec.ts +++ b/orchestrator/packages/cli/src/config.spec.ts @@ -1,12 +1,12 @@ -import { describe, it, expect, vi } from 'vitest'; -import { loadConfig } from './config.js'; -import { readFile } from 'node:fs/promises'; +import { describe, it, expect, vi } from "vitest"; +import { loadConfig } from "./config.js"; +import { readFile } from "node:fs/promises"; -vi.mock('node:fs/promises'); +vi.mock("node:fs/promises"); -describe('Config Loader - US-8, FR-13', () => { - describe('Load .orchestrator.yaml configuration', () => { - it('should parse valid configuration file', async () => { +describe("Config Loader - US-8, FR-13", () => { + describe("Load .orchestrator.yaml configuration", () => { + it("should parse valid configuration file", async () => { // Given a .orchestrator.yaml configuration file const yamlContent = ` orchestrate: @@ -34,34 +34,34 @@ orchestrate: vi.mocked(readFile).mockResolvedValue(yamlContent); // When the orchestrator loads configuration - const config = await loadConfig('/test/project'); + const config = await loadConfig("/test/project"); // Then an APITaskSource is created with the specified base_url - expect(config.orchestrate.task_source.type).toBe('api'); - expect(config.orchestrate.task_source.settings?.base_url).toBe('https://api.example.com'); + expect(config.orchestrate.task_source.type).toBe("api"); + expect(config.orchestrate.task_source.settings?.base_url).toBe("https://api.example.com"); // And the task source polls every 30 seconds expect(config.orchestrate.task_source.settings?.poll_interval).toBe(30); // And an AIRuntimeEngine is created with the specified endpoint - expect(config.orchestrate.engine.type).toBe('ai-runtime'); - expect(config.orchestrate.engine.settings?.endpoint).toBe('https://ai.example.com'); + expect(config.orchestrate.engine.type).toBe("ai-runtime"); + expect(config.orchestrate.engine.settings?.endpoint).toBe("https://ai.example.com"); // And a RedisTaskStateStore is created - expect(config.orchestrate.task_state_store.type).toBe('redis'); - expect(config.orchestrate.task_state_store.settings?.url).toBe('redis://localhost:6379'); + expect(config.orchestrate.task_state_store.type).toBe("redis"); + expect(config.orchestrate.task_state_store.settings?.url).toBe("redis://localhost:6379"); // And concurrency is 5 expect(config.orchestrate.concurrency).toBe(5); // And claimant is set - expect(config.orchestrate.claimant).toBe('orchestrator-1'); + expect(config.orchestrate.claimant).toBe("orchestrator-1"); // And logging is verbose - expect(config.orchestrate.logging).toBe('verbose'); + expect(config.orchestrate.logging).toBe("verbose"); }); - it('should use default values when optional fields are missing', async () => { + it("should use default values when optional fields are missing", async () => { const yamlContent = ` orchestrate: task_source: @@ -74,14 +74,14 @@ orchestrate: vi.mocked(readFile).mockResolvedValue(yamlContent); - const config = await loadConfig('/test/project'); + const config = await loadConfig("/test/project"); expect(config.orchestrate.concurrency).toBe(1); // default expect(config.orchestrate.claimant).toBeNull(); // default - expect(config.orchestrate.logging).toBe('normal'); // default + expect(config.orchestrate.logging).toBe("normal"); // default }); - it('should throw error for unknown task source type', async () => { + it("should throw error for unknown task source type", async () => { // Given an unknown task source type is configured const yamlContent = ` orchestrate: @@ -97,12 +97,12 @@ orchestrate: // When the orchestrator attempts to create the task source // Then an error is raised with message "Unknown task source type: {type}" - await expect(loadConfig('/test/project')).rejects.toThrow( - 'Unknown task source type: unknown-type' + await expect(loadConfig("/test/project")).rejects.toThrow( + "Unknown task source type: unknown-type", ); }); - it('should load from home directory when not in project', async () => { + it("should load from home directory when not in project", async () => { // Test loading config from home directory as fallback const yamlContent = ` orchestrate: @@ -116,9 +116,9 @@ orchestrate: vi.mocked(readFile).mockResolvedValue(yamlContent); - const config = await loadConfig('/home/user/.orchestrator.yaml'); + const config = await loadConfig("/home/user/.orchestrator.yaml"); - expect(config.orchestrate.task_source.type).toBe('memory'); + expect(config.orchestrate.task_source.type).toBe("memory"); }); }); }); diff --git a/orchestrator/packages/cli/src/config.ts b/orchestrator/packages/cli/src/config.ts index f606e32..e0d926d 100644 --- a/orchestrator/packages/cli/src/config.ts +++ b/orchestrator/packages/cli/src/config.ts @@ -1,7 +1,7 @@ -import { readFile } from 'node:fs/promises'; -import { resolve, join } from 'node:path'; -import { homedir } from 'node:os'; -import { parse as yamlParse } from 'yaml'; +import { readFile } from "node:fs/promises"; +import { resolve, join } from "node:path"; +import { homedir } from "node:os"; +import { parse as yamlParse } from "yaml"; export type TaskSourceConfig = { type: string; @@ -14,7 +14,7 @@ export type EngineConfig = { }; export type TaskStateStoreConfig = { - type: 'redis' | 'memory' | 'file'; + type: "redis" | "memory" | "file"; settings?: Record; }; @@ -24,7 +24,7 @@ export type OrchestrateConfig = { task_state_store: TaskStateStoreConfig; concurrency: number; claimant: string | null; - logging: 'normal' | 'verbose'; + logging: "normal" | "verbose"; }; export type OrchestratorConfig = { @@ -33,12 +33,12 @@ export type OrchestratorConfig = { const DEFAULT_CONFIG: OrchestratorConfig = { orchestrate: { - task_source: { type: 'memory' }, - engine: { type: 'test' }, - task_state_store: { type: 'memory' }, + task_source: { type: "memory" }, + engine: { type: "test" }, + task_state_store: { type: "memory" }, concurrency: 1, claimant: null, - logging: 'normal', + logging: "normal", }, }; @@ -52,16 +52,16 @@ const DEFAULT_CONFIG: OrchestratorConfig = { */ export const loadConfig = async (projectDir: string): Promise => { // Try project directory first, then home directory - const projectConfigPath = resolve(projectDir, '.orchestrator.yaml'); - const homeConfigPath = resolve(homedir(), '.orchestrator.yaml'); + const projectConfigPath = resolve(projectDir, ".orchestrator.yaml"); + const homeConfigPath = resolve(homedir(), ".orchestrator.yaml"); let configContent: string; try { - configContent = await readFile(projectConfigPath, 'utf-8'); + configContent = await readFile(projectConfigPath, "utf-8"); } catch { try { - configContent = await readFile(homeConfigPath, 'utf-8'); + configContent = await readFile(homeConfigPath, "utf-8"); } catch { // No config file found, return defaults return DEFAULT_CONFIG; @@ -78,10 +78,10 @@ export const loadConfig = async (projectDir: string): Promise) || {}; // Parse optional fields with defaults - const concurrency = typeof orchestrate.concurrency === 'number' ? orchestrate.concurrency : 1; - const claimant = typeof orchestrate.claimant === 'string' ? orchestrate.claimant : null; - const logging = (orchestrate.logging === 'verbose' ? 'verbose' : 'normal') as - | 'normal' - | 'verbose'; + const concurrency = typeof orchestrate.concurrency === "number" ? orchestrate.concurrency : 1; + const claimant = typeof orchestrate.claimant === "string" ? orchestrate.claimant : null; + const logging = (orchestrate.logging === "verbose" ? "verbose" : "normal") as + | "normal" + | "verbose"; return { orchestrate: { @@ -109,11 +109,11 @@ export const loadConfig = async (projectDir: string): Promise { - describe('FR-6: projectDir Resolution', () => { - it('should set projectDir to git root when run from inside repository', async () => { +describe("Git Root Resolution - US-10", () => { + describe("FR-6: projectDir Resolution", () => { + it("should set projectDir to git root when run from inside repository", async () => { // Given a developer runs the orchestrator from a directory inside a git repository // When the orchestrator program starts - const root = await resolveGitRoot('/home/devzeebo/git/bifrost/orchestrator/packages/cli'); + const root = await resolveGitRoot("/home/devzeebo/git/bifrost/orchestrator/packages/cli"); // Then projectDir is set to the git root - expect(root).toContain('/bifrost'); + expect(root).toContain("/bifrost"); }); - it('should find git root from subdirectory', async () => { + it("should find git root from subdirectory", async () => { // Given a git repository rooted at /home/user/myrepo // And a developer runs the orchestrator from /home/user/myrepo/src/lib - const root = await resolveGitRoot('/home/devzeebo/git/bifrost/orchestrator/packages/cli/src'); + const root = await resolveGitRoot("/home/devzeebo/git/bifrost/orchestrator/packages/cli/src"); // When the orchestrator program starts // Then projectDir is /home/user/myrepo expect(root).toBeTruthy(); }); - it('should exit with error when not inside a git repository', async () => { + it("should exit with error when not inside a git repository", async () => { // Given a developer runs the orchestrator from a directory that is not inside any git repository // When the orchestrator program starts - const root = await resolveGitRoot('/tmp/nonexistent/path'); + const root = await resolveGitRoot("/tmp/nonexistent/path"); // Then it exits with a descriptive error stating that no git root could be found expect(root).toBeNull(); diff --git a/orchestrator/packages/cli/src/git-root.ts b/orchestrator/packages/cli/src/git-root.ts index 8af3b0f..f56f5c7 100644 --- a/orchestrator/packages/cli/src/git-root.ts +++ b/orchestrator/packages/cli/src/git-root.ts @@ -1,5 +1,5 @@ -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; /** * Resolve the git repository root by walking up from the current directory. @@ -13,15 +13,15 @@ export const resolveGitRoot = async (startPath: string): Promise let currentPath = resolve(startPath); // Walk up the directory tree - while (currentPath !== '/') { - const gitDir = resolve(currentPath, '.git'); + while (currentPath !== "/") { + const gitDir = resolve(currentPath, ".git"); if (existsSync(gitDir)) { return currentPath; } // Move up one directory - const parentPath = resolve(currentPath, '..'); + const parentPath = resolve(currentPath, ".."); // Prevent infinite loop if (parentPath === currentPath) { diff --git a/orchestrator/packages/cli/src/index.spec.ts b/orchestrator/packages/cli/src/index.spec.ts index 88e83a9..161bf7b 100644 --- a/orchestrator/packages/cli/src/index.spec.ts +++ b/orchestrator/packages/cli/src/index.spec.ts @@ -1,19 +1,19 @@ -import { describe, it, expect } from 'vitest'; -import { listAgents } from './index.js'; +import { describe, it, expect } from "vitest"; +import { listAgents } from "./index.js"; -describe('CLI - US-9: List Available Agents', () => { - describe('--list-agents command', () => { - it('should print each agent name, description, model, tools, start_hooks, stop_hooks', async () => { +describe("CLI - US-9: List Available Agents", () => { + describe("--list-agents command", () => { + it("should print each agent name, description, model, tools, start_hooks, stop_hooks", async () => { // Given the agent catalog contains agents // And agent "reviewer" has description, model, tools, and hooks const mockAgent = { - name: 'reviewer', - description: 'Code review agent', - tools: ['readFile', 'edit'], - model: 'claude-opus-4-7', + name: "reviewer", + description: "Code review agent", + tools: ["readFile", "edit"], + model: "claude-opus-4-7", hooks: { - Start: [{ name: 'validate-args', scriptPath: '/hooks/validate-args.mjs' }], - Stop: [{ name: 'check-new-tests', scriptPath: '/hooks/check.mjs' }], + Start: [{ name: "validate-args", scriptPath: "/hooks/validate-args.mjs" }], + Stop: [{ name: "check-new-tests", scriptPath: "/hooks/check.mjs" }], }, }; @@ -21,15 +21,15 @@ describe('CLI - US-9: List Available Agents', () => { const output = await listAgents([mockAgent]); // Then each agent name is printed - expect(output).toContain('reviewer'); + expect(output).toContain("reviewer"); // And agent description is printed if present - expect(output).toContain('Code review agent'); + expect(output).toContain("Code review agent"); // And agent tools are printed as comma-separated list - expect(output).toContain('readFile, edit'); + expect(output).toContain("readFile, edit"); // And start_hooks are printed - expect(output).toContain('validate-args'); + expect(output).toContain("validate-args"); // And stop_hooks are printed - expect(output).toContain('check-new-tests'); + expect(output).toContain("check-new-tests"); }); it('should print "No agents found" when catalog is empty', async () => { @@ -38,7 +38,7 @@ describe('CLI - US-9: List Available Agents', () => { const output = await listAgents([]); // Then "No agents found." is printed - expect(output).toContain('No agents found'); + expect(output).toContain("No agents found"); }); }); }); diff --git a/orchestrator/packages/cli/src/index.ts b/orchestrator/packages/cli/src/index.ts index 09f90e0..8de49c8 100644 --- a/orchestrator/packages/cli/src/index.ts +++ b/orchestrator/packages/cli/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import type { AgentDefinition } from '@orchestrator/core'; +import type { AgentDefinition } from "@orchestrator/core"; export type AgentDisplayInfo = { name: string; @@ -19,7 +19,7 @@ export type AgentDisplayInfo = { */ export const listAgents = async (agents: AgentDisplayInfo[]): Promise => { if (agents.length === 0) { - return 'No agents found.'; + return "No agents found."; } const lines: string[] = []; @@ -36,28 +36,28 @@ export const listAgents = async (agents: AgentDisplayInfo[]): Promise => } if (agent.tools && agent.tools.length > 0) { - lines.push(` Tools: ${agent.tools.join(', ')}`); + lines.push(` Tools: ${agent.tools.join(", ")}`); } if (agent.hooks?.Start && agent.hooks.Start.length > 0) { - const startHooks = agent.hooks.Start.map((h) => h.name).join(', '); + const startHooks = agent.hooks.Start.map((h) => h.name).join(", "); lines.push(` Start Hooks: ${startHooks}`); } if (agent.hooks?.Stop && agent.hooks.Stop.length > 0) { - const stopHooks = agent.hooks.Stop.map((h) => h.name).join(', '); + const stopHooks = agent.hooks.Stop.map((h) => h.name).join(", "); lines.push(` Stop Hooks: ${stopHooks}`); } - lines.push(''); // Blank line between agents + lines.push(""); // Blank line between agents } - return lines.join('\n'); + return lines.join("\n"); }; export function run() { - console.log('Orchestrator CLI'); + console.log("Orchestrator CLI"); } -export * from './git-root.js'; -export * from './config.js'; +export * from "./git-root.js"; +export * from "./config.js"; diff --git a/orchestrator/packages/cli/vite.config.ts b/orchestrator/packages/cli/vite.config.ts index 2a7b514..2891fc0 100644 --- a/orchestrator/packages/cli/vite.config.ts +++ b/orchestrator/packages/cli/vite.config.ts @@ -1,10 +1,10 @@ -import pkg from './package.json'; +import pkg from "./package.json"; // @ts-ignore -import tsconfig from './tsconfig.json'; -import base from '../../vite.base'; +import tsconfig from "./tsconfig.json"; +import base from "../../vite.base"; export default base({ - name: 'cli', + name: "cli", pkg, tsconfig, }); diff --git a/orchestrator/packages/core/src/agent-parser.spec.ts b/orchestrator/packages/core/src/agent-parser.spec.ts index 9dc287c..553e9cb 100644 --- a/orchestrator/packages/core/src/agent-parser.spec.ts +++ b/orchestrator/packages/core/src/agent-parser.spec.ts @@ -1,9 +1,9 @@ -import { describe, it, expect } from 'vitest'; -import { parseAgentDefinition } from './agent-parser.js'; +import { describe, it, expect } from "vitest"; +import { parseAgentDefinition } from "./agent-parser.js"; -describe('AGENT.md Parser - US-1', () => { - describe('Valid AGENT.md parsing', () => { - it('should parse valid AGENT.md with all required fields', () => { +describe("AGENT.md Parser - US-1", () => { + describe("Valid AGENT.md parsing", () => { + it("should parse valid AGENT.md with all required fields", () => { // Given an AGENT.md file with valid YAML frontmatter const content = `--- name: reviewer @@ -39,15 +39,15 @@ You are a code reviewer. Review the changes for {{language.name}}. // Then the agent name, description, tools, toolClasses, template parameter schema, and prompt body are all accessible expect(agent).toBeDefined(); - expect(agent?.name).toBe('reviewer'); - expect(agent?.description).toBe('Code review agent'); - expect(agent?.tools).toEqual(['readFile', 'edit']); - expect(agent?.toolClasses).toEqual(['linter']); + expect(agent?.name).toBe("reviewer"); + expect(agent?.description).toBe("Code review agent"); + expect(agent?.tools).toEqual(["readFile", "edit"]); + expect(agent?.toolClasses).toEqual(["linter"]); expect(agent?.template.parameters).toBeDefined(); - expect(agent?.promptBody).toContain('You are a code reviewer'); + expect(agent?.promptBody).toContain("You are a code reviewer"); }); - it('should render Handlebars tokens from template.parameters', () => { + it("should render Handlebars tokens from template.parameters", () => { const content = `--- name: test-writer description: Write tests @@ -62,12 +62,12 @@ Write {{language.name}} tests using {{framework}}. `; const agent = parseAgentDefinition(content); - expect(agent?.promptBody).toContain('Write {{language.name}} tests using {{framework}}'); + expect(agent?.promptBody).toContain("Write {{language.name}} tests using {{framework}}"); }); }); - describe('Required field validation', () => { - it('should fail when name is missing', () => { + describe("Required field validation", () => { + it("should fail when name is missing", () => { // Given an AGENT.md missing a required frontmatter field (name) const content = `--- description: Test agent @@ -83,7 +83,7 @@ Test prompt expect(agent).toBeNull(); }); - it('should fail when description is missing', () => { + it("should fail when description is missing", () => { const content = `--- name: test-agent tools: [] @@ -95,7 +95,7 @@ Test prompt expect(agent).toBeNull(); }); - it('should fail when tools is missing', () => { + it("should fail when tools is missing", () => { const content = `--- name: test-agent description: Test @@ -108,8 +108,8 @@ Test prompt }); }); - describe('Optional parameters (ending with ?)', () => { - it('should mark field ending with ? as optional', () => { + describe("Optional parameters (ending with ?)", () => { + it("should mark field ending with ? as optional", () => { // Given a template parameter where a field name ends with ? const content = `--- name: test-agent @@ -128,12 +128,12 @@ Test prompt {{context.prDescription}} // When the parameter schema is parsed // Then that field is marked optional - expect(agent?.template.parameters['context?']).toBeDefined(); + expect(agent?.template.parameters["context?"]).toBeDefined(); }); }); - describe('Handlebars token validation', () => { - it('should fail when prompt references undeclared Handlebars token', () => { + describe("Handlebars token validation", () => { + it("should fail when prompt references undeclared Handlebars token", () => { // Given a prompt body referencing a Handlebars token not declared in template.parameters const content = `--- name: test-agent @@ -154,8 +154,8 @@ Use the {{framework}} for {{language}}. }); }); - describe('Hook parsing', () => { - it('should parse Start hooks', () => { + describe("Hook parsing", () => { + it("should parse Start hooks", () => { const content = `--- name: test-agent description: Test @@ -171,11 +171,11 @@ Prompt const agent = parseAgentDefinition(content); expect(agent?.hooks.Start).toHaveLength(1); - expect(agent?.hooks.Start[0].name).toBe('validate-args'); + expect(agent?.hooks.Start[0].name).toBe("validate-args"); expect(agent?.hooks.Start[0].timeout).toBe(120000); }); - it('should parse Stop hooks', () => { + it("should parse Stop hooks", () => { const content = `--- name: test-agent description: Test @@ -190,7 +190,7 @@ Prompt const agent = parseAgentDefinition(content); expect(agent?.hooks.Stop).toHaveLength(1); - expect(agent?.hooks.Stop[0].name).toBe('check-new-tests'); + expect(agent?.hooks.Stop[0].name).toBe("check-new-tests"); }); }); }); diff --git a/orchestrator/packages/core/src/agent-parser.ts b/orchestrator/packages/core/src/agent-parser.ts index 7f6083f..d7e18eb 100644 --- a/orchestrator/packages/core/src/agent-parser.ts +++ b/orchestrator/packages/core/src/agent-parser.ts @@ -1,5 +1,5 @@ -import matter from 'gray-matter'; -import { AgentDefinition } from './types.js'; +import matter from "gray-matter"; +import { AgentDefinition } from "./types.js"; /** * Extract all Handlebars tokens from a string. @@ -14,7 +14,7 @@ const extractHandlebarsTokens = (content: string): Set => { while ((match = simpleTokenRegex.exec(content)) !== null) { const token = match[1].trim(); // Extract the base path (first part before any dots or spaces) - const basePath = token.split('.')[0].split(' ')[0]; + const basePath = token.split(".")[0].split(" ")[0]; tokens.add(basePath); } @@ -22,7 +22,7 @@ const extractHandlebarsTokens = (content: string): Set => { const blockTokenRegex = /\{\{#(?:if|unless|each)\s+([^}]+)\}\}/g; while ((match = blockTokenRegex.exec(content)) !== null) { const token = match[1].trim(); - const basePath = token.split('.')[0].split(' ')[0]; + const basePath = token.split(".")[0].split(" ")[0]; tokens.add(basePath); } @@ -38,12 +38,12 @@ const getDeclaredParameters = (params: Record): Set => for (const key of Object.keys(params)) { // Remove the ? suffix for optional parameters - const baseKey = key.endsWith('?') ? key.slice(0, -1) : key; + const baseKey = key.endsWith("?") ? key.slice(0, -1) : key; declared.add(baseKey); // Recursively extract nested parameters const value = params[key]; - if (typeof value === 'object' && value !== null) { + if (typeof value === "object" && value !== null) { const nestedParams = getDeclaredParameters(value as Record); for (const nested of nestedParams) { declared.add(`${baseKey}.${nested}`); @@ -66,18 +66,18 @@ export const parseAgentDefinition = (content: string): AgentDefinition | null => const promptBody = parsed.content; // Validate required fields: name, description, tools - if (!data.name || typeof data.name !== 'string') { - console.error('Missing or invalid required field: name'); + if (!data.name || typeof data.name !== "string") { + console.error("Missing or invalid required field: name"); return null; } - if (!data.description || typeof data.description !== 'string') { - console.error('Missing or invalid required field: description'); + if (!data.description || typeof data.description !== "string") { + console.error("Missing or invalid required field: description"); return null; } if (!Array.isArray(data.tools)) { - console.error('Missing or invalid required field: tools'); + console.error("Missing or invalid required field: tools"); return null; } @@ -101,9 +101,9 @@ export const parseAgentDefinition = (content: string): AgentDefinition | null => // Check for parent paths (e.g., if using "context.prDescription", check if "context" or "context?" is declared) if (!isDeclared) { - const parts = token.split('.'); + const parts = token.split("."); for (let i = parts.length; i > 0; i--) { - const parentPath = parts.slice(0, i).join('.'); + const parentPath = parts.slice(0, i).join("."); if (declaredParams.has(parentPath) || declaredParams.has(`${parentPath}?`)) { isDeclared = true; break; @@ -129,13 +129,13 @@ export const parseAgentDefinition = (content: string): AgentDefinition | null => if (hooksData?.Start && Array.isArray(hooksData.Start)) { for (const hook of hooksData.Start) { - if (typeof hook === 'object' && hook !== null) { + if (typeof hook === "object" && hook !== null) { const hookObj = hook as Record; - if (typeof hookObj.name === 'string' && typeof hookObj.scriptPath === 'string') { + if (typeof hookObj.name === "string" && typeof hookObj.scriptPath === "string") { hooks.Start.push({ name: hookObj.name, scriptPath: hookObj.scriptPath, - timeout: typeof hookObj.timeout === 'number' ? hookObj.timeout : undefined, + timeout: typeof hookObj.timeout === "number" ? hookObj.timeout : undefined, }); } } @@ -144,13 +144,13 @@ export const parseAgentDefinition = (content: string): AgentDefinition | null => if (hooksData?.Stop && Array.isArray(hooksData.Stop)) { for (const hook of hooksData.Stop) { - if (typeof hook === 'object' && hook !== null) { + if (typeof hook === "object" && hook !== null) { const hookObj = hook as Record; - if (typeof hookObj.name === 'string' && typeof hookObj.scriptPath === 'string') { + if (typeof hookObj.name === "string" && typeof hookObj.scriptPath === "string") { hooks.Stop.push({ name: hookObj.name, scriptPath: hookObj.scriptPath, - timeout: typeof hookObj.timeout === 'number' ? hookObj.timeout : undefined, + timeout: typeof hookObj.timeout === "number" ? hookObj.timeout : undefined, }); } } @@ -167,7 +167,7 @@ export const parseAgentDefinition = (content: string): AgentDefinition | null => promptBody, }; } catch (error) { - console.error('Failed to parse AGENT.md:', error); + console.error("Failed to parse AGENT.md:", error); return null; } }; diff --git a/orchestrator/packages/core/src/handlebars-renderer.spec.ts b/orchestrator/packages/core/src/handlebars-renderer.spec.ts index 84b9190..a472932 100644 --- a/orchestrator/packages/core/src/handlebars-renderer.spec.ts +++ b/orchestrator/packages/core/src/handlebars-renderer.spec.ts @@ -1,45 +1,45 @@ -import { describe, it, expect } from 'vitest'; -import { renderPrompt } from './handlebars-renderer.js'; +import { describe, it, expect } from "vitest"; +import { renderPrompt } from "./handlebars-renderer.js"; -describe('Handlebars Prompt Renderer', () => { - describe('FR-14: Render Handlebars prompt with taskState values', () => { - it('should replace simple Handlebars tokens with taskState values', () => { +describe("Handlebars Prompt Renderer", () => { + describe("FR-14: Render Handlebars prompt with taskState values", () => { + it("should replace simple Handlebars tokens with taskState values", () => { // Given a prompt body with Handlebars tokens - const promptBody = 'Write {{language.name}} code using {{testFramework.name}}.'; + const promptBody = "Write {{language.name}} code using {{testFramework.name}}."; // And taskState with matching values const taskState = { - language: { name: 'Python', prompt: 'Write Python code' }, - testFramework: { name: 'pytest', prompt: 'Use pytest framework' }, + language: { name: "Python", prompt: "Write Python code" }, + testFramework: { name: "pytest", prompt: "Use pytest framework" }, }; // When rendering const rendered = renderPrompt(promptBody, taskState); // Then tokens are replaced with values - expect(rendered).toContain('Python'); - expect(rendered).toContain('pytest'); - expect(rendered).not.toContain('{{'); + expect(rendered).toContain("Python"); + expect(rendered).toContain("pytest"); + expect(rendered).not.toContain("{{"); }); - it('should render nested object paths', () => { - const promptBody = 'PR: {{context.prDescription}}, Notes: {{context.additionalNotes}}'; + it("should render nested object paths", () => { + const promptBody = "PR: {{context.prDescription}}, Notes: {{context.additionalNotes}}"; const taskState = { context: { - prDescription: 'Fix authentication bug', - additionalNotes: 'Urgent - blocking release', + prDescription: "Fix authentication bug", + additionalNotes: "Urgent - blocking release", }, }; const rendered = renderPrompt(promptBody, taskState); - expect(rendered).toContain('Fix authentication bug'); - expect(rendered).toContain('Urgent - blocking release'); + expect(rendered).toContain("Fix authentication bug"); + expect(rendered).toContain("Urgent - blocking release"); }); - it('should render empty string for missing optional parameters', () => { + it("should render empty string for missing optional parameters", () => { // Given absent optional Handlebars tokens render as empty string - const promptBody = 'Write tests. {{context.additionalNotes}}'; + const promptBody = "Write tests. {{context.additionalNotes}}"; const taskState = { // context is optional and absent }; @@ -48,58 +48,58 @@ describe('Handlebars Prompt Renderer', () => { const rendered = renderPrompt(promptBody, taskState); // Then absent optional field renders as empty string - expect(rendered).toBe('Write tests. '); + expect(rendered).toBe("Write tests. "); }); - it('should support {{#if}} blocks for optional parameters', () => { + it("should support {{#if}} blocks for optional parameters", () => { const promptBody = - '{{#if context.prDescription}}PR: {{context.prDescription}}{{/if}} Write tests.'; + "{{#if context.prDescription}}PR: {{context.prDescription}}{{/if}} Write tests."; const taskState = { context: { - prDescription: 'Add unit tests', + prDescription: "Add unit tests", }, }; const rendered = renderPrompt(promptBody, taskState); - expect(rendered).toContain('PR: Add unit tests'); - expect(rendered).toContain('Write tests'); + expect(rendered).toContain("PR: Add unit tests"); + expect(rendered).toContain("Write tests"); }); - it('should exclude {{#if}} content when parameter is absent', () => { + it("should exclude {{#if}} content when parameter is absent", () => { const promptBody = - '{{#if context.prDescription}}PR: {{context.prDescription}}{{/if}} Write tests.'; + "{{#if context.prDescription}}PR: {{context.prDescription}}{{/if}} Write tests."; const taskState = {}; const rendered = renderPrompt(promptBody, taskState); - expect(rendered).not.toContain('PR:'); - expect(rendered).toContain('Write tests'); + expect(rendered).not.toContain("PR:"); + expect(rendered).toContain("Write tests"); }); }); - describe('FR-5: Handlebars tokens in prompt body must match declared parameters', () => { - it('should fail gracefully on undeclared tokens', () => { + describe("FR-5: Handlebars tokens in prompt body must match declared parameters", () => { + it("should fail gracefully on undeclared tokens", () => { // This is validated during AGENT.md parsing, not rendering // Rendering should handle missing keys gracefully - const promptBody = 'Use {{framework}} for {{language}}'; + const promptBody = "Use {{framework}} for {{language}}"; const taskState = { - language: 'Python', + language: "Python", // framework is missing }; const rendered = renderPrompt(promptBody, taskState); // Missing tokens render as empty string - expect(rendered).toContain('Python'); - expect(rendered).toContain('for'); // "Use for Python" - framework is empty + expect(rendered).toContain("Python"); + expect(rendered).toContain("for"); // "Use for Python" - framework is empty }); }); - describe('NFR-7: Reproducibility', () => { - it('should produce identical output for same inputs', () => { - const promptBody = 'Write {{language}} tests with {{framework}}'; - const taskState = { language: 'Python', framework: 'pytest' }; + describe("NFR-7: Reproducibility", () => { + it("should produce identical output for same inputs", () => { + const promptBody = "Write {{language}} tests with {{framework}}"; + const taskState = { language: "Python", framework: "pytest" }; const rendered1 = renderPrompt(promptBody, taskState); const rendered2 = renderPrompt(promptBody, taskState); @@ -109,9 +109,9 @@ describe('Handlebars Prompt Renderer', () => { expect(rendered1).toBe(rendered2); }); - it('should be side-effect free', () => { - const promptBody = 'Use {{language}}'; - const taskState = { language: 'Python' }; + it("should be side-effect free", () => { + const promptBody = "Use {{language}}"; + const taskState = { language: "Python" }; const originalTaskState = { ...taskState }; renderPrompt(promptBody, taskState); diff --git a/orchestrator/packages/core/src/handlebars-renderer.ts b/orchestrator/packages/core/src/handlebars-renderer.ts index 0491837..0334058 100644 --- a/orchestrator/packages/core/src/handlebars-renderer.ts +++ b/orchestrator/packages/core/src/handlebars-renderer.ts @@ -1,4 +1,4 @@ -import Handlebars from 'handlebars'; +import Handlebars from "handlebars"; /** * Render a Handlebars template with taskState values. diff --git a/orchestrator/packages/core/src/hook-executor.spec.ts b/orchestrator/packages/core/src/hook-executor.spec.ts index 868177a..9633725 100644 --- a/orchestrator/packages/core/src/hook-executor.spec.ts +++ b/orchestrator/packages/core/src/hook-executor.spec.ts @@ -1,88 +1,88 @@ -import { describe, it, expect, vi } from 'vitest'; -import { executeHooks, HookExecutionContext } from './hook-executor.js'; +import { describe, it, expect, vi } from "vitest"; +import { executeHooks, HookExecutionContext } from "./hook-executor.js"; -describe('Hook Executor - US-4', () => { - describe('Start hooks execution', () => { - it('should execute Start hooks in sequence before agent', async () => { +describe("Hook Executor - US-4", () => { + describe("Start hooks execution", () => { + it("should execute Start hooks in sequence before agent", async () => { // Given an AGENT.md with hooks.Start section const hooks = [ { - name: 'validate-args', - scriptPath: '/hooks/validate-args.mjs', + name: "validate-args", + scriptPath: "/hooks/validate-args.mjs", timeout: 30000, }, ]; const context: HookExecutionContext = { - projectDir: '/test/project', - params: { language: 'python' }, - taskState: { language: { name: 'python' } }, + projectDir: "/test/project", + params: { language: "python" }, + taskState: { language: { name: "python" } }, }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); // When the agent is dispatched - const results = await executeHooks(hooks, 'Start', context, mockExec); + const results = await executeHooks(hooks, "Start", context, mockExec); // Then each Start hook executes in declaration order expect(results).toHaveLength(1); - expect(results[0].hookName).toBe('validate-args'); + expect(results[0].hookName).toBe("validate-args"); expect(results[0].exitCode).toBe(0); }); - it('should pass exit code 0 allows agent to proceed', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }]; + it("should pass exit code 0 allows agent to proceed", async () => { + const hooks = [{ name: "test", scriptPath: "/test.mjs", timeout: 30000 }]; const context = { - projectDir: '/test', + projectDir: "/test", params: {}, taskState: {}, }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - const results = await executeHooks(hooks, 'Start', context, mockExec); + const results = await executeHooks(hooks, "Start", context, mockExec); // Exit code 0 allows the agent to proceed expect(results[0].shouldProceed).toBe(true); }); - it('should pass exit code 1 passes stdout as warning and continues', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }]; + it("should pass exit code 1 passes stdout as warning and continues", async () => { + const hooks = [{ name: "test", scriptPath: "/test.mjs", timeout: 30000 }]; const context = { - projectDir: '/test', + projectDir: "/test", params: {}, taskState: {}, }; const mockExec = vi.fn().mockResolvedValue({ exitCode: 1, - stdout: 'Warning: deprecated usage', - stderr: '', + stdout: "Warning: deprecated usage", + stderr: "", }); - const results = await executeHooks(hooks, 'Start', context, mockExec); + const results = await executeHooks(hooks, "Start", context, mockExec); // Exit code 1 passes hook stdout to agent as warning and continues expect(results[0].exitCode).toBe(1); - expect(results[0].stdout).toBe('Warning: deprecated usage'); + expect(results[0].stdout).toBe("Warning: deprecated usage"); expect(results[0].shouldProceed).toBe(true); }); - it('should pass exit code 2 halts agent and marks UoW as failed', async () => { - const hooks = [{ name: 'validate-args', scriptPath: '/test.mjs', timeout: 30000 }]; + it("should pass exit code 2 halts agent and marks UoW as failed", async () => { + const hooks = [{ name: "validate-args", scriptPath: "/test.mjs", timeout: 30000 }]; const context = { - projectDir: '/test', + projectDir: "/test", params: {}, taskState: {}, }; const mockExec = vi.fn().mockResolvedValue({ exitCode: 2, - stdout: '', - stderr: 'Validation failed', + stdout: "", + stderr: "Validation failed", }); - const results = await executeHooks(hooks, 'Start', context, mockExec); + const results = await executeHooks(hooks, "Start", context, mockExec); // Exit code 2 halts the agent, marks UoW as failed expect(results[0].exitCode).toBe(2); @@ -91,97 +91,97 @@ describe('Hook Executor - US-4', () => { }); }); - describe('Hook stdin format', () => { - it('should receive JSON with projectDir, params, taskState', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }]; + describe("Hook stdin format", () => { + it("should receive JSON with projectDir, params, taskState", async () => { + const hooks = [{ name: "test", scriptPath: "/test.mjs", timeout: 30000 }]; const context = { - projectDir: '/test/project', - params: { language: 'python' }, - taskState: { language: { name: 'python' } }, + projectDir: "/test/project", + params: { language: "python" }, + taskState: { language: { name: "python" } }, }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - await executeHooks(hooks, 'Start', context, mockExec); + await executeHooks(hooks, "Start", context, mockExec); // Hook receives JSON containing: projectDir, params, taskState expect(mockExec).toHaveBeenCalledWith( expect.objectContaining({ - stdin: expect.stringContaining('projectDir'), - }) + stdin: expect.stringContaining("projectDir"), + }), ); }); - it('should NOT include rendered prompt in stdin', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 30000 }]; + it("should NOT include rendered prompt in stdin", async () => { + const hooks = [{ name: "test", scriptPath: "/test.mjs", timeout: 30000 }]; const context = { - projectDir: '/test', + projectDir: "/test", params: {}, taskState: {}, }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - await executeHooks(hooks, 'Start', context, mockExec); + await executeHooks(hooks, "Start", context, mockExec); // The rendered prompt is NOT present in stdin const callArgs = mockExec.mock.calls[0]; const stdin = callArgs[0].stdin; - expect(stdin).not.toContain('prompt'); + expect(stdin).not.toContain("prompt"); }); }); - describe('Hook timeout behavior', () => { - it('should use default timeout of 300000ms when not configured', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs' }]; // no timeout + describe("Hook timeout behavior", () => { + it("should use default timeout of 300000ms when not configured", async () => { + const hooks = [{ name: "test", scriptPath: "/test.mjs" }]; // no timeout const context = { - projectDir: '/test', + projectDir: "/test", params: {}, taskState: {}, }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - await executeHooks(hooks, 'Start', context, mockExec); + await executeHooks(hooks, "Start", context, mockExec); // Default timeout of 300000ms (5 minutes) is applied expect(mockExec).toHaveBeenCalledWith( expect.objectContaining({ timeout: 300000, - }) + }), ); }); - it('should use configured timeout when specified', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 120000 }]; + it("should use configured timeout when specified", async () => { + const hooks = [{ name: "test", scriptPath: "/test.mjs", timeout: 120000 }]; const context = { - projectDir: '/test', + projectDir: "/test", params: {}, taskState: {}, }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - await executeHooks(hooks, 'Start', context, mockExec); + await executeHooks(hooks, "Start", context, mockExec); expect(mockExec).toHaveBeenCalledWith( expect.objectContaining({ timeout: 120000, - }) + }), ); }); - it('should treat timeout as exit code 2', async () => { - const hooks = [{ name: 'test', scriptPath: '/test.mjs', timeout: 100 }]; + it("should treat timeout as exit code 2", async () => { + const hooks = [{ name: "test", scriptPath: "/test.mjs", timeout: 100 }]; const context = { - projectDir: '/test', + projectDir: "/test", params: {}, taskState: {}, }; - const mockExec = vi.fn().mockRejectedValue(new Error('Timeout')); + const mockExec = vi.fn().mockRejectedValue(new Error("Timeout")); - const results = await executeHooks(hooks, 'Start', context, mockExec); + const results = await executeHooks(hooks, "Start", context, mockExec); // Hook execution is terminated and treated as exit code 2 expect(results[0].fatal).toBe(true); @@ -189,42 +189,42 @@ describe('Hook Executor - US-4', () => { }); }); - describe('Stop hooks execution', () => { - it('should execute Stop hooks after agent finishes', async () => { - const hooks = [{ name: 'check-new-tests', scriptPath: '/check.mjs', timeout: 30000 }]; + describe("Stop hooks execution", () => { + it("should execute Stop hooks after agent finishes", async () => { + const hooks = [{ name: "check-new-tests", scriptPath: "/check.mjs", timeout: 30000 }]; const context = { - projectDir: '/test', + projectDir: "/test", params: {}, taskState: {}, }; - const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const mockExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - const results = await executeHooks(hooks, 'Stop', context, mockExec); + const results = await executeHooks(hooks, "Stop", context, mockExec); expect(results).toHaveLength(1); - expect(results[0].hookName).toBe('check-new-tests'); + expect(results[0].hookName).toBe("check-new-tests"); }); - it('should trigger follow-up on exit code 1', async () => { - const hooks = [{ name: 'lint', scriptPath: '/lint.mjs', timeout: 30000 }]; + it("should trigger follow-up on exit code 1", async () => { + const hooks = [{ name: "lint", scriptPath: "/lint.mjs", timeout: 30000 }]; const context = { - projectDir: '/test', + projectDir: "/test", params: {}, taskState: {}, }; const mockExec = vi.fn().mockResolvedValue({ exitCode: 1, - stdout: 'Lint errors found', - stderr: '', + stdout: "Lint errors found", + stderr: "", }); - const results = await executeHooks(hooks, 'Stop', context, mockExec); + const results = await executeHooks(hooks, "Stop", context, mockExec); // Exit code 1 returns stdout to agent for remediation (follow-up loop) expect(results[0].needsFollowUp).toBe(true); - expect(results[0].stdout).toBe('Lint errors found'); + expect(results[0].stdout).toBe("Lint errors found"); }); }); }); diff --git a/orchestrator/packages/core/src/hook-executor.ts b/orchestrator/packages/core/src/hook-executor.ts index d21ad51..8c83be3 100644 --- a/orchestrator/packages/core/src/hook-executor.ts +++ b/orchestrator/packages/core/src/hook-executor.ts @@ -1,4 +1,4 @@ -import { HookSpec } from './types.js'; +import { HookSpec } from "./types.js"; export type HookExecutionContext = { projectDir: string; @@ -33,9 +33,9 @@ const DEFAULT_HOOK_TIMEOUT = 300000; // 5 minutes in ms */ export const executeHooks = async ( hooks: HookSpec[], - lifecycle: 'Start' | 'Stop', + lifecycle: "Start" | "Stop", context: HookExecutionContext, - execFn: HookExecFunction + execFn: HookExecFunction, ): Promise => { const results: HookResult[] = []; @@ -65,7 +65,7 @@ export const executeHooks = async ( // 2 = Fatal error, halt, mark UoW as failed const fatal = exitCode === 2; const shouldProceed = exitCode !== 2; - const needsFollowUp = lifecycle === 'Stop' && exitCode === 1; + const needsFollowUp = lifecycle === "Stop" && exitCode === 1; results.push({ hookName: hook.name, @@ -90,7 +90,7 @@ export const executeHooks = async ( results.push({ hookName: hook.name, exitCode: 2, - stdout: '', + stdout: "", stderr: error instanceof Error ? error.message : String(error), durationMs, shouldProceed: false, diff --git a/orchestrator/packages/core/src/index.ts b/orchestrator/packages/core/src/index.ts index 3bb8809..58685d3 100644 --- a/orchestrator/packages/core/src/index.ts +++ b/orchestrator/packages/core/src/index.ts @@ -1,7 +1,7 @@ -export * from './types.js'; -export * from './agent-parser.js'; -export * from './validator.js'; -export * from './hook-executor.js'; -export * from './handlebars-renderer.js'; -export * from './orchestrator.js'; -export * from './repo-installer.js'; +export * from "./types.js"; +export * from "./agent-parser.js"; +export * from "./validator.js"; +export * from "./hook-executor.js"; +export * from "./handlebars-renderer.js"; +export * from "./orchestrator.js"; +export * from "./repo-installer.js"; diff --git a/orchestrator/packages/core/src/orchestrator.spec.ts b/orchestrator/packages/core/src/orchestrator.spec.ts index df6b289..074af51 100644 --- a/orchestrator/packages/core/src/orchestrator.spec.ts +++ b/orchestrator/packages/core/src/orchestrator.spec.ts @@ -1,29 +1,29 @@ -import { describe, it, expect, vi } from 'vitest'; -import { orchestrate } from './orchestrator.js'; -import type { AgentDefinition } from './types.js'; -import type { HookExecutionContext } from './hook-executor.js'; -import type { Task, TaskSource } from '@orchestrator/task-source'; -import type { Engine, EngineResult } from '@orchestrator/engine'; - -describe('Orchestrator', () => { - describe('task execution lifecycle', () => { - it('should validate taskState → execute pre-hooks → invoke engine → execute post-hooks → report success', async () => { +import { describe, it, expect, vi } from "vitest"; +import { orchestrate } from "./orchestrator.js"; +import type { AgentDefinition } from "./types.js"; +import type { HookExecutionContext } from "./hook-executor.js"; +import type { Task, TaskSource } from "@orchestrator/task-source"; +import type { Engine, EngineResult } from "@orchestrator/engine"; + +describe("Orchestrator", () => { + describe("task execution lifecycle", () => { + it("should validate taskState → execute pre-hooks → invoke engine → execute post-hooks → report success", async () => { // Given a task with valid taskState const task: Task = { - id: 'task-1', - agentId: 'agent-1', - taskState: { language: 'Python' }, - metadata: { priority: 'high' }, + id: "task-1", + agentId: "agent-1", + taskState: { language: "Python" }, + metadata: { priority: "high" }, }; const agent: AgentDefinition = { - name: 'reviewer', - description: 'Code review agent', - tools: ['readFile', 'edit'], + name: "reviewer", + description: "Code review agent", + tools: ["readFile", "edit"], toolClasses: [], - template: { parameters: { language: { type: 'string' } } }, + template: { parameters: { language: { type: "string" } } }, hooks: { Start: [], Stop: [] }, - promptBody: 'Review the code.', + promptBody: "Review the code.", }; const mockTaskSource: TaskSource = { @@ -39,7 +39,7 @@ describe('Orchestrator', () => { execute: vi.fn().mockResolvedValue({ success: true, skipFulfill: false, - lastMessage: 'Review complete', + lastMessage: "Review complete", stats: { durationMs: 5000, inputTokens: 1000, @@ -58,32 +58,32 @@ describe('Orchestrator', () => { agent, taskSource: mockTaskSource, engine: mockEngine, - projectDir: '/test/project', + projectDir: "/test/project", }); // Then validation passes, hooks execute, engine runs, task completes - expect(result.outcome).toBe('completed'); + expect(result.outcome).toBe("completed"); expect(mockEngine.execute).toHaveBeenCalled(); - expect(mockTaskSource.completeTask).toHaveBeenCalledWith('task-1'); + expect(mockTaskSource.completeTask).toHaveBeenCalledWith("task-1"); }); - it('should fail task when taskState validation fails', async () => { + it("should fail task when taskState validation fails", async () => { // Given a task with invalid taskState const task: Task = { - id: 'task-2', - agentId: 'agent-1', + id: "task-2", + agentId: "agent-1", taskState: {}, // Missing required 'language' parameter metadata: {}, }; const agent: AgentDefinition = { - name: 'reviewer', - description: 'Code review agent', + name: "reviewer", + description: "Code review agent", tools: [], toolClasses: [], - template: { parameters: { language: { type: 'string' } } }, + template: { parameters: { language: { type: "string" } } }, hooks: { Start: [], Stop: [] }, - promptBody: 'Review the code.', + promptBody: "Review the code.", }; const mockTaskSource: TaskSource = { @@ -99,7 +99,7 @@ describe('Orchestrator', () => { execute: vi.fn().mockResolvedValue({ success: true, skipFulfill: false, - lastMessage: 'Done', + lastMessage: "Done", stats: null, }), }; @@ -110,34 +110,34 @@ describe('Orchestrator', () => { agent, taskSource: mockTaskSource, engine: mockEngine, - projectDir: '/test/project', + projectDir: "/test/project", }); // Then task fails, engine not called - expect(result.outcome).toBe('failed'); + expect(result.outcome).toBe("failed"); expect(mockTaskSource.failTask).toHaveBeenCalledWith( - 'task-2', - expect.stringContaining('language') + "task-2", + expect.stringContaining("language"), ); expect(mockEngine.execute).not.toHaveBeenCalled(); }); - it('should pass setState callback to engine', async () => { + it("should pass setState callback to engine", async () => { const task: Task = { - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: { step: 1 }, metadata: {}, }; const agent: AgentDefinition = { - name: 'test', - description: 'Test', + name: "test", + description: "Test", tools: [], toolClasses: [], template: { parameters: {} }, hooks: { Start: [], Stop: [] }, - promptBody: 'Test', + promptBody: "Test", }; const mockTaskSource: TaskSource = { @@ -157,7 +157,7 @@ describe('Orchestrator', () => { return { success: true, skipFulfill: false, - lastMessage: 'Done', + lastMessage: "Done", stats: null, }; }), @@ -168,7 +168,7 @@ describe('Orchestrator', () => { agent, taskSource: mockTaskSource, engine: mockEngine, - projectDir: '/test/project', + projectDir: "/test/project", }); // Engine receives setState callback @@ -176,28 +176,28 @@ describe('Orchestrator', () => { // Calling setState persists to task source await capturedSetState!({ step: 2 }); - expect(mockTaskSource.setState).toHaveBeenCalledWith('task-1', { step: 2 }); + expect(mockTaskSource.setState).toHaveBeenCalledWith("task-1", { step: 2 }); }); - it('should fail task when Start hook returns fatal error', async () => { + it("should fail task when Start hook returns fatal error", async () => { const task: Task = { - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }; const agent: AgentDefinition = { - name: 'test', - description: 'Test', + name: "test", + description: "Test", tools: [], toolClasses: [], template: { parameters: {} }, hooks: { - Start: [{ name: 'fatal-hook', scriptPath: '/fatal.mjs', timeout: 30000 }], + Start: [{ name: "fatal-hook", scriptPath: "/fatal.mjs", timeout: 30000 }], Stop: [], }, - promptBody: 'Test', + promptBody: "Test", }; const mockTaskSource: TaskSource = { @@ -213,15 +213,15 @@ describe('Orchestrator', () => { execute: vi.fn().mockResolvedValue({ success: true, skipFulfill: false, - lastMessage: 'Done', + lastMessage: "Done", stats: null, }), }; const mockExec = vi.fn().mockResolvedValue({ exitCode: 2, // Fatal - stdout: '', - stderr: 'Fatal error', + stdout: "", + stderr: "Fatal error", }); const result = await orchestrate({ @@ -229,37 +229,37 @@ describe('Orchestrator', () => { agent, taskSource: mockTaskSource, engine: mockEngine, - projectDir: '/test/project', + projectDir: "/test/project", hookExec: mockExec, }); - expect(result.outcome).toBe('failed'); + expect(result.outcome).toBe("failed"); expect(mockTaskSource.failTask).toHaveBeenCalledWith( - 'task-1', - expect.stringContaining('fatal-hook') + "task-1", + expect.stringContaining("fatal-hook"), ); expect(mockEngine.execute).not.toHaveBeenCalled(); }); - it('should fail task when Stop hook returns fatal error', async () => { + it("should fail task when Stop hook returns fatal error", async () => { const task: Task = { - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }; const agent: AgentDefinition = { - name: 'test', - description: 'Test', + name: "test", + description: "Test", tools: [], toolClasses: [], template: { parameters: {} }, hooks: { Start: [], - Stop: [{ name: 'fatal-hook', scriptPath: '/fatal.mjs', timeout: 30000 }], + Stop: [{ name: "fatal-hook", scriptPath: "/fatal.mjs", timeout: 30000 }], }, - promptBody: 'Test', + promptBody: "Test", }; const mockTaskSource: TaskSource = { @@ -275,15 +275,15 @@ describe('Orchestrator', () => { execute: vi.fn().mockResolvedValue({ success: true, skipFulfill: false, - lastMessage: 'Done', + lastMessage: "Done", stats: null, }), }; const mockExec = vi.fn().mockResolvedValue({ exitCode: 2, // Fatal - stdout: '', - stderr: 'Fatal error', + stdout: "", + stderr: "Fatal error", }); const result = await orchestrate({ @@ -291,36 +291,36 @@ describe('Orchestrator', () => { agent, taskSource: mockTaskSource, engine: mockEngine, - projectDir: '/test/project', + projectDir: "/test/project", hookExec: mockExec, }); - expect(result.outcome).toBe('failed'); + expect(result.outcome).toBe("failed"); expect(mockTaskSource.failTask).toHaveBeenCalledWith( - 'task-1', - expect.stringContaining('fatal-hook') + "task-1", + expect.stringContaining("fatal-hook"), ); }); - it('should support follow-up loop when Stop hook returns exit code 1', async () => { + it("should support follow-up loop when Stop hook returns exit code 1", async () => { const task: Task = { - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }; const agent: AgentDefinition = { - name: 'test', - description: 'Test', + name: "test", + description: "Test", tools: [], toolClasses: [], template: { parameters: {} }, hooks: { Start: [], - Stop: [{ name: 'lint', scriptPath: '/lint.mjs', timeout: 30000 }], + Stop: [{ name: "lint", scriptPath: "/lint.mjs", timeout: 30000 }], }, - promptBody: 'Test', + promptBody: "Test", }; const mockTaskSource: TaskSource = { @@ -338,32 +338,32 @@ describe('Orchestrator', () => { .mockResolvedValueOnce({ success: true, skipFulfill: false, - lastMessage: 'First run', + lastMessage: "First run", stats: null, }) .mockResolvedValueOnce({ success: true, skipFulfill: false, - lastMessage: 'Second run', + lastMessage: "Second run", stats: null, }), }; const mockExec = vi .fn() - .mockResolvedValueOnce({ exitCode: 1, stdout: 'Fix lint issues', stderr: '' }) - .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + .mockResolvedValueOnce({ exitCode: 1, stdout: "Fix lint issues", stderr: "" }) + .mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); const result = await orchestrate({ task, agent, taskSource: mockTaskSource, engine: mockEngine, - projectDir: '/test/project', + projectDir: "/test/project", hookExec: mockExec, }); - expect(result.outcome).toBe('completed'); + expect(result.outcome).toBe("completed"); expect(mockEngine.execute).toHaveBeenCalledTimes(2); }); }); diff --git a/orchestrator/packages/core/src/orchestrator.ts b/orchestrator/packages/core/src/orchestrator.ts index 4ad7435..ef90e70 100644 --- a/orchestrator/packages/core/src/orchestrator.ts +++ b/orchestrator/packages/core/src/orchestrator.ts @@ -1,12 +1,12 @@ -import type { AgentDefinition } from './types.js'; -import { validateTaskState } from './validator.js'; -import { executeHooks, type HookExecutionContext } from './hook-executor.js'; -import { renderPrompt } from './handlebars-renderer.js'; -import type { Task, TaskSource } from '@orchestrator/task-source'; -import type { Engine, EngineContext, EngineResult } from '@orchestrator/engine'; +import type { AgentDefinition } from "./types.js"; +import { validateTaskState } from "./validator.js"; +import { executeHooks, type HookExecutionContext } from "./hook-executor.js"; +import { renderPrompt } from "./handlebars-renderer.js"; +import type { Task, TaskSource } from "@orchestrator/task-source"; +import type { Engine, EngineContext, EngineResult } from "@orchestrator/engine"; type OrchestrationResult = { - outcome: 'completed' | 'failed' | 'halted'; + outcome: "completed" | "failed" | "halted"; telemetry?: { durationMs: number; inputTokens: number; @@ -43,15 +43,15 @@ export const orchestrate = async (options: OrchestrateOptions): Promise ({ exitCode: 0, stdout: '', stderr: '' }); + const defaultHookExec: HookExecFn = async () => ({ exitCode: 0, stdout: "", stderr: "" }); const execFn = hookExec ?? defaultHookExec; - const startHookResults = await executeHooks(agent.hooks.Start, 'Start', hookContext, execFn); + const startHookResults = await executeHooks(agent.hooks.Start, "Start", hookContext, execFn); for (const hook of startHookResults) { if (hook.fatal) { await taskSource.failTask(task.id, `Start hook ${hook.hookName} failed: ${hook.stderr}`); - return { outcome: 'failed', error: hook.stderr }; + return { outcome: "failed", error: hook.stderr }; } } @@ -86,7 +86,7 @@ export const orchestrate = async (options: OrchestrateOptions): Promise 0) { numTurns++; @@ -110,10 +110,10 @@ export const orchestrate = async (options: OrchestrateOptions): Promise { - describe('First run installs repo scripts into working repository', () => { - it('should hard-copy repo scripts to .ai//hooks/.d/.mjs', async () => { +describe("Repo Script Installer - US-5", () => { + describe("First run installs repo scripts into working repository", () => { + it("should hard-copy repo scripts to .ai//hooks/.d/.mjs", async () => { // Given an orchestrator program with built-in agents that have repo scripts const agents = [ { - name: 'test-agent', + name: "test-agent", hooks: { Start: [ { - name: 'validate-args', - scriptPath: 'hooks/Start.d/validate-args.mjs', + name: "validate-args", + scriptPath: "hooks/Start.d/validate-args.mjs", isRepoScript: true, }, ], Stop: [ { - name: 'check-new-tests', - scriptPath: 'hooks/Stop.d/check-new-tests.mjs', + name: "check-new-tests", + scriptPath: "hooks/Stop.d/check-new-tests.mjs", isRepoScript: true, }, ], @@ -30,153 +30,153 @@ describe('Repo Script Installer - US-5', () => { }, ]; - const mockScriptContent = 'export default async () => { return { exitCode: 0 } }'; + const mockScriptContent = "export default async () => { return { exitCode: 0 } }"; vi.mocked(readFile).mockResolvedValue(mockScriptContent); - vi.mocked(stat).mockRejectedValue(new Error('File not found')); + vi.mocked(stat).mockRejectedValue(new Error("File not found")); vi.mocked(mkdir).mockResolvedValue(undefined); vi.mocked(writeFile).mockResolvedValue(undefined); // When the orchestrator runs against the working repository for the first time - await installRepoScripts('/test/project', agents, '/orchestrator/packages'); + await installRepoScripts("/test/project", agents, "/orchestrator/packages"); // Then each repo script is hard-copied to .ai//hooks/.d/.mjs expect(writeFile).toHaveBeenCalledWith( - '/test/project/.ai/test-agent/hooks/Start.d/validate-args.mjs', + "/test/project/.ai/test-agent/hooks/Start.d/validate-args.mjs", mockScriptContent, - 'utf-8' + "utf-8", ); expect(writeFile).toHaveBeenCalledWith( - '/test/project/.ai/test-agent/hooks/Stop.d/check-new-tests.mjs', + "/test/project/.ai/test-agent/hooks/Stop.d/check-new-tests.mjs", mockScriptContent, - 'utf-8' + "utf-8", ); }); - it('should create no symlinks - only hard copies', async () => { + it("should create no symlinks - only hard copies", async () => { const agents = [ { - name: 'test-agent', + name: "test-agent", hooks: { - Start: [{ name: 'test', scriptPath: 'hooks/Start.d/test.mjs', isRepoScript: true }], + Start: [{ name: "test", scriptPath: "hooks/Start.d/test.mjs", isRepoScript: true }], Stop: [], }, }, ]; - vi.mocked(readFile).mockResolvedValue('content'); - vi.mocked(stat).mockRejectedValue(new Error('not found')); + vi.mocked(readFile).mockResolvedValue("content"); + vi.mocked(stat).mockRejectedValue(new Error("not found")); - await installRepoScripts('/test/project', agents, '/orchestrator/packages'); + await installRepoScripts("/test/project", agents, "/orchestrator/packages"); // Verify copy was made (not symlink) expect(writeFile).toHaveBeenCalled(); expect(writeFile).toHaveBeenCalledWith( - expect.stringContaining('.ai'), + expect.stringContaining(".ai"), expect.any(String), - 'utf-8' + "utf-8", ); }); - it('should log each installed path', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + it("should log each installed path", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const agents = [ { - name: 'test-agent', + name: "test-agent", hooks: { Start: [ - { name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }, + { name: "validate", scriptPath: "hooks/Start.d/validate.mjs", isRepoScript: true }, ], Stop: [], }, }, ]; - vi.mocked(readFile).mockResolvedValue('content'); - vi.mocked(stat).mockRejectedValue(new Error('not found')); + vi.mocked(readFile).mockResolvedValue("content"); + vi.mocked(stat).mockRejectedValue(new Error("not found")); - await installRepoScripts('/test/project', agents, '/orchestrator/packages'); + await installRepoScripts("/test/project", agents, "/orchestrator/packages"); // Then the orchestrator logs each installed path expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('test-agent/hooks/Start.d/validate.mjs') + expect.stringContaining("test-agent/hooks/Start.d/validate.mjs"), ); consoleSpy.mockRestore(); }); }); - describe('Idempotent installation', () => { - it('should not overwrite existing scripts', async () => { + describe("Idempotent installation", () => { + it("should not overwrite existing scripts", async () => { // Given a working repository that has already been initialized // And a repo script already exists at the expected path const agents = [ { - name: 'test-agent', + name: "test-agent", hooks: { Start: [ - { name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }, + { name: "validate", scriptPath: "hooks/Start.d/validate.mjs", isRepoScript: true }, ], Stop: [], }, }, ]; - vi.mocked(readFile).mockResolvedValue('new content'); + vi.mocked(readFile).mockResolvedValue("new content"); vi.mocked(stat).mockResolvedValue({ isFile: () => true } as any); // Clear previous calls vi.clearAllMocks(); // When the orchestrator runs again - await installRepoScripts('/test/project', agents, '/orchestrator/packages'); + await installRepoScripts("/test/project", agents, "/orchestrator/packages"); // Then the existing script is not overwritten expect(writeFile).not.toHaveBeenCalled(); }); - it('should log that script is already present', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + it("should log that script is already present", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const agents = [ { - name: 'test-agent', + name: "test-agent", hooks: { Start: [ - { name: 'validate', scriptPath: 'hooks/Start.d/validate.mjs', isRepoScript: true }, + { name: "validate", scriptPath: "hooks/Start.d/validate.mjs", isRepoScript: true }, ], Stop: [], }, }, ]; - vi.mocked(readFile).mockResolvedValue('content'); + vi.mocked(readFile).mockResolvedValue("content"); vi.mocked(stat).mockResolvedValue({ isFile: () => true } as any); vi.clearAllMocks(); - await installRepoScripts('/test/project', agents, '/orchestrator/packages'); + await installRepoScripts("/test/project", agents, "/orchestrator/packages"); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Already present:')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Already present:")); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('test-agent/hooks/Start.d/validate.mjs') + expect.stringContaining("test-agent/hooks/Start.d/validate.mjs"), ); consoleSpy.mockRestore(); }); }); - describe('Framework hooks not installed', () => { - it('should skip hooks where isRepoScript is false', async () => { + describe("Framework hooks not installed", () => { + it("should skip hooks where isRepoScript is false", async () => { const agents = [ { - name: 'test-agent', + name: "test-agent", hooks: { Start: [ { - name: 'validate-args', - scriptPath: 'hooks/Start.d/validate-args.mjs', + name: "validate-args", + scriptPath: "hooks/Start.d/validate-args.mjs", isRepoScript: false, }, ], @@ -187,7 +187,7 @@ describe('Repo Script Installer - US-5', () => { vi.clearAllMocks(); - await installRepoScripts('/test/project', agents, '/orchestrator/packages'); + await installRepoScripts("/test/project", agents, "/orchestrator/packages"); // Framework hooks run from the orchestrator's packages/ context // They should not be installed to the working repo diff --git a/orchestrator/packages/core/src/repo-installer.ts b/orchestrator/packages/core/src/repo-installer.ts index 949cd9d..b98e6d1 100644 --- a/orchestrator/packages/core/src/repo-installer.ts +++ b/orchestrator/packages/core/src/repo-installer.ts @@ -1,5 +1,5 @@ -import { mkdir, writeFile, readFile, stat } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { mkdir, writeFile, readFile, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; export type AgentWithRepoScripts = { name: string; @@ -22,11 +22,11 @@ export type AgentWithRepoScripts = { export const installRepoScripts = async ( projectDir: string, agents: AgentWithRepoScripts[], - orchestratorPackagesPath: string + orchestratorPackagesPath: string, ): Promise => { for (const agent of agents) { - for (const lifecycle of ['Start', 'Stop'] as const) { - const hooks = lifecycle === 'Start' ? agent.hooks.Start : agent.hooks.Stop; + for (const lifecycle of ["Start", "Stop"] as const) { + const hooks = lifecycle === "Start" ? agent.hooks.Start : agent.hooks.Stop; for (const hook of hooks) { // Skip framework hooks - only install repo scripts @@ -36,7 +36,7 @@ export const installRepoScripts = async ( // FR-9: Hook execution order within each .d/ directory: alphabetical by filename // Target path: .ai//hooks/.d/.mjs - const targetDir = resolve(projectDir, '.ai', agent.name, 'hooks', `${lifecycle}.d`); + const targetDir = resolve(projectDir, ".ai", agent.name, "hooks", `${lifecycle}.d`); const targetPath = resolve(targetDir, `${hook.name}.mjs`); // Check if script already exists (US-5: idempotency) @@ -53,13 +53,13 @@ export const installRepoScripts = async ( const sourcePath = resolve(orchestratorPackagesPath, agent.name, hook.scriptPath); // Read the source script content - const content = await readFile(sourcePath, 'utf-8'); + const content = await readFile(sourcePath, "utf-8"); // Create target directory await mkdir(targetDir, { recursive: true }); // FR-8: Repo scripts are hard-copied (never symlinked) - await writeFile(targetPath, content, 'utf-8'); + await writeFile(targetPath, content, "utf-8"); console.log(`Installed: ${targetPath}`); } diff --git a/orchestrator/packages/core/src/validator.spec.ts b/orchestrator/packages/core/src/validator.spec.ts index cffb4d7..158f901 100644 --- a/orchestrator/packages/core/src/validator.spec.ts +++ b/orchestrator/packages/core/src/validator.spec.ts @@ -1,21 +1,21 @@ -import { describe, it, expect } from 'vitest'; -import { validateTaskState } from './validator.js'; +import { describe, it, expect } from "vitest"; +import { validateTaskState } from "./validator.js"; -describe('Template Parameters Validator - US-6', () => { - describe('Required parameter validation', () => { - it('should pass when all required parameters are present and non-empty', () => { +describe("Template Parameters Validator - US-6", () => { + describe("Required parameter validation", () => { + it("should pass when all required parameters are present and non-empty", () => { // Given a Level 3 agent with a declared template.parameters schema const schema = { - language: { name: 'string' }, - testFramework: { name: 'string' }, - testStyle: { name: 'string' }, + language: { name: "string" }, + testFramework: { name: "string" }, + testStyle: { name: "string" }, }; // And a UoW whose taskState satisfies all required parameters const taskState = { - language: { name: 'python', prompt: 'Write Python code' }, - testFramework: { name: 'pytest', prompt: 'Use pytest' }, - testStyle: { name: 'gherkin', prompt: 'BDD style' }, + language: { name: "python", prompt: "Write Python code" }, + testFramework: { name: "pytest", prompt: "Use pytest" }, + testStyle: { name: "gherkin", prompt: "BDD style" }, }; // When the orchestrator dispatches @@ -26,15 +26,15 @@ describe('Template Parameters Validator - US-6', () => { expect(result.errors).toHaveLength(0); }); - it('should fail when required parameter is missing', () => { + it("should fail when required parameter is missing", () => { // Given a UoW whose taskState is missing a required parameter const schema = { - language: { name: 'string' }, - testFramework: { name: 'string' }, + language: { name: "string" }, + testFramework: { name: "string" }, }; const taskState = { - language: { name: 'python' }, + language: { name: "python" }, // testFramework is missing }; @@ -43,17 +43,17 @@ describe('Template Parameters Validator - US-6', () => { // Then validation fails expect(result.valid).toBe(false); - expect(result.errors).toContain('Missing required parameter: testFramework'); + expect(result.errors).toContain("Missing required parameter: testFramework"); }); - it('should fail when required field is empty string', () => { + it("should fail when required field is empty string", () => { // Given a UoW taskState where a required field is present but set to empty string const schema = { - language: { name: 'string' }, + language: { name: "string" }, }; const taskState = { - language: { name: '' }, + language: { name: "" }, }; // When validate-args runs @@ -61,17 +61,17 @@ describe('Template Parameters Validator - US-6', () => { // Then validation fails as if the field were absent expect(result.valid).toBe(false); - expect(result.errors).toContain('Missing required parameter: language.name'); + expect(result.errors).toContain("Missing required parameter: language.name"); }); }); - describe('Optional parameter validation (ending with ?)', () => { - it('should pass when optional parameter is absent', () => { + describe("Optional parameter validation (ending with ?)", () => { + it("should pass when optional parameter is absent", () => { // Given a template parameter declared as optional (name ends with ?) const schema = { - 'context?': { - prDescription: 'string', - 'additionalNotes?': 'string', + "context?": { + prDescription: "string", + "additionalNotes?": "string", }, }; @@ -83,20 +83,20 @@ describe('Template Parameters Validator - US-6', () => { expect(result.valid).toBe(true); }); - it('should pass when optional parameter is provided with required sub-fields', () => { + it("should pass when optional parameter is provided with required sub-fields", () => { // Given a template parameter declared as optional const schema = { - 'context?': { - prDescription: 'string', - 'additionalNotes?': 'string', + "context?": { + prDescription: "string", + "additionalNotes?": "string", }, }; // When taskState provides that optional parameter const taskState = { context: { - prDescription: 'Fix bug in auth', - additionalNotes: 'Urgent', + prDescription: "Fix bug in auth", + additionalNotes: "Urgent", }, }; @@ -105,12 +105,12 @@ describe('Template Parameters Validator - US-6', () => { expect(result.valid).toBe(true); }); - it('should fail when optional parameter is provided but missing required sub-field', () => { + it("should fail when optional parameter is provided but missing required sub-field", () => { // Given a template parameter that is an optional object const schema = { - 'context?': { - prDescription: 'string', - 'additionalNotes?': 'string', + "context?": { + prDescription: "string", + "additionalNotes?": "string", }, }; @@ -119,7 +119,7 @@ describe('Template Parameters Validator - US-6', () => { const taskState = { context: { // prDescription is missing (required) - additionalNotes: 'Some notes', + additionalNotes: "Some notes", }, }; @@ -128,29 +128,29 @@ describe('Template Parameters Validator - US-6', () => { // Then validation fails identifying the missing sub-field by its dot-notation path expect(result.valid).toBe(false); - expect(result.errors).toContain('Missing required parameter: context.prDescription'); + expect(result.errors).toContain("Missing required parameter: context.prDescription"); }); }); - describe('Nested parameter validation', () => { - it('should validate nested required parameters', () => { + describe("Nested parameter validation", () => { + it("should validate nested required parameters", () => { const schema = { language: { - name: 'string', - version: 'string', + name: "string", + version: "string", }, }; const taskState = { language: { - name: 'python', + name: "python", // version is missing }, }; const result = validateTaskState(taskState, schema); expect(result.valid).toBe(false); - expect(result.errors).toContain('Missing required parameter: language.version'); + expect(result.errors).toContain("Missing required parameter: language.version"); }); }); }); diff --git a/orchestrator/packages/core/src/validator.ts b/orchestrator/packages/core/src/validator.ts index 53f2501..fb678d8 100644 --- a/orchestrator/packages/core/src/validator.ts +++ b/orchestrator/packages/core/src/validator.ts @@ -10,14 +10,14 @@ export type ValidationResult = { */ export const validateTaskState = ( taskState: Record, - schema: Record + schema: Record, ): ValidationResult => { const errors: string[] = []; const validateValue = (value: unknown, schemaNode: unknown, path: string): void => { // If schemaNode is not an object, it's a scalar type hint - just check existence - if (typeof schemaNode !== 'object' || schemaNode === null) { - if (value === undefined || value === null || value === '') { + if (typeof schemaNode !== "object" || schemaNode === null) { + if (value === undefined || value === null || value === "") { errors.push(`Missing required parameter: ${path}`); } return; @@ -27,12 +27,12 @@ export const validateTaskState = ( const schemaObj = schemaNode as Record; // If value is missing/null/empty, check if path is optional - if (value === undefined || value === null || value === '') { + if (value === undefined || value === null || value === "") { return; // Handled by parent check } // Value exists - validate its structure - if (typeof value !== 'object' || value === null) { + if (typeof value !== "object" || value === null) { // Value is scalar but schema expects object - already caught above return; } @@ -41,14 +41,14 @@ export const validateTaskState = ( // Recursively validate each schema key for (const [key, subSchema] of Object.entries(schemaObj)) { - const isOptional = key.endsWith('?'); + const isOptional = key.endsWith("?"); const baseKey = isOptional ? key.slice(0, -1) : key; const fullPath = path ? `${path}.${baseKey}` : baseKey; // Check if the key exists in value (with or without ? suffix) const subValue = valueObj[baseKey] ?? valueObj[key]; - if (subValue === undefined || subValue === null || subValue === '') { + if (subValue === undefined || subValue === null || subValue === "") { if (!isOptional) { errors.push(`Missing required parameter: ${fullPath}`); } @@ -60,13 +60,13 @@ export const validateTaskState = ( // Validate each top-level schema parameter for (const [key, schemaNode] of Object.entries(schema)) { - const isOptional = key.endsWith('?'); + const isOptional = key.endsWith("?"); const baseKey = isOptional ? key.slice(0, -1) : key; // Check if parameter exists in taskState (with or without ? suffix) const value = taskState[baseKey] ?? taskState[key]; - if (value === undefined || value === null || value === '') { + if (value === undefined || value === null || value === "") { if (!isOptional) { errors.push(`Missing required parameter: ${baseKey}`); } diff --git a/orchestrator/packages/core/vite.config.ts b/orchestrator/packages/core/vite.config.ts index 2006867..3c3c06b 100644 --- a/orchestrator/packages/core/vite.config.ts +++ b/orchestrator/packages/core/vite.config.ts @@ -1,10 +1,10 @@ -import pkg from './package.json'; +import pkg from "./package.json"; // @ts-ignore -import tsconfig from './tsconfig.json'; -import base from '../../vite.base'; +import tsconfig from "./tsconfig.json"; +import base from "../../vite.base"; export default base({ - name: 'core', + name: "core", pkg, tsconfig, }); diff --git a/orchestrator/packages/engine/src/index.ts b/orchestrator/packages/engine/src/index.ts index 62efe00..de66665 100644 --- a/orchestrator/packages/engine/src/index.ts +++ b/orchestrator/packages/engine/src/index.ts @@ -1,3 +1,3 @@ -export * from './types.js'; -export * from './interface.js'; -export * from './test-engine.js'; +export * from "./types.js"; +export * from "./interface.js"; +export * from "./test-engine.js"; diff --git a/orchestrator/packages/engine/src/interface.spec.ts b/orchestrator/packages/engine/src/interface.spec.ts index 7c15685..11e62c6 100644 --- a/orchestrator/packages/engine/src/interface.spec.ts +++ b/orchestrator/packages/engine/src/interface.spec.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, vi } from 'vitest'; -import { Engine } from './interface.js'; -import { EngineContext, EngineResult } from './types.js'; +import { describe, it, expect, vi } from "vitest"; +import { Engine } from "./interface.js"; +import { EngineContext, EngineResult } from "./types.js"; -describe('Engine Interface', () => { - describe('FR-2: Engine Interface', () => { - it('should require execute method', async () => { +describe("Engine Interface", () => { + describe("FR-2: Engine Interface", () => { + it("should require execute method", async () => { class MockEngine implements Engine { async execute(context: EngineContext): Promise { return { @@ -26,9 +26,9 @@ describe('Engine Interface', () => { const engine = new MockEngine(); const context: EngineContext = { - taskId: 'task-1', - workingDir: '/test', - agentName: 'test-agent', + taskId: "task-1", + workingDir: "/test", + agentName: "test-agent", taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), @@ -37,16 +37,16 @@ describe('Engine Interface', () => { const result = await engine.execute(context); expect(result.success).toBe(true); - expect(result.lastMessage).toContain('test-agent'); + expect(result.lastMessage).toContain("test-agent"); }); - it('should require optional sendFollowUp method', async () => { + it("should require optional sendFollowUp method", async () => { class MockEngineWithFollowUp implements Engine { async execute(): Promise { return { success: true, skipFulfill: false, - lastMessage: 'Initial', + lastMessage: "Initial", stats: null, }; } @@ -64,19 +64,19 @@ describe('Engine Interface', () => { const engine = new MockEngineWithFollowUp(); // sendFollowUp is optional - check if it exists - if ('sendFollowUp' in engine) { - const result = await engine.sendFollowUp('Fix the lint errors'); - expect(result.lastMessage).toContain('Follow-up'); + if ("sendFollowUp" in engine) { + const result = await engine.sendFollowUp("Fix the lint errors"); + expect(result.lastMessage).toContain("Follow-up"); } }); - it('should allow engine without sendFollowUp', async () => { + it("should allow engine without sendFollowUp", async () => { class MockEngine implements Engine { async execute(_context: EngineContext): Promise { return { success: true, skipFulfill: false, - lastMessage: 'Done', + lastMessage: "Done", stats: null, }; } @@ -84,9 +84,9 @@ describe('Engine Interface', () => { const engine: Engine = new MockEngine(); const context: EngineContext = { - taskId: 'task-1', - workingDir: '/test', - agentName: 'test-agent', + taskId: "task-1", + workingDir: "/test", + agentName: "test-agent", taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), @@ -96,7 +96,7 @@ describe('Engine Interface', () => { expect(result.success).toBe(true); // sendFollowUp is optional, so engine doesn't need it - expect('sendFollowUp' in engine).toBe(false); + expect("sendFollowUp" in engine).toBe(false); }); }); }); diff --git a/orchestrator/packages/engine/src/interface.ts b/orchestrator/packages/engine/src/interface.ts index caf095b..20bd39a 100644 --- a/orchestrator/packages/engine/src/interface.ts +++ b/orchestrator/packages/engine/src/interface.ts @@ -1,4 +1,4 @@ -import { EngineContext, EngineResult } from './types.js'; +import { EngineContext, EngineResult } from "./types.js"; // FR-2: Engine Interface export type Engine = { diff --git a/orchestrator/packages/engine/src/test-engine.spec.ts b/orchestrator/packages/engine/src/test-engine.spec.ts index 5539bb3..24d8ea5 100644 --- a/orchestrator/packages/engine/src/test-engine.spec.ts +++ b/orchestrator/packages/engine/src/test-engine.spec.ts @@ -1,16 +1,16 @@ -import { describe, it, expect, vi } from 'vitest'; -import { TestEngine } from './test-engine.js'; -import type { EngineContext } from './types.js'; +import { describe, it, expect, vi } from "vitest"; +import { TestEngine } from "./test-engine.js"; +import type { EngineContext } from "./types.js"; -describe('Test Engine', () => { - describe('Basic execution', () => { - it('should execute and return success result', async () => { +describe("Test Engine", () => { + describe("Basic execution", () => { + it("should execute and return success result", async () => { const engine = new TestEngine(); const context: EngineContext = { - taskId: 'task-1', - workingDir: '/test/project', - agentName: 'test-agent', + taskId: "task-1", + workingDir: "/test/project", + agentName: "test-agent", taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), @@ -20,18 +20,18 @@ describe('Test Engine', () => { const result = await engine.execute(context); expect(result.success).toBe(true); - expect(result.lastMessage).toContain('complete'); + expect(result.lastMessage).toContain("complete"); expect(result.stats).toBeDefined(); expect(result.stats?.durationMs).toBeGreaterThanOrEqual(0); }); - it('should include execution telemetry', async () => { + it("should include execution telemetry", async () => { const engine = new TestEngine(); const context: EngineContext = { - taskId: 'task-1', - workingDir: '/test', - agentName: 'agent', + taskId: "task-1", + workingDir: "/test", + agentName: "agent", taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), @@ -51,29 +51,29 @@ describe('Test Engine', () => { }); }); - it('should support follow-up execution', async () => { + it("should support follow-up execution", async () => { const engine = new TestEngine(); - const followUpResult = await engine.sendFollowUp?.('Fix the lint errors'); + const followUpResult = await engine.sendFollowUp?.("Fix the lint errors"); expect(followUpResult).toBeDefined(); expect(followUpResult?.success).toBe(true); - expect(followUpResult?.lastMessage).toContain('Follow-up'); + expect(followUpResult?.lastMessage).toContain("Follow-up"); }); }); - describe('Configurable behavior', () => { - it('should support custom response configuration', async () => { + describe("Configurable behavior", () => { + it("should support custom response configuration", async () => { const engine = new TestEngine({ success: true, - lastMessage: 'Custom message', + lastMessage: "Custom message", simulateError: false, }); const context: EngineContext = { - taskId: 'task-1', - workingDir: '/test', - agentName: 'agent', + taskId: "task-1", + workingDir: "/test", + agentName: "agent", taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), @@ -83,20 +83,20 @@ describe('Test Engine', () => { const result = await engine.execute(context); expect(result.success).toBe(true); - expect(result.lastMessage).toContain('Custom message'); + expect(result.lastMessage).toContain("Custom message"); }); - it('should simulate failures when configured', async () => { + it("should simulate failures when configured", async () => { const engine = new TestEngine({ success: false, - lastMessage: 'Execution failed', + lastMessage: "Execution failed", simulateError: false, }); const context: EngineContext = { - taskId: 'task-1', - workingDir: '/test', - agentName: 'agent', + taskId: "task-1", + workingDir: "/test", + agentName: "agent", taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), @@ -106,18 +106,18 @@ describe('Test Engine', () => { const result = await engine.execute(context); expect(result.success).toBe(false); - expect(result.lastMessage).toContain('Execution failed'); + expect(result.lastMessage).toContain("Execution failed"); }); - it('should simulate delays for realistic timing', async () => { + it("should simulate delays for realistic timing", async () => { const engine = new TestEngine({ simulateDelay: 100, }); const context: EngineContext = { - taskId: 'task-1', - workingDir: '/test', - agentName: 'agent', + taskId: "task-1", + workingDir: "/test", + agentName: "agent", taskState: {}, metadata: {}, setState: vi.fn().mockResolvedValue(undefined), diff --git a/orchestrator/packages/engine/src/test-engine.ts b/orchestrator/packages/engine/src/test-engine.ts index 810b98d..6d52bda 100644 --- a/orchestrator/packages/engine/src/test-engine.ts +++ b/orchestrator/packages/engine/src/test-engine.ts @@ -1,12 +1,12 @@ -import type { Engine } from './interface.js'; -import type { EngineContext, EngineResult } from './types.js'; +import type { Engine } from "./interface.js"; +import type { EngineContext, EngineResult } from "./types.js"; export type TestEngineConfig = { success?: boolean; lastMessage?: string; simulateError?: boolean; simulateDelay?: number; // milliseconds - mockStats?: Partial; + mockStats?: Partial; }; /** @@ -19,7 +19,7 @@ export class TestEngine implements Engine { constructor(config: TestEngineConfig = {}) { this.#config = { success: true, - lastMessage: 'Test execution complete', + lastMessage: "Test execution complete", simulateError: false, simulateDelay: 0, mockStats: undefined, @@ -35,12 +35,12 @@ export class TestEngine implements Engine { // Simulate error if configured if (this.#config.simulateError) { - throw new Error('Simulated engine error'); + throw new Error("Simulated engine error"); } const startTime = Date.now(); - const defaultStats: EngineResult['stats'] = { + const defaultStats: EngineResult["stats"] = { durationMs: 0, inputTokens: 100, outputTokens: 50, @@ -50,7 +50,7 @@ export class TestEngine implements Engine { numTurns: 1, }; - const stats: EngineResult['stats'] = this.#config.mockStats + const stats: EngineResult["stats"] = this.#config.mockStats ? { ...defaultStats, ...this.#config.mockStats } : defaultStats; @@ -70,7 +70,7 @@ export class TestEngine implements Engine { await new Promise((resolve) => setTimeout(resolve, this.#config.simulateDelay)); } - const stats: EngineResult['stats'] = { + const stats: EngineResult["stats"] = { durationMs: this.#config.simulateDelay ?? 10, inputTokens: 50, outputTokens: 25, diff --git a/orchestrator/packages/engine/src/types.spec.ts b/orchestrator/packages/engine/src/types.spec.ts index 782a0a3..c116b34 100644 --- a/orchestrator/packages/engine/src/types.spec.ts +++ b/orchestrator/packages/engine/src/types.spec.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, vi } from 'vitest'; -import { EngineResult, ExecutionStats, EngineContext } from './types.js'; +import { describe, it, expect, vi } from "vitest"; +import { EngineResult, ExecutionStats, EngineContext } from "./types.js"; -describe('Engine Types', () => { - describe('ExecutionStats', () => { - it('should contain all required telemetry fields', () => { +describe("Engine Types", () => { + describe("ExecutionStats", () => { + it("should contain all required telemetry fields", () => { // FR-2: ExecutionStats MUST contain const stats: ExecutionStats = { durationMs: 5000, @@ -25,13 +25,13 @@ describe('Engine Types', () => { }); }); - describe('EngineResult', () => { - it('should contain success, skipFulfill, lastMessage, and stats', () => { + describe("EngineResult", () => { + it("should contain success, skipFulfill, lastMessage, and stats", () => { // FR-2: EngineResult MUST contain const result: EngineResult = { success: true, skipFulfill: false, - lastMessage: 'Task completed', + lastMessage: "Task completed", stats: { durationMs: 5000, inputTokens: 1000, @@ -45,11 +45,11 @@ describe('Engine Types', () => { expect(result.success).toBe(true); expect(result.skipFulfill).toBe(false); - expect(result.lastMessage).toBe('Task completed'); + expect(result.lastMessage).toBe("Task completed"); expect(result.stats).toBeDefined(); }); - it('should allow null stats and lastMessage', () => { + it("should allow null stats and lastMessage", () => { const result: EngineResult = { success: false, skipFulfill: false, @@ -63,23 +63,23 @@ describe('Engine Types', () => { }); }); - describe('EngineContext', () => { - it('should contain taskId, workingDir, agentName, taskState, metadata, setState, and verbose', () => { + describe("EngineContext", () => { + it("should contain taskId, workingDir, agentName, taskState, metadata, setState, and verbose", () => { const context: EngineContext = { - taskId: 'task-123', - workingDir: '/home/user/project', - agentName: 'reviewer', + taskId: "task-123", + workingDir: "/home/user/project", + agentName: "reviewer", taskState: { step: 1 }, - metadata: { priority: 'high' }, + metadata: { priority: "high" }, setState: vi.fn().mockResolvedValue(undefined), verbose: true, }; - expect(context.taskId).toBe('task-123'); - expect(context.workingDir).toBe('/home/user/project'); - expect(context.agentName).toBe('reviewer'); + expect(context.taskId).toBe("task-123"); + expect(context.workingDir).toBe("/home/user/project"); + expect(context.agentName).toBe("reviewer"); expect(context.taskState).toEqual({ step: 1 }); - expect(context.metadata).toEqual({ priority: 'high' }); + expect(context.metadata).toEqual({ priority: "high" }); expect(context.setState).toBeDefined(); expect(context.verbose).toBe(true); }); diff --git a/orchestrator/packages/engine/vite.config.ts b/orchestrator/packages/engine/vite.config.ts index 7721d2a..c41f62e 100644 --- a/orchestrator/packages/engine/vite.config.ts +++ b/orchestrator/packages/engine/vite.config.ts @@ -1,10 +1,10 @@ -import pkg from './package.json'; +import pkg from "./package.json"; // @ts-ignore -import tsconfig from './tsconfig.json'; -import base from '../../vite.base'; +import tsconfig from "./tsconfig.json"; +import base from "../../vite.base"; export default base({ - name: 'engine', + name: "engine", pkg, tsconfig, }); diff --git a/orchestrator/packages/task-source-memory/src/index.ts b/orchestrator/packages/task-source-memory/src/index.ts index 733ce6c..d1ceab8 100644 --- a/orchestrator/packages/task-source-memory/src/index.ts +++ b/orchestrator/packages/task-source-memory/src/index.ts @@ -1 +1 @@ -export { MemoryTaskSource } from './memory-task-source.js'; +export { MemoryTaskSource } from "./memory-task-source.js"; diff --git a/orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts b/orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts index aa7d000..61dad34 100644 --- a/orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts +++ b/orchestrator/packages/task-source-memory/src/memory-task-source.spec.ts @@ -1,54 +1,54 @@ -import { describe, it, expect } from 'vitest'; -import { MemoryTaskSource } from './memory-task-source.js'; +import { describe, it, expect } from "vitest"; +import { MemoryTaskSource } from "./memory-task-source.js"; -describe('MemoryTaskSource', () => { - describe('watchTasks', () => { - it('should yield tasks with id, agentId, taskState, and metadata', async () => { +describe("MemoryTaskSource", () => { + describe("watchTasks", () => { + it("should yield tasks with id, agentId, taskState, and metadata", async () => { const source = new MemoryTaskSource(); await source.addTask({ - id: 'task-1', - agentId: 'agent-1', - taskState: { foo: 'bar' }, - metadata: { tags: ['bug'] }, + id: "task-1", + agentId: "agent-1", + taskState: { foo: "bar" }, + metadata: { tags: ["bug"] }, }); const tasks: string[] = []; for await (const task of source.watchTasks()) { tasks.push(task.id); - expect(task.id).toBe('task-1'); - expect(task.agentId).toBe('agent-1'); - expect(task.taskState).toEqual({ foo: 'bar' }); - expect(task.metadata).toEqual({ tags: ['bug'] }); + expect(task.id).toBe("task-1"); + expect(task.agentId).toBe("agent-1"); + expect(task.taskState).toEqual({ foo: "bar" }); + expect(task.metadata).toEqual({ tags: ["bug"] }); break; } expect(tasks).toHaveLength(1); }); - it('should mark task as IN_PROGRESS when yielded', async () => { + it("should mark task as IN_PROGRESS when yielded", async () => { const source = new MemoryTaskSource(); await source.addTask({ - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }); for await (const task of source.watchTasks()) { const internal = source.getInternalTask(task.id); - expect(internal?.status).toBe('IN_PROGRESS'); + expect(internal?.status).toBe("IN_PROGRESS"); break; } }); - it('should not yield the same task twice', async () => { + it("should not yield the same task twice", async () => { const source = new MemoryTaskSource(); await source.addTask({ - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }); @@ -62,94 +62,94 @@ describe('MemoryTaskSource', () => { }); }); - describe('completeTask', () => { - it('should mark task as COMPLETED', async () => { + describe("completeTask", () => { + it("should mark task as COMPLETED", async () => { const source = new MemoryTaskSource(); await source.addTask({ - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }); - await source.completeTask('task-1'); + await source.completeTask("task-1"); - const internal = source.getInternalTask('task-1'); - expect(internal?.status).toBe('COMPLETED'); + const internal = source.getInternalTask("task-1"); + expect(internal?.status).toBe("COMPLETED"); }); - it('should throw if task not found', async () => { + it("should throw if task not found", async () => { const source = new MemoryTaskSource(); - await expect(source.completeTask('unknown')).rejects.toThrow('Task unknown not found'); + await expect(source.completeTask("unknown")).rejects.toThrow("Task unknown not found"); }); }); - describe('failTask', () => { - it('should mark task as FAILED with error message', async () => { + describe("failTask", () => { + it("should mark task as FAILED with error message", async () => { const source = new MemoryTaskSource(); await source.addTask({ - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }); - await source.failTask('task-1', 'Test error'); + await source.failTask("task-1", "Test error"); - const internal = source.getInternalTask('task-1'); - expect(internal?.status).toBe('FAILED'); - expect(internal?.error).toBe('Test error'); + const internal = source.getInternalTask("task-1"); + expect(internal?.status).toBe("FAILED"); + expect(internal?.error).toBe("Test error"); }); - it('should throw if task not found', async () => { + it("should throw if task not found", async () => { const source = new MemoryTaskSource(); - await expect(source.failTask('unknown', 'error')).rejects.toThrow('Task unknown not found'); + await expect(source.failTask("unknown", "error")).rejects.toThrow("Task unknown not found"); }); }); - describe('setState', () => { - it('should update taskState', async () => { + describe("setState", () => { + it("should update taskState", async () => { const source = new MemoryTaskSource(); await source.addTask({ - id: 'task-1', - agentId: 'agent-1', - taskState: { foo: 'bar' }, + id: "task-1", + agentId: "agent-1", + taskState: { foo: "bar" }, metadata: {}, }); - await source.setState('task-1', { foo: 'baz', newField: 'value' }); + await source.setState("task-1", { foo: "baz", newField: "value" }); - const internal = source.getInternalTask('task-1'); - expect(internal?.taskState).toEqual({ foo: 'baz', newField: 'value' }); + const internal = source.getInternalTask("task-1"); + expect(internal?.taskState).toEqual({ foo: "baz", newField: "value" }); }); - it('should throw if task not found', async () => { + it("should throw if task not found", async () => { const source = new MemoryTaskSource(); - await expect(source.setState('unknown', {})).rejects.toThrow('Task unknown not found'); + await expect(source.setState("unknown", {})).rejects.toThrow("Task unknown not found"); }); }); - describe('orchestration lifecycle', () => { - it('should support full task lifecycle: add → watch → setState → complete', async () => { + describe("orchestration lifecycle", () => { + it("should support full task lifecycle: add → watch → setState → complete", async () => { const source = new MemoryTaskSource(); // Add task await source.addTask({ - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: { step: 1 }, - metadata: { priority: 'high' }, + metadata: { priority: "high" }, }); // Watch task for await (const task of source.watchTasks()) { - expect(task.id).toBe('task-1'); + expect(task.id).toBe("task-1"); // Engine updates state await source.setState(task.id, { step: 2 }); @@ -160,26 +160,26 @@ describe('MemoryTaskSource', () => { // Complete task await source.completeTask(task.id); - expect(source.getInternalTask(task.id)?.status).toBe('COMPLETED'); + expect(source.getInternalTask(task.id)?.status).toBe("COMPLETED"); break; } }); - it('should support failed task lifecycle: add → watch → fail', async () => { + it("should support failed task lifecycle: add → watch → fail", async () => { const source = new MemoryTaskSource(); await source.addTask({ - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }); for await (const task of source.watchTasks()) { - await source.failTask(task.id, 'Execution failed'); + await source.failTask(task.id, "Execution failed"); - expect(source.getInternalTask(task.id)?.status).toBe('FAILED'); - expect(source.getInternalTask(task.id)?.error).toBe('Execution failed'); + expect(source.getInternalTask(task.id)?.status).toBe("FAILED"); + expect(source.getInternalTask(task.id)?.error).toBe("Execution failed"); break; } }); diff --git a/orchestrator/packages/task-source-memory/src/memory-task-source.ts b/orchestrator/packages/task-source-memory/src/memory-task-source.ts index 078ae3d..fd742ec 100644 --- a/orchestrator/packages/task-source-memory/src/memory-task-source.ts +++ b/orchestrator/packages/task-source-memory/src/memory-task-source.ts @@ -1,11 +1,11 @@ -import type { TaskSource, Task } from '@orchestrator/task-source'; +import type { TaskSource, Task } from "@orchestrator/task-source"; type InternalTask = { id: string; agentId: string; taskState: Record; metadata: Record; - status: 'OPEN' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED'; + status: "OPEN" | "IN_PROGRESS" | "COMPLETED" | "FAILED"; error?: string; }; @@ -14,10 +14,10 @@ export class MemoryTaskSource implements TaskSource { #pending: Set = new Set(); #claimed: Set = new Set(); - async addTask(task: Omit): Promise { + async addTask(task: Omit): Promise { const internalTask: InternalTask = { ...task, - status: 'OPEN', + status: "OPEN", }; this.#tasks.set(task.id, internalTask); this.#pending.add(task.id); @@ -31,9 +31,9 @@ export class MemoryTaskSource implements TaskSource { for (const taskId of this.#pending) { if (!this.#claimed.has(taskId)) { const task = this.#tasks.get(taskId); - if (task && task.status === 'OPEN') { + if (task && task.status === "OPEN") { this.#claimed.add(taskId); - task.status = 'IN_PROGRESS'; + task.status = "IN_PROGRESS"; yield { id: task.id, @@ -58,7 +58,7 @@ export class MemoryTaskSource implements TaskSource { throw new Error(`Task ${taskId} not found`); } - task.status = 'COMPLETED'; + task.status = "COMPLETED"; this.#pending.delete(taskId); this.#claimed.delete(taskId); } @@ -69,7 +69,7 @@ export class MemoryTaskSource implements TaskSource { throw new Error(`Task ${taskId} not found`); } - task.status = 'FAILED'; + task.status = "FAILED"; task.error = error; this.#pending.delete(taskId); this.#claimed.delete(taskId); diff --git a/orchestrator/packages/task-source-memory/vite.config.ts b/orchestrator/packages/task-source-memory/vite.config.ts index 430c001..f874100 100644 --- a/orchestrator/packages/task-source-memory/vite.config.ts +++ b/orchestrator/packages/task-source-memory/vite.config.ts @@ -1,10 +1,10 @@ -import pkg from './package.json'; +import pkg from "./package.json"; // @ts-ignore -import tsconfig from './tsconfig.json'; -import base from '../../vite.base'; +import tsconfig from "./tsconfig.json"; +import base from "../../vite.base"; export default base({ - name: 'tast-source-memory', + name: "tast-source-memory", pkg, tsconfig, }); diff --git a/orchestrator/packages/task-source/src/index.ts b/orchestrator/packages/task-source/src/index.ts index ad4feed..4030879 100644 --- a/orchestrator/packages/task-source/src/index.ts +++ b/orchestrator/packages/task-source/src/index.ts @@ -1,2 +1,2 @@ -export * from './types.js'; -export * from './interface.js'; +export * from "./types.js"; +export * from "./interface.js"; diff --git a/orchestrator/packages/task-source/src/interface.spec.ts b/orchestrator/packages/task-source/src/interface.spec.ts index bf9b54f..bc588ff 100644 --- a/orchestrator/packages/task-source/src/interface.spec.ts +++ b/orchestrator/packages/task-source/src/interface.spec.ts @@ -1,17 +1,17 @@ -import { describe, it, expect, vi } from 'vitest'; -import { TaskSource } from './interface.js'; -import type { Task } from './types.js'; +import { describe, it, expect, vi } from "vitest"; +import { TaskSource } from "./interface.js"; +import type { Task } from "./types.js"; -describe('TaskSource Interface', () => { - describe('FR-1: Task Source Interface', () => { - it('should require watchTasks method returning AsyncIterator', async () => { +describe("TaskSource Interface", () => { + describe("FR-1: Task Source Interface", () => { + it("should require watchTasks method returning AsyncIterator", async () => { class MockTaskSource implements TaskSource { async *watchTasks(): AsyncGenerator { yield { - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: { step: 1 }, - metadata: { priority: 'high' }, + metadata: { priority: "high" }, }; } @@ -26,20 +26,20 @@ describe('TaskSource Interface', () => { const tasks = source.watchTasks(); for await (const task of tasks) { - expect(task.id).toBe('task-1'); - expect(task.agentId).toBe('agent-1'); + expect(task.id).toBe("task-1"); + expect(task.agentId).toBe("agent-1"); expect(task.taskState).toEqual({ step: 1 }); - expect(task.metadata).toEqual({ priority: 'high' }); + expect(task.metadata).toEqual({ priority: "high" }); break; } }); - it('should require completeTask method', async () => { + it("should require completeTask method", async () => { const source: TaskSource = { async *watchTasks() { yield { - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }; @@ -49,15 +49,15 @@ describe('TaskSource Interface', () => { async setState(_taskId: string, _taskState: Record): Promise {}, }; - await source.completeTask('task-1'); + await source.completeTask("task-1"); }); - it('should require failTask method', async () => { + it("should require failTask method", async () => { const source: TaskSource = { async *watchTasks() { yield { - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }; @@ -67,15 +67,15 @@ describe('TaskSource Interface', () => { async setState(_taskId: string, _taskState: Record): Promise {}, }; - await source.failTask('task-1', 'Test error'); + await source.failTask("task-1", "Test error"); }); - it('should require setState method', async () => { + it("should require setState method", async () => { const source: TaskSource = { async *watchTasks() { yield { - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }; @@ -85,7 +85,7 @@ describe('TaskSource Interface', () => { async setState(_taskId: string, _taskState: Record): Promise {}, }; - await source.setState('task-1', { step: 2 }); + await source.setState("task-1", { step: 2 }); }); }); }); diff --git a/orchestrator/packages/task-source/src/interface.ts b/orchestrator/packages/task-source/src/interface.ts index 88d6055..f82ec54 100644 --- a/orchestrator/packages/task-source/src/interface.ts +++ b/orchestrator/packages/task-source/src/interface.ts @@ -1,4 +1,4 @@ -import { Task } from './types.js'; +import { Task } from "./types.js"; export type TaskSource = { // Yield tasks with ALL data needed diff --git a/orchestrator/packages/task-source/src/types.spec.ts b/orchestrator/packages/task-source/src/types.spec.ts index bc566ab..aea9f4c 100644 --- a/orchestrator/packages/task-source/src/types.spec.ts +++ b/orchestrator/packages/task-source/src/types.spec.ts @@ -1,36 +1,36 @@ -import { describe, it, expect } from 'vitest'; -import { TaskStatus, Task } from './types.js'; +import { describe, it, expect } from "vitest"; +import { TaskStatus, Task } from "./types.js"; -describe('TaskSource Types', () => { - describe('TaskStatus enum', () => { - it('should have all required status values', () => { - expect(TaskStatus.OPEN).toBe('OPEN'); - expect(TaskStatus.IN_PROGRESS).toBe('IN_PROGRESS'); - expect(TaskStatus.COMPLETED).toBe('COMPLETED'); - expect(TaskStatus.FAILED).toBe('FAILED'); - expect(TaskStatus.CANCELLED).toBe('CANCELLED'); +describe("TaskSource Types", () => { + describe("TaskStatus enum", () => { + it("should have all required status values", () => { + expect(TaskStatus.OPEN).toBe("OPEN"); + expect(TaskStatus.IN_PROGRESS).toBe("IN_PROGRESS"); + expect(TaskStatus.COMPLETED).toBe("COMPLETED"); + expect(TaskStatus.FAILED).toBe("FAILED"); + expect(TaskStatus.CANCELLED).toBe("CANCELLED"); }); }); - describe('Task type', () => { - it('should create a valid Task with required fields', () => { + describe("Task type", () => { + it("should create a valid Task with required fields", () => { const task: Task = { - id: 'task-123', - agentId: 'agent-1', - taskState: { language: 'Python', step: 1 }, - metadata: { priority: 'high', tags: ['bug'] }, + id: "task-123", + agentId: "agent-1", + taskState: { language: "Python", step: 1 }, + metadata: { priority: "high", tags: ["bug"] }, }; - expect(task.id).toBe('task-123'); - expect(task.agentId).toBe('agent-1'); - expect(task.taskState).toEqual({ language: 'Python', step: 1 }); - expect(task.metadata).toEqual({ priority: 'high', tags: ['bug'] }); + expect(task.id).toBe("task-123"); + expect(task.agentId).toBe("agent-1"); + expect(task.taskState).toEqual({ language: "Python", step: 1 }); + expect(task.metadata).toEqual({ priority: "high", tags: ["bug"] }); }); - it('should allow empty objects for taskState and metadata', () => { + it("should allow empty objects for taskState and metadata", () => { const task: Task = { - id: 'task-1', - agentId: 'agent-1', + id: "task-1", + agentId: "agent-1", taskState: {}, metadata: {}, }; diff --git a/orchestrator/packages/task-source/src/types.ts b/orchestrator/packages/task-source/src/types.ts index aca24b0..3fec85a 100644 --- a/orchestrator/packages/task-source/src/types.ts +++ b/orchestrator/packages/task-source/src/types.ts @@ -1,10 +1,10 @@ // Task Status enum - used by task source implementations internally export const TaskStatus = { - OPEN: 'OPEN', - IN_PROGRESS: 'IN_PROGRESS', - COMPLETED: 'COMPLETED', - FAILED: 'FAILED', - CANCELLED: 'CANCELLED', + OPEN: "OPEN", + IN_PROGRESS: "IN_PROGRESS", + COMPLETED: "COMPLETED", + FAILED: "FAILED", + CANCELLED: "CANCELLED", } as const; export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus]; diff --git a/orchestrator/packages/task-source/vite.config.ts b/orchestrator/packages/task-source/vite.config.ts index 1c84696..d2885d6 100644 --- a/orchestrator/packages/task-source/vite.config.ts +++ b/orchestrator/packages/task-source/vite.config.ts @@ -1,10 +1,10 @@ -import pkg from './package.json'; +import pkg from "./package.json"; // @ts-ignore -import tsconfig from './tsconfig.json'; -import base from '../../vite.base'; +import tsconfig from "./tsconfig.json"; +import base from "../../vite.base"; export default base({ - name: 'task-source', + name: "task-source", pkg, tsconfig, }); diff --git a/orchestrator/vite.base.ts b/orchestrator/vite.base.ts index 4c32fc7..8f8d95c 100644 --- a/orchestrator/vite.base.ts +++ b/orchestrator/vite.base.ts @@ -1,5 +1,5 @@ -import { defineConfig } from 'vite'; -import dts from 'vite-plugin-dts'; +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; export default ({ name, @@ -13,26 +13,26 @@ export default ({ defineConfig({ plugins: [ dts({ - tsconfigPath: './tsconfig.json', + tsconfigPath: "./tsconfig.json", }), ], build: { lib: { - entry: './src/index.ts', + entry: "./src/index.ts", name, - formats: ['es'], - fileName: 'index', + formats: ["es"], + fileName: "index", }, rollupOptions: { external: [ - ...Object.keys(('dependencies' in pkg && pkg.dependencies) ?? {}), - ...Object.keys(('peerDependencies' in pkg && pkg.peerDependencies) ?? {}), - ...(('references' in tsconfig && tsconfig.references) || []).map((x: any) => x.path), + ...Object.keys(("dependencies" in pkg && pkg.dependencies) ?? {}), + ...Object.keys(("peerDependencies" in pkg && pkg.peerDependencies) ?? {}), + ...(("references" in tsconfig && tsconfig.references) || []).map((x: any) => x.path), /^node:.*$/, /node_modules/, ], }, - target: 'node20', + target: "node20", emptyOutDir: true, }, }); diff --git a/orchestrator/vitest.config.ts b/orchestrator/vitest.config.ts index a2bd6f4..e106f2f 100644 --- a/orchestrator/vitest.config.ts +++ b/orchestrator/vitest.config.ts @@ -1,9 +1,9 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - environment: 'node', - setupFiles: './vitest.setup.ts', + environment: "node", + setupFiles: "./vitest.setup.ts", }, }); diff --git a/orchestrator/prettier.config.mjs b/prettier.config.mjs similarity index 86% rename from orchestrator/prettier.config.mjs rename to prettier.config.mjs index 54c8d4d..cd9330e 100644 --- a/orchestrator/prettier.config.mjs +++ b/prettier.config.mjs @@ -3,7 +3,7 @@ const config = { semi: true, singleQuote: false, tabWidth: 2, - trailingComma: true, + trailingComma: "all", printWidth: 100, }; From f367f514f925b7212d43d5ba3f4ab095d339a5d4 Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Thu, 7 May 2026 23:35:06 -0500 Subject: [PATCH 11/33] prettier --- bifrost/ui/.prettierignore | 1 + bifrost/ui/index.html | 2 +- bifrost/ui/oxlint.config.ts | 10 +- bifrost/ui/playwright.config.ts | 12 +- bifrost/ui/src/components/Dialog/Dialog.tsx | 12 +- .../RealmSelector/RealmSelector.spec.tsx | 12 +- .../RealmSelector/RealmSelector.tsx | 2 +- .../ui/src/components/Toast/Toast.spec.tsx | 8 +- bifrost/ui/src/components/Toast/Toast.tsx | 8 +- .../ui/src/components/TopNav/TopNav.spec.tsx | 32 +- bifrost/ui/src/components/TopNav/TopNav.tsx | 32 +- .../ui/src/components/Wizard/Wizard.spec.tsx | 8 +- bifrost/ui/src/components/Wizard/Wizard.tsx | 50 +-- bifrost/ui/src/index.css | 15 +- bifrost/ui/src/lib/api.spec.ts | 79 ++-- bifrost/ui/src/lib/api.ts | 82 ++-- bifrost/ui/src/lib/auth.tsx | 13 +- bifrost/ui/src/lib/realm.tsx | 34 +- bifrost/ui/src/lib/router.ts | 5 +- bifrost/ui/src/lib/theme.tsx | 15 +- bifrost/ui/src/lib/toast.tsx | 21 +- bifrost/ui/src/main.tsx | 2 +- bifrost/ui/src/pages/+Layout.tsx | 3 +- bifrost/ui/src/pages/+config.ts | 6 +- bifrost/ui/src/pages/_error/+Page.tsx | 9 +- bifrost/ui/src/pages/account/+Page.tsx | 192 ++++---- bifrost/ui/src/pages/accounts/+Page.tsx | 190 ++++---- bifrost/ui/src/pages/accounts/@id/+Page.tsx | 105 +++-- bifrost/ui/src/pages/accounts/@id/+config.ts | 2 +- bifrost/ui/src/pages/accounts/new/+Page.tsx | 49 +- bifrost/ui/src/pages/dashboard/+Page.tsx | 30 +- bifrost/ui/src/pages/login/+Page.tsx | 82 ++-- bifrost/ui/src/pages/onboarding/+Page.tsx | 140 +++--- bifrost/ui/src/pages/realms/+Page.tsx | 184 ++++---- bifrost/ui/src/pages/realms/@id/+Page.tsx | 420 +++++++++--------- bifrost/ui/src/pages/realms/@id/+config.ts | 2 +- bifrost/ui/src/pages/realms/new/+Page.tsx | 148 +++--- bifrost/ui/src/pages/runes/+Page.tsx | 29 +- bifrost/ui/src/pages/runes/@id/+Page.tsx | 77 ++-- bifrost/ui/src/pages/runes/@id/+config.ts | 2 +- bifrost/ui/src/pages/runes/@id/edit/+Page.tsx | 30 +- bifrost/ui/src/pages/runes/new/+Page.tsx | 72 ++- bifrost/ui/src/types/account.ts | 1 - bifrost/ui/src/types/realm.ts | 2 +- bifrost/ui/src/types/rune.ts | 1 - bifrost/ui/vite.config.ts | 24 +- bifrost/ui/vitest.config.ts | 12 +- bifrost/ui/vitest.setup.ts | 3 +- 48 files changed, 1140 insertions(+), 1130 deletions(-) create mode 100644 bifrost/ui/.prettierignore diff --git a/bifrost/ui/.prettierignore b/bifrost/ui/.prettierignore new file mode 100644 index 0000000..884a232 --- /dev/null +++ b/bifrost/ui/.prettierignore @@ -0,0 +1 @@ +**/dist/** diff --git a/bifrost/ui/index.html b/bifrost/ui/index.html index 052662e..4334b8f 100644 --- a/bifrost/ui/index.html +++ b/bifrost/ui/index.html @@ -1,4 +1,4 @@ - + diff --git a/bifrost/ui/oxlint.config.ts b/bifrost/ui/oxlint.config.ts index b9f0ace..299aae5 100644 --- a/bifrost/ui/oxlint.config.ts +++ b/bifrost/ui/oxlint.config.ts @@ -1,10 +1,10 @@ -import { defineConfig } from 'oxlint'; +import { defineConfig } from "oxlint"; export default defineConfig({ - plugins: ['typescript', 'react'], + plugins: ["typescript", "react"], rules: { - 'no-console': 'warn', - 'no-unused-vars': 'error', - 'react-hooks/exhaustive-deps': 'error', + "no-console": "warn", + "no-unused-vars": "error", + "react-hooks/exhaustive-deps": "error", }, }); diff --git a/bifrost/ui/playwright.config.ts b/bifrost/ui/playwright.config.ts index c31b0f3..1c1d68f 100644 --- a/bifrost/ui/playwright.config.ts +++ b/bifrost/ui/playwright.config.ts @@ -1,17 +1,17 @@ -import { defineConfig } from '@playwright/test'; +import { defineConfig } from "@playwright/test"; export default defineConfig({ - testDir: './tests', + testDir: "./tests", fullyParallel: true, forbidOnly: true, retries: 0, use: { - baseURL: 'http://localhost:3002', - trace: 'on-first-retry', + baseURL: "http://localhost:3002", + trace: "on-first-retry", headless: true, launchOptions: { - executablePath: '/home/blake/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome', + executablePath: "/home/blake/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome", }, }, - projects: ['Bifrost UI'], + projects: ["Bifrost UI"], }); diff --git a/bifrost/ui/src/components/Dialog/Dialog.tsx b/bifrost/ui/src/components/Dialog/Dialog.tsx index b2b86ab..63fe887 100644 --- a/bifrost/ui/src/components/Dialog/Dialog.tsx +++ b/bifrost/ui/src/components/Dialog/Dialog.tsx @@ -18,25 +18,29 @@ const colorStyles = { border: "border-blue-500", bg: "bg-white dark:bg-gray-800", confirm: "border-blue-500 bg-blue-500 text-white hover:bg-blue-600", - cancel: "border-gray-400 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700", + cancel: + "border-gray-400 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700", }, green: { border: "border-green-500", bg: "bg-white dark:bg-gray-800", confirm: "border-green-500 bg-green-500 text-white hover:bg-green-600", - cancel: "border-gray-400 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700", + cancel: + "border-gray-400 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700", }, red: { border: "border-red-500", bg: "bg-white dark:bg-gray-800", confirm: "border-red-500 bg-red-500 text-white hover:bg-red-600", - cancel: "border-gray-400 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700", + cancel: + "border-gray-400 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700", }, yellow: { border: "border-yellow-500", bg: "bg-white dark:bg-gray-800", confirm: "border-yellow-500 bg-yellow-500 text-white hover:bg-yellow-600", - cancel: "border-gray-400 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700", + cancel: + "border-gray-400 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700", }, }; diff --git a/bifrost/ui/src/components/RealmSelector/RealmSelector.spec.tsx b/bifrost/ui/src/components/RealmSelector/RealmSelector.spec.tsx index 6f67c14..c01fa4e 100644 --- a/bifrost/ui/src/components/RealmSelector/RealmSelector.spec.tsx +++ b/bifrost/ui/src/components/RealmSelector/RealmSelector.spec.tsx @@ -19,9 +19,7 @@ vi.mock("../../lib/realm", () => ({ import { useRealm } from "../../lib/realm"; // Helper function to create complete RealmContextValue mock -const createMockRealmValue = ( - overrides: Partial = {}, -): RealmContextValue => ({ +const createMockRealmValue = (overrides: Partial = {}): RealmContextValue => ({ currentRealm: "test-realm", setCurrentRealm: vi.fn(), availableRealms: ["test-realm", "other-realm"], @@ -40,13 +38,9 @@ describe("RealmSelector", () => { describe("Loading State", () => { test("shows loading message when isLoading is true", () => { - vi.mocked(useRealm).mockReturnValue( - createMockRealmValue({ isLoading: true }), - ); + vi.mocked(useRealm).mockReturnValue(createMockRealmValue({ isLoading: true })); render(); - expect( - screen.getByText("Loading realms..."), - ).toBeInTheDocument(); + expect(screen.getByText("Loading realms...")).toBeInTheDocument(); }); }); diff --git a/bifrost/ui/src/components/RealmSelector/RealmSelector.tsx b/bifrost/ui/src/components/RealmSelector/RealmSelector.tsx index 6e1f924..3ebcecb 100644 --- a/bifrost/ui/src/components/RealmSelector/RealmSelector.tsx +++ b/bifrost/ui/src/components/RealmSelector/RealmSelector.tsx @@ -10,7 +10,7 @@ export function RealmSelector() { const selectedRealm = currentRealm && availableRealms.includes(currentRealm) ? currentRealm - : availableRealms[0] ?? ""; + : (availableRealms[0] ?? ""); const items = Object.fromEntries(options.map((option) => [option.id, option.name])); if (isLoading) { diff --git a/bifrost/ui/src/components/Toast/Toast.spec.tsx b/bifrost/ui/src/components/Toast/Toast.spec.tsx index d1c80b9..d673a17 100644 --- a/bifrost/ui/src/components/Toast/Toast.spec.tsx +++ b/bifrost/ui/src/components/Toast/Toast.spec.tsx @@ -6,9 +6,7 @@ import { type Toast as ToastType } from "@/lib/toast"; describe("Toast", () => { const mockOnRemove = vi.fn(); - const createMockToast = ( - overrides: Partial = {}, - ): ToastType => ({ + const createMockToast = (overrides: Partial = {}): ToastType => ({ id: "test-123", title: "Test Toast", description: "This is a test toast message", @@ -38,9 +36,7 @@ describe("Toast", () => { description: "Operation completed successfully", }); render(); - expect( - screen.getByText("Operation completed successfully"), - ).toBeInTheDocument(); + expect(screen.getByText("Operation completed successfully")).toBeInTheDocument(); }); test("does not render description when not provided", () => { diff --git a/bifrost/ui/src/components/Toast/Toast.tsx b/bifrost/ui/src/components/Toast/Toast.tsx index d24695f..e984ab6 100644 --- a/bifrost/ui/src/components/Toast/Toast.tsx +++ b/bifrost/ui/src/components/Toast/Toast.tsx @@ -29,13 +29,9 @@ export function Toast({ toast, onRemove }: ToastItemProps) {
{iconStyles[toast.type]}
-
- {toast.title} -
+
{toast.title}
{toast.description && ( -
- {toast.description} -
+
{toast.description}
)}
)}
- ); -} +}; // Step content components type StepContentProps = { @@ -474,30 +278,26 @@ type StepContentProps = { color: string; }; -function StepContent({ children }: StepContentProps) { - return
{children}
; -} +const StepContent = ({ children }: StepContentProps) => ( +
{children}
+); type StepHeaderProps = { children: string; color: string; }; -function StepHeader({ children, color }: StepHeaderProps) { - return ( -

- {children} -

- ); -} +const StepHeader = ({ children, color }: StepHeaderProps) => ( +

+ {children} +

+); -function StepDescription({ children }: { children: string }) { - return ( -

- {children} -

- ); -} +const StepDescription = ({ children }: { children: string }) => ( +

+ {children} +

+); type FormFieldProps = { label: string; @@ -507,7 +307,7 @@ type FormFieldProps = { disabled: boolean; }; -function FormField({ label, value, onChange, placeholder, disabled }: FormFieldProps) { +const FormField = ({ label, value, onChange, placeholder, disabled }: FormFieldProps) => { const fieldId = label.toLowerCase().replace(/\s+/g, "-"); const inputRef = useRef(null); @@ -530,7 +330,7 @@ function FormField({ label, value, onChange, placeholder, disabled }: FormFieldP id={fieldId} type="text" value={value} - onChange={(e) => onChange(e.target.value)} + onChange={(event) => onChange(event.target.value)} placeholder={placeholder} disabled={disabled} className="w-full px-4 py-3 text-sm transition-all duration-150" @@ -540,18 +340,18 @@ function FormField({ label, value, onChange, placeholder, disabled }: FormFieldP color: "var(--color-text)", boxShadow: "var(--shadow-soft)", }} - onFocus={(e) => { - e.currentTarget.style.boxShadow = "var(--shadow-soft-hover)"; - e.currentTarget.style.transform = "translate(2px, 2px)"; + onFocus={(event) => { + event.currentTarget.style.boxShadow = "var(--shadow-soft-hover)"; + event.currentTarget.style.transform = "translate(2px, 2px)"; }} - onBlur={(e) => { - e.currentTarget.style.boxShadow = "var(--shadow-soft)"; - e.currentTarget.style.transform = "translate(0, 0)"; + onBlur={(event) => { + event.currentTarget.style.boxShadow = "var(--shadow-soft)"; + event.currentTarget.style.transform = "translate(0, 0)"; }} /> ); -} +}; type PATDisplayProps = { pat: string; @@ -559,44 +359,250 @@ type PATDisplayProps = { onCopy: () => void; }; -function PATDisplay({ pat, copied, onCopy }: PATDisplayProps) { +const PATDisplay = ({ pat, copied, onCopy }: PATDisplayProps) => ( +
+
+ {pat} +
+ +

+ ⚠️ Store this token securely. It won't be shown again. +

+
+); + +const Page = () => { + const [username, setUsername] = useState(""); + const [realmName, setRealmName] = useState(""); + const [adminResponse, setAdminResponse] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [copied, setCopied] = useState(false); + const [isCompleting, setIsCompleting] = useState(false); + const { showToast } = useToast(); + const { login } = useAuth(); + + const handleCreateAdmin = useCallback(async () => { + if (!username.trim()) { + showToast("Error", "Username is required", "error"); + return false; + } + + if (!realmName.trim()) { + showToast("Error", "Realm name is required", "error"); + return false; + } + + setIsLoading(true); + try { + const response = await api.createAdmin({ + username: username.trim(), + realm_name: realmName.trim(), + create_sysadmin: true, + create_realm: true, + }); + setAdminResponse(response); + return true; + } catch { + showToast("Error", "Failed to create admin account", "error"); + return false; + } finally { + setIsLoading(false); + } + }, [username, realmName, showToast]); + + const handleCopyPAT = useCallback(async () => { + if (adminResponse?.pat) { + await navigator.clipboard.writeText(adminResponse.pat); + setCopied(true); + showToast("Copied!", "PAT copied to clipboard", "success"); + setTimeout(() => setCopied(false), 2000); + } + }, [adminResponse, showToast]); + + const handleComplete = useCallback(async () => { + if (!adminResponse?.pat) { + showToast("Error", "No access token available. Please sign in.", "error"); + navigate("/login"); + return; + } + + setIsCompleting(true); + try { + // Auto-login with the PAT that was generated during onboarding + await login(adminResponse.pat, true); + navigate("/dashboard"); + } catch { + showToast("Error", "Failed to auto-login. Please sign in manually.", "error"); + navigate("/login"); + } finally { + setIsCompleting(false); + } + }, [adminResponse, login, showToast]); + + const stepColors = [ + "var(--color-red)", + "var(--color-blue)", + "var(--color-green)", + "var(--color-purple)", + ]; + + const steps = [ + { + title: "Admin Account", + content: ( + + Create Your Admin Account + + This will be your primary administrator account for managing Bifrost. + + + + ), + }, + { + title: "Create Realm", + content: ( + + Create Your First Realm + + A realm is an isolated workspace for managing runes (issues, tasks, bugs). + + + + ), + }, + { + title: "Access Token", + content: ( + + Your Personal Access Token + + Save this token securely. You'll need it to authenticate with Bifrost. + + {adminResponse ? ( + + ) : ( +
+

Click Next to generate your token...

+
+ )} +
+ ), + }, + { + title: "Complete", + content: ( + + You're All Set! + + Your Bifrost instance is ready to use. Start creating and managing runes. + +
+
+

Setup Summary

+

+ Admin: {username} +

+

+ Realm: {realmName} +

+
+
+
+ ), + }, + ]; + + const handleWizardNext = useCallback( + async (currentStep: number) => { + // Step 2 (index 1) is Create Realm - generate PAT when advancing to step 3 + if (currentStep === 1 && !adminResponse) { + return handleCreateAdmin(); + } + return true; + }, + [adminResponse, handleCreateAdmin], + ); + return ( -
-
- {pat} +
+
+ {/* Header */} +
+

+ Bifrost +

+

+ First-Time Setup +

+
+ + {/* Wizard Card */} +
+ +
- -

- ⚠️ Store this token securely. It won't be shown again. -

); -} +}; + +export { Page }; diff --git a/bifrost/ui/src/pages/realms/+Page.tsx b/bifrost/ui/src/pages/realms/+Page.tsx index b9e3419..1264ee9 100644 --- a/bifrost/ui/src/pages/realms/+Page.tsx +++ b/bifrost/ui/src/pages/realms/+Page.tsx @@ -12,9 +12,7 @@ import { useToast } from "../../lib/toast"; import { api } from "../../lib/api"; import type { RealmListEntry, RealmStatus } from "../../types/realm"; -export { Page }; - -function Page() { +const Page = () => { const [realms, setRealms] = useState([]); const [isLoading, setIsLoading] = useState(true); const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all"); @@ -76,7 +74,9 @@ function Page() { ); useEffect(() => { - if (authLoading) {return;} + if (authLoading) { + return; + } if (!isAuthenticated) { navigate("/login"); @@ -194,8 +194,7 @@ function Page() {
{ - const nextFilter = values[0]; + onValueChange={([nextFilter]) => { if (nextFilter === "all" || nextFilter === "active" || nextFilter === "inactive") { setStatusFilter(nextFilter); } @@ -247,15 +246,15 @@ function Page() { color: "var(--color-text)", boxShadow: "var(--shadow-soft)", }} - onMouseEnter={(e) => { - e.currentTarget.style.backgroundColor = "var(--color-green)"; - e.currentTarget.style.color = "white"; - e.currentTarget.style.boxShadow = "var(--shadow-soft-hover)"; + onMouseEnter={(event) => { + event.currentTarget.style.backgroundColor = "var(--color-green)"; + event.currentTarget.style.color = "white"; + event.currentTarget.style.boxShadow = "var(--shadow-soft-hover)"; }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = "var(--color-bg)"; - e.currentTarget.style.color = "var(--color-text)"; - e.currentTarget.style.boxShadow = "var(--shadow-soft)"; + onMouseLeave={(event) => { + event.currentTarget.style.backgroundColor = "var(--color-bg)"; + event.currentTarget.style.color = "var(--color-text)"; + event.currentTarget.style.boxShadow = "var(--shadow-soft)"; }} > + @@ -304,7 +303,7 @@ function Page() { setNewRealmName(e.target.value)} + onChange={(event) => setNewRealmName(event.target.value)} placeholder="Engineering" className="w-full px-3 py-2 text-sm outline-none" style={{ @@ -390,15 +389,15 @@ function Page() { textAlign: "left", }} onClick={() => navigate(`/realms/${realm.id}`)} - onMouseEnter={(e) => { - e.currentTarget.style.backgroundColor = "var(--color-surface)"; - e.currentTarget.style.borderLeftWidth = "4px"; - e.currentTarget.style.borderLeftColor = "var(--color-green)"; - e.currentTarget.style.borderLeftStyle = "solid"; + onMouseEnter={(event) => { + event.currentTarget.style.backgroundColor = "var(--color-surface)"; + event.currentTarget.style.borderLeftWidth = "4px"; + event.currentTarget.style.borderLeftColor = "var(--color-green)"; + event.currentTarget.style.borderLeftStyle = "solid"; }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = "var(--color-bg)"; - e.currentTarget.style.borderLeftWidth = "0px"; + onMouseLeave={(event) => { + event.currentTarget.style.backgroundColor = "var(--color-bg)"; + event.currentTarget.style.borderLeftWidth = "0px"; }} >
@@ -432,4 +431,6 @@ function Page() {
); -} +}; + +export { Page }; diff --git a/bifrost/ui/src/pages/realms/@id/+Page.tsx b/bifrost/ui/src/pages/realms/@id/+Page.tsx index 18ff652..4c8b9a8 100644 --- a/bifrost/ui/src/pages/realms/@id/+Page.tsx +++ b/bifrost/ui/src/pages/realms/@id/+Page.tsx @@ -15,8 +15,6 @@ import type { RealmDetail, RealmStatus } from "../../../types/realm"; import type { RuneListItem, RuneStatus } from "../../../types/rune"; import type { AdminAccountEntry } from "../../../types/account"; -export { Page }; - const realmStatusColors: Record = { active: { bg: "var(--color-green)", @@ -58,7 +56,7 @@ const runeStatusColors: Record { const pageContext = usePageContext(); const routeParams = pageContext.routeParams as Record; const realmId = routeParams?.id ?? routeParams?.["@id"] ?? routeParams?.["-id"] ?? ""; @@ -106,12 +104,14 @@ function Page() { return null; } - const memberCount = - typeof rawRealm.member_count === "number" - ? rawRealm.member_count - : Array.isArray(rawRealm.members) - ? rawRealm.members.length - : 0; + let memberCount = 0; + if (typeof rawRealm.member_count === "number") { + memberCount = rawRealm.member_count; + } else if (Array.isArray(rawRealm.members)) { + memberCount = rawRealm.members.length; + } else { + memberCount = 0; + } return { id, @@ -167,7 +167,9 @@ function Page() { }, []); useEffect(() => { - if (authLoading) {return;} + if (authLoading) { + return; + } if (!isAuthenticated) { navigate("/login"); @@ -216,7 +218,9 @@ function Page() { ]); const handleSuspend = async () => { - if (!realm) {return;} + if (!realm) { + return; + } setIsSuspending(true); setIsLoading(true); @@ -363,13 +367,13 @@ function Page() { color: "white", boxShadow: "var(--shadow-soft)", }} - onMouseEnter={(e) => { - e.currentTarget.style.boxShadow = "var(--shadow-soft-hover)"; - e.currentTarget.style.transform = "translate(2px, 2px)"; + onMouseEnter={(event) => { + event.currentTarget.style.boxShadow = "var(--shadow-soft-hover)"; + event.currentTarget.style.transform = "translate(2px, 2px)"; }} - onMouseLeave={(e) => { - e.currentTarget.style.boxShadow = "var(--shadow-soft)"; - e.currentTarget.style.transform = "translate(0, 0)"; + onMouseLeave={(event) => { + event.currentTarget.style.boxShadow = "var(--shadow-soft)"; + event.currentTarget.style.transform = "translate(0, 0)"; }} > Back to Realms @@ -527,13 +531,13 @@ function Page() { color: "white", boxShadow: "var(--shadow-soft)", }} - onMouseEnter={(e) => { - e.currentTarget.style.boxShadow = "var(--shadow-soft-hover)"; - e.currentTarget.style.transform = "translate(2px, 2px)"; + onMouseEnter={(event) => { + event.currentTarget.style.boxShadow = "var(--shadow-soft-hover)"; + event.currentTarget.style.transform = "translate(2px, 2px)"; }} - onMouseLeave={(e) => { - e.currentTarget.style.boxShadow = "var(--shadow-soft)"; - e.currentTarget.style.transform = "translate(0, 0)"; + onMouseLeave={(event) => { + event.currentTarget.style.boxShadow = "var(--shadow-soft)"; + event.currentTarget.style.transform = "translate(0, 0)"; }} > Suspend Realm @@ -753,15 +757,15 @@ function Page() { textAlign: "left", }} onClick={() => navigate(`/runes/${rune.id}`)} - onMouseEnter={(e) => { - e.currentTarget.style.backgroundColor = "var(--color-surface)"; - e.currentTarget.style.borderLeftWidth = "4px"; - e.currentTarget.style.borderLeftColor = "var(--color-green)"; - e.currentTarget.style.borderLeftStyle = "solid"; + onMouseEnter={(event) => { + event.currentTarget.style.backgroundColor = "var(--color-surface)"; + event.currentTarget.style.borderLeftWidth = "4px"; + event.currentTarget.style.borderLeftColor = "var(--color-green)"; + event.currentTarget.style.borderLeftStyle = "solid"; }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = "var(--color-bg)"; - e.currentTarget.style.borderLeftWidth = "0px"; + onMouseLeave={(event) => { + event.currentTarget.style.backgroundColor = "var(--color-bg)"; + event.currentTarget.style.borderLeftWidth = "0px"; }} >
@@ -1021,4 +1025,6 @@ function Page() {
); -} +}; + +export { Page }; diff --git a/bifrost/ui/src/pages/realms/new/+Page.tsx b/bifrost/ui/src/pages/realms/new/+Page.tsx index 7e3eba0..9adcc3d 100644 --- a/bifrost/ui/src/pages/realms/new/+Page.tsx +++ b/bifrost/ui/src/pages/realms/new/+Page.tsx @@ -1,14 +1,12 @@ "use client"; import { useState } from "react"; -import { Button } from "@base-ui/react/button"; import { Input } from "@base-ui/react/input"; import { navigate } from "@/lib/router"; import { useAuth } from "../../../lib/auth"; import { useToast } from "../../../lib/toast"; import { api } from "../../../lib/api"; - -export { Page }; +import { Wizard, type WizardStep } from "../../../components/Wizard/Wizard"; type FormData = { name: string; @@ -20,308 +18,128 @@ const INITIAL_FORM: FormData = { description: "", }; -const STEPS = [ - { id: 1, label: "Name", field: "name" as const }, - { id: 2, label: "Description", field: "description" as const }, -]; - -function Page() { +const Page = () => { const { isAuthenticated, loading: authLoading } = useAuth(); const { showToast } = useToast(); - - const [step, setStep] = useState(0); - const [form, setForm] = useState(INITIAL_FORM); + const [formData, setFormData] = useState(INITIAL_FORM); const [isSubmitting, setIsSubmitting] = useState(false); - if (authLoading) { - return ( -
-
- Loading... -
-
- ); - } - - if (!isAuthenticated) { - navigate("/login"); - return null; - } - - const updateForm = (field: K, value: FormData[K]) => { - setForm((prev) => ({ ...prev, [field]: value })); - }; - - const canProceed = () => { - switch (step) { - case 0: - return form.name.trim().length >= 2; - case 1: - return true; // Description is optional - default: - return false; - } + const handleInputChange = (field: keyof FormData) => (value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); }; const handleSubmit = async () => { setIsSubmitting(true); - try { - const realm = await api.createRealm({ - name: form.name.trim(), - description: form.description.trim() || "", - }); - showToast("Realm Created", `"${realm.name}" has been created`, "success"); - navigate(`/realms/${realm.id}`); + await api.createRealm(formData); + showToast("Success", "Realm created successfully", "success"); + navigate("/realms"); } catch { showToast("Error", "Failed to create realm", "error"); + } finally { setIsSubmitting(false); } }; - const nextStep = () => { - if (step < STEPS.length - 1) { - setStep(step + 1); - } else { - handleSubmit(); - } - }; - - const prevStep = () => { - if (step > 0) { - setStep(step - 1); - } - }; - - return ( -
- {/* Header */} -
- -

- New Realm -

-

- Create a new workspace for your project -

-
- - {/* Progress Steps */} -
- {STEPS.map((s, idx) => ( -
- ))} -
- - {/* Wizard Card */} -
- {/* Step Title */} -
-

- {STEPS[step].label} -

- - Step {step + 1} of {STEPS.length} - + if (authLoading) { + return ( +
+
+
+

Loading...

+
+ ); + } - {/* Step Content */} -
- {step === 0 && ( -
- - updateForm("name", e.target.value)} - placeholder="e.g., my-project, acme-corp, team-alpha" - className="w-full px-4 py-3 text-lg outline-none transition-all duration-150" - style={{ - backgroundColor: "var(--color-surface)", - border: "2px solid var(--color-border)", - color: "var(--color-text)", - }} - onFocus={(e) => { - e.currentTarget.style.borderLeftWidth = "4px"; - e.currentTarget.style.borderLeftColor = "var(--color-green)"; - }} - onBlur={(e) => { - e.currentTarget.style.borderLeftWidth = "2px"; - e.currentTarget.style.borderLeftColor = "var(--color-border)"; - }} - autoFocus - /> -

- {form.name.length}/50 characters (minimum 2) -

-
- )} - - {step === 1 && ( -
- -