refactor(HAI-116): rename kb to hai across all packages, CLI, and docs
- Rename npm packages from @kb/* to @hai/* and update all workspace references - Rename CLI binary from kb to hai and config directory from .kb to .hai - Update dashboard UI branding, titles, and references from kb to hai - Update all test files, CI workflows, and documentation to reflect new naming - Run comprehensive grep verification to ensure no stale kb references remain
This commit is contained in:
@@ -1,14 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: hai-board
|
name: kb-board
|
||||||
description: Start and manage the hai dashboard web UI and AI engine. Use when asked to start the board, run the dashboard, enable the AI engine, or configure hai settings.
|
description: Start and manage the kb dashboard web UI and AI engine. Use when asked to start the board, run the dashboard, enable the AI engine, or configure kb settings.
|
||||||
---
|
---
|
||||||
|
|
||||||
# hai board
|
# kb board
|
||||||
|
|
||||||
## Start the dashboard
|
## Start the dashboard
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hai dashboard
|
kb dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
Opens the kanban board at http://localhost:4040 and starts the AI engine.
|
Opens the kanban board at http://localhost:4040 and starts the AI engine.
|
||||||
@@ -30,7 +30,7 @@ Run in two terminals:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1: start the server + engine
|
# Terminal 1: start the server + engine
|
||||||
hai dashboard
|
kb dashboard
|
||||||
|
|
||||||
# Terminal 2: watch-rebuild the React dashboard UI
|
# Terminal 2: watch-rebuild the React dashboard UI
|
||||||
pnpm dev:ui
|
pnpm dev:ui
|
||||||
@@ -38,7 +38,7 @@ pnpm dev:ui
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Settings are in `.hai/config.json`:
|
Settings are in `.kb/config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -61,13 +61,13 @@ Settings are in `.hai/config.json`:
|
|||||||
|
|
||||||
## Task storage
|
## Task storage
|
||||||
|
|
||||||
Tasks live in `.hai/tasks/`:
|
Tasks live in `.kb/tasks/`:
|
||||||
|
|
||||||
```
|
```
|
||||||
.hai/
|
.kb/
|
||||||
├── config.json
|
├── config.json
|
||||||
└── tasks/
|
└── tasks/
|
||||||
└── HAI-001/
|
└── KB-001/
|
||||||
├── task.json # Metadata, steps, log
|
├── task.json # Metadata, steps, log
|
||||||
└── PROMPT.md # Task specification
|
└── PROMPT.md # Task specification
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: hai-task
|
name: kb-task
|
||||||
description: Create, manage, and track tasks on the hai board. Use when asked to create a task, file a bug, report an issue, check task status, update progress, or interact with the hai task board in any way.
|
description: Create, manage, and track tasks on the kb board. Use when asked to create a task, file a bug, report an issue, check task status, update progress, or interact with the kb task board in any way.
|
||||||
---
|
---
|
||||||
|
|
||||||
# hai task
|
# kb task
|
||||||
|
|
||||||
hai is an AI-orchestrated task board. Tasks flow through columns:
|
kb is an AI-orchestrated task board. Tasks flow through columns:
|
||||||
**triage → todo → in-progress → in-review → done**
|
**triage → todo → in-progress → in-review → done**
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -13,9 +13,9 @@ hai is an AI-orchestrated task board. Tasks flow through columns:
|
|||||||
### Create a task
|
### Create a task
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hai task create "description of what needs to be done"
|
kb task create "description of what needs to be done"
|
||||||
hai task create "button is misaligned" --attach screenshot.png
|
kb task create "button is misaligned" --attach screenshot.png
|
||||||
hai task create "server crash" --attach error.log --attach trace.txt
|
kb task create "server crash" --attach error.log --attach trace.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Creates a task in **triage**. The AI triage agent will specify it into a full
|
Creates a task in **triage**. The AI triage agent will specify it into a full
|
||||||
@@ -25,7 +25,7 @@ move it to **todo**.
|
|||||||
Options:
|
Options:
|
||||||
- `--attach <file>` — attach files (images, logs, configs). Repeatable.
|
- `--attach <file>` — attach files (images, logs, configs). Repeatable.
|
||||||
Images are sent to the triage agent for visual context.
|
Images are sent to the triage agent for visual context.
|
||||||
Files are stored in `.hai/tasks/HAI-XXX/attachments/`.
|
Files are stored in `.kb/tasks/KB-XXX/attachments/`.
|
||||||
|
|
||||||
Tips:
|
Tips:
|
||||||
- Be descriptive — the triage agent uses this to write the spec
|
- Be descriptive — the triage agent uses this to write the spec
|
||||||
@@ -37,7 +37,7 @@ Tips:
|
|||||||
### List tasks
|
### List tasks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hai task list
|
kb task list
|
||||||
```
|
```
|
||||||
|
|
||||||
Shows all tasks grouped by column with IDs and descriptions.
|
Shows all tasks grouped by column with IDs and descriptions.
|
||||||
@@ -45,7 +45,7 @@ Shows all tasks grouped by column with IDs and descriptions.
|
|||||||
### Show task details
|
### Show task details
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hai task show HAI-001
|
kb task show KB-001
|
||||||
```
|
```
|
||||||
|
|
||||||
Shows full task info: steps, progress, log entries, dependencies.
|
Shows full task info: steps, progress, log entries, dependencies.
|
||||||
@@ -53,7 +53,7 @@ Shows full task info: steps, progress, log entries, dependencies.
|
|||||||
### Move a task
|
### Move a task
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hai task move HAI-001 <column>
|
kb task move KB-001 <column>
|
||||||
```
|
```
|
||||||
|
|
||||||
Columns: `triage`, `todo`, `in-progress`, `in-review`, `done`
|
Columns: `triage`, `todo`, `in-progress`, `in-review`, `done`
|
||||||
@@ -68,7 +68,7 @@ Transitions are validated:
|
|||||||
### Update step status
|
### Update step status
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hai task update HAI-001 <step-number> <status>
|
kb task update KB-001 <step-number> <status>
|
||||||
```
|
```
|
||||||
|
|
||||||
Status: `pending`, `in-progress`, `done`, `skipped`
|
Status: `pending`, `in-progress`, `done`, `skipped`
|
||||||
@@ -78,7 +78,7 @@ Steps are 0-indexed and auto-parsed from the PROMPT.md headings.
|
|||||||
### Log an entry
|
### Log an entry
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hai task log HAI-001 "what happened"
|
kb task log KB-001 "what happened"
|
||||||
```
|
```
|
||||||
|
|
||||||
Adds a timestamped log entry visible on the task card.
|
Adds a timestamped log entry visible on the task card.
|
||||||
@@ -86,7 +86,7 @@ Adds a timestamped log entry visible on the task card.
|
|||||||
### Merge a completed task
|
### Merge a completed task
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hai task merge HAI-001
|
kb task merge KB-001
|
||||||
```
|
```
|
||||||
|
|
||||||
Squash-merges the task's branch into main with an AI-written commit message.
|
Squash-merges the task's branch into main with an AI-written commit message.
|
||||||
@@ -95,12 +95,12 @@ Cleans up the worktree and branch after merge.
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. **Create** — `hai task create "description"` → goes to triage
|
1. **Create** — `kb task create "description"` → goes to triage
|
||||||
2. **Triage** — AI agent reads the codebase, writes a PROMPT.md spec, moves to todo
|
2. **Triage** — AI agent reads the codebase, writes a PROMPT.md spec, moves to todo
|
||||||
3. **Schedule** — Scheduler moves to in-progress when deps are met and concurrency allows
|
3. **Schedule** — Scheduler moves to in-progress when deps are met and concurrency allows
|
||||||
4. **Execute** — AI agent works the task in a git worktree, reports progress via tools
|
4. **Execute** — AI agent works the task in a git worktree, reports progress via tools
|
||||||
5. **Review** — Cross-model reviewer checks plan/code at step boundaries
|
5. **Review** — Cross-model reviewer checks plan/code at step boundaries
|
||||||
6. **Merge** — `hai task merge HAI-001` squash-merges to main
|
6. **Merge** — `kb task merge KB-001` squash-merges to main
|
||||||
|
|
||||||
## Filing good tasks
|
## Filing good tasks
|
||||||
|
|
||||||
@@ -108,14 +108,14 @@ A task can be anything from a rough idea to a detailed spec:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Rough — triage agent will flesh it out
|
# Rough — triage agent will flesh it out
|
||||||
hai task create "the login page is slow"
|
kb task create "the login page is slow"
|
||||||
|
|
||||||
# Specific — triage agent will structure it
|
# Specific — triage agent will structure it
|
||||||
hai task create "Add rate limiting to POST /api/tasks. Use a token bucket algorithm with 100 req/min per IP. Return 429 with Retry-After header when exceeded."
|
kb task create "Add rate limiting to POST /api/tasks. Use a token bucket algorithm with 100 req/min per IP. Return 429 with Retry-After header when exceeded."
|
||||||
|
|
||||||
# Bug report with screenshot
|
# Bug report with screenshot
|
||||||
hai task create "button is misaligned on mobile" --attach screenshot.png
|
kb task create "button is misaligned on mobile" --attach screenshot.png
|
||||||
|
|
||||||
# Bug report with logs
|
# Bug report with logs
|
||||||
hai task create "server crashes on startup" --attach crash.log
|
kb task create "server crashes on startup" --attach crash.log
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
"hai": minor
|
"@dustinbyrne/kb": minor
|
||||||
---
|
---
|
||||||
|
|
||||||
Add automated versioning pipeline using changesets. Developers now add changeset files to describe changes, and a CI workflow automatically opens version PRs that bump versions and generate changelogs.
|
Add automated versioning pipeline using changesets. Developers now add changeset files to describe changes, and a CI workflow automatically opens version PRs that bump versions and generate changelogs.
|
||||||
|
|||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
|||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
- name: Build standalone binary
|
- name: Build standalone binary
|
||||||
run: pnpm --filter hai build:exe
|
run: pnpm --filter @dustinbyrne/kb build:exe
|
||||||
|
|
||||||
- name: Verify binary exists
|
- name: Verify binary exists
|
||||||
run: test -f packages/cli/dist/hai
|
run: test -f packages/cli/dist/kb
|
||||||
|
|||||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -29,16 +29,16 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
target: bun-linux-x64
|
target: bun-linux-x64
|
||||||
binary: hai-linux-x64
|
binary: kb-linux-x64
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
target: bun-darwin-arm64
|
target: bun-darwin-arm64
|
||||||
binary: hai-darwin-arm64
|
binary: kb-darwin-arm64
|
||||||
- os: macos-13
|
- os: macos-13
|
||||||
target: bun-darwin-x64
|
target: bun-darwin-x64
|
||||||
binary: hai-darwin-x64
|
binary: kb-darwin-x64
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
target: bun-windows-x64
|
target: bun-windows-x64
|
||||||
binary: hai-windows-x64.exe
|
binary: kb-windows-x64.exe
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Build binary
|
- name: Build binary
|
||||||
run: pnpm --filter hai build:exe -- --target ${{ matrix.target }}
|
run: pnpm --filter @dustinbyrne/kb build:exe -- --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Verify binary exists
|
- name: Verify binary exists
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
- name: Collect release files
|
- name: Collect release files
|
||||||
run: |
|
run: |
|
||||||
mkdir release-files
|
mkdir release-files
|
||||||
find artifacts -type f \( -name "hai-*" -o -name "*.sha256" \) -exec cp {} release-files/ \;
|
find artifacts -type f \( -name "kb-*" -o -name "*.sha256" \) -exec cp {} release-files/ \;
|
||||||
ls -la release-files/
|
ls -la release-files/
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
|
|||||||
12
.github/workflows/test-release.yml
vendored
12
.github/workflows/test-release.yml
vendored
@@ -19,16 +19,16 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
target: bun-linux-x64
|
target: bun-linux-x64
|
||||||
binary: hai-linux-x64
|
binary: kb-linux-x64
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
target: bun-darwin-arm64
|
target: bun-darwin-arm64
|
||||||
binary: hai-darwin-arm64
|
binary: kb-darwin-arm64
|
||||||
- os: macos-13
|
- os: macos-13
|
||||||
target: bun-darwin-x64
|
target: bun-darwin-x64
|
||||||
binary: hai-darwin-x64
|
binary: kb-darwin-x64
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
target: bun-windows-x64
|
target: bun-windows-x64
|
||||||
binary: hai-windows-x64.exe
|
binary: kb-windows-x64.exe
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Build binary
|
- name: Build binary
|
||||||
run: pnpm --filter hai build:exe -- --target ${{ matrix.target }}
|
run: pnpm --filter @dustinbyrne/kb build:exe -- --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Verify binary exists
|
- name: Verify binary exists
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
- name: Combine artifacts
|
- name: Combine artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir combined
|
mkdir combined
|
||||||
find artifacts -type f \( -name "hai-*" -o -name "*.sha256" \) -exec cp {} combined/ \;
|
find artifacts -type f \( -name "kb-*" -o -name "*.sha256" \) -exec cp {} combined/ \;
|
||||||
ls -la combined/
|
ls -la combined/
|
||||||
|
|
||||||
- name: Upload combined archive
|
- name: Upload combined archive
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ dist/
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# hai runtime (local board state — don't commit)
|
# hai runtime (local board state — don't commit)
|
||||||
|
.kb/
|
||||||
.hai/
|
.hai/
|
||||||
|
|
||||||
# Taskplane runtime artifacts
|
# Taskplane runtime artifacts
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -1,4 +1,4 @@
|
|||||||
# hai
|
# kb
|
||||||
|
|
||||||
AI-orchestrated task board. Like Trello, but your tasks get specified, executed, and delivered by AI — powered by [pi](https://github.com/badlogic/pi-mono).
|
AI-orchestrated task board. Like Trello, but your tasks get specified, executed, and delivered by AI — powered by [pi](https://github.com/badlogic/pi-mono).
|
||||||
|
|
||||||
@@ -39,16 +39,16 @@ pnpm dev task create "Fix the login redirect bug"
|
|||||||
pnpm dev task list
|
pnpm dev task list
|
||||||
|
|
||||||
# Move a task
|
# Move a task
|
||||||
pnpm dev task move HAI-001 todo
|
pnpm dev task move KB-001 todo
|
||||||
|
|
||||||
# Pause a task (stops all automation)
|
# Pause a task (stops all automation)
|
||||||
pnpm dev task pause HAI-001
|
pnpm dev task pause KB-001
|
||||||
|
|
||||||
# Unpause a task (resumes automation)
|
# Unpause a task (resumes automation)
|
||||||
pnpm dev task unpause HAI-001
|
pnpm dev task unpause KB-001
|
||||||
|
|
||||||
# Attach a file to a task (images, logs, configs)
|
# Attach a file to a task (images, logs, configs)
|
||||||
pnpm dev task attach HAI-001 ./screenshot.png
|
pnpm dev task attach KB-001 ./screenshot.png
|
||||||
|
|
||||||
# Create a task with attachments
|
# Create a task with attachments
|
||||||
pnpm dev task create "Fix the login bug" -- --attach screenshot.png --attach error.log
|
pnpm dev task create "Fix the login bug" -- --attach screenshot.png --attach error.log
|
||||||
@@ -63,28 +63,28 @@ The AI engine uses [pi](https://github.com/badlogic/pi-mono) agent sessions unde
|
|||||||
1. **pi installed:** `npm install -g @mariozechner/pi-coding-agent`
|
1. **pi installed:** `npm install -g @mariozechner/pi-coding-agent`
|
||||||
2. **API key configured:** Run `pi` and use `/login` or set `ANTHROPIC_API_KEY`
|
2. **API key configured:** Run `pi` and use `/login` or set `ANTHROPIC_API_KEY`
|
||||||
|
|
||||||
hai reuses your existing pi authentication — no separate setup needed.
|
kb reuses your existing pi authentication — no separate setup needed.
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
| Package | Description |
|
| Package | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `@hai/core` | Domain model — tasks, board columns, file-based store |
|
| `@kb/core` | Domain model — tasks, board columns, file-based store |
|
||||||
| `@hai/dashboard` | Web UI — Express server + kanban board with SSE |
|
| `@kb/dashboard` | Web UI — Express server + kanban board with SSE |
|
||||||
| `@hai/engine` | AI engine — triage (pi), execution (pi + worktrees), scheduling |
|
| `@kb/engine` | AI engine — triage (pi), execution (pi + worktrees), scheduling |
|
||||||
| `hai` (cli) | CLI — `hai dashboard`, `hai task create/list/move/attach` |
|
| `kb` (cli) | CLI — `kb dashboard`, `kb task create/list/move/attach` |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Task Storage
|
### Task Storage
|
||||||
|
|
||||||
Tasks live on disk in `.hai/tasks/` in the project root:
|
Tasks live on disk in `.kb/tasks/` in the project root:
|
||||||
|
|
||||||
```
|
```
|
||||||
.hai/
|
.kb/
|
||||||
├── config.json # Board config + ID counter
|
├── config.json # Board config + ID counter
|
||||||
└── tasks/
|
└── tasks/
|
||||||
└── HAI-001/
|
└── KB-001/
|
||||||
├── task.json # Metadata (column, deps, timestamps)
|
├── task.json # Metadata (column, deps, timestamps)
|
||||||
├── PROMPT.md # Task specification
|
├── PROMPT.md # Task specification
|
||||||
└── attachments/ # File attachments — images & text files (optional)
|
└── attachments/ # File attachments — images & text files (optional)
|
||||||
@@ -144,7 +144,7 @@ pnpm dev task list # CLI commands
|
|||||||
|
|
||||||
## Building a standalone executable
|
## Building a standalone executable
|
||||||
|
|
||||||
You can build a single self-contained `hai` binary using [Bun](https://bun.sh/):
|
You can build a single self-contained `kb` binary using [Bun](https://bun.sh/):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm build:exe
|
pnpm build:exe
|
||||||
@@ -152,18 +152,18 @@ pnpm build:exe
|
|||||||
|
|
||||||
This compiles all TypeScript, builds the dashboard client, and produces:
|
This compiles all TypeScript, builds the dashboard client, and produces:
|
||||||
|
|
||||||
- `packages/cli/dist/hai` — the standalone binary
|
- `packages/cli/dist/kb` — the standalone binary
|
||||||
- `packages/cli/dist/client/` — co-located dashboard assets
|
- `packages/cli/dist/client/` — co-located dashboard assets
|
||||||
|
|
||||||
Run the binary directly — no Node.js, pnpm, or workspace setup needed:
|
Run the binary directly — no Node.js, pnpm, or workspace setup needed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./packages/cli/dist/hai --help
|
./packages/cli/dist/kb --help
|
||||||
./packages/cli/dist/hai task list
|
./packages/cli/dist/kb task list
|
||||||
./packages/cli/dist/hai dashboard
|
./packages/cli/dist/kb dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
To distribute, copy both the `hai` binary and the `client/` directory together.
|
To distribute, copy both the `kb` binary and the `client/` directory together.
|
||||||
|
|
||||||
### Cross-compilation
|
### Cross-compilation
|
||||||
|
|
||||||
@@ -177,24 +177,24 @@ This produces binaries for all supported targets in `packages/cli/dist/`:
|
|||||||
|
|
||||||
| Target | Output |
|
| Target | Output |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| `bun-linux-x64` | `hai-linux-x64` |
|
| `bun-linux-x64` | `kb-linux-x64` |
|
||||||
| `bun-linux-arm64` | `hai-linux-arm64` |
|
| `bun-linux-arm64` | `kb-linux-arm64` |
|
||||||
| `bun-darwin-x64` | `hai-darwin-x64` |
|
| `bun-darwin-x64` | `kb-darwin-x64` |
|
||||||
| `bun-darwin-arm64` | `hai-darwin-arm64` |
|
| `bun-darwin-arm64` | `kb-darwin-arm64` |
|
||||||
| `bun-windows-x64` | `hai-windows-x64.exe` |
|
| `bun-windows-x64` | `kb-windows-x64.exe` |
|
||||||
|
|
||||||
To build for a specific platform:
|
To build for a specific platform:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter hai build:exe -- --target bun-linux-x64
|
pnpm --filter kb build:exe -- --target bun-linux-x64
|
||||||
```
|
```
|
||||||
|
|
||||||
The `client/` directory is shared across all binaries (platform-independent assets).
|
The `client/` directory is shared across all binaries (platform-independent assets).
|
||||||
|
|
||||||
You can override the dashboard asset path via the `HAI_CLIENT_DIR` environment variable:
|
You can override the dashboard asset path via the `KB_CLIENT_DIR` environment variable:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
HAI_CLIENT_DIR=/path/to/client ./hai dashboard
|
KB_CLIENT_DIR=/path/to/client ./kb dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
**Prerequisites:** Bun ≥ 1.0 (`bun --version`)
|
**Prerequisites:** Bun ≥ 1.0 (`bun --version`)
|
||||||
@@ -206,7 +206,7 @@ Packages are published to npm automatically via GitHub Actions and [changesets](
|
|||||||
### Installing from npm
|
### Installing from npm
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g hai
|
npm install -g kb
|
||||||
```
|
```
|
||||||
|
|
||||||
### Triggering a release
|
### Triggering a release
|
||||||
|
|||||||
16
RELEASING.md
16
RELEASING.md
@@ -36,7 +36,7 @@ When you merge the Version Packages PR:
|
|||||||
|
|
||||||
- The `version.yml` workflow detects that all changesets have been consumed
|
- The `version.yml` workflow detects that all changesets have been consumed
|
||||||
- It builds all packages and publishes them to **npm** with provenance attestation
|
- It builds all packages and publishes them to **npm** with provenance attestation
|
||||||
- It creates a git tag `v{version}` based on the `hai` CLI package version
|
- It creates a git tag `v{version}` based on the `kb` CLI package version
|
||||||
- The tag push triggers `release.yml`, which:
|
- The tag push triggers `release.yml`, which:
|
||||||
- Builds platform-specific binaries for Linux x64, macOS x64, macOS arm64, and Windows x64
|
- Builds platform-specific binaries for Linux x64, macOS x64, macOS arm64, and Windows x64
|
||||||
- Signs macOS binaries (codesign + notarization) and Windows binaries (Authenticode)
|
- Signs macOS binaries (codesign + notarization) and Windows binaries (Authenticode)
|
||||||
@@ -54,10 +54,10 @@ When you merge the Version Packages PR:
|
|||||||
|
|
||||||
| Platform | Binary name | Signed |
|
| Platform | Binary name | Signed |
|
||||||
|----------|------------|--------|
|
|----------|------------|--------|
|
||||||
| Linux x64 | `hai-linux-x64` | — |
|
| Linux x64 | `kb-linux-x64` | — |
|
||||||
| macOS arm64 | `hai-darwin-arm64` | ✓ (codesign + notarization) |
|
| macOS arm64 | `kb-darwin-arm64` | ✓ (codesign + notarization) |
|
||||||
| macOS x64 | `hai-darwin-x64` | ✓ (codesign + notarization) |
|
| macOS x64 | `kb-darwin-x64` | ✓ (codesign + notarization) |
|
||||||
| Windows x64 | `hai-windows-x64.exe` | ✓ (Authenticode) |
|
| Windows x64 | `kb-windows-x64.exe` | ✓ (Authenticode) |
|
||||||
|
|
||||||
## Testing binary builds
|
## Testing binary builds
|
||||||
|
|
||||||
@@ -85,9 +85,9 @@ This will trigger `release.yml` to build binaries and create a GitHub Release. N
|
|||||||
| `pnpm changeset` | Add a new changeset |
|
| `pnpm changeset` | Add a new changeset |
|
||||||
| `pnpm changeset status` | Check pending changesets |
|
| `pnpm changeset status` | Check pending changesets |
|
||||||
| `pnpm release:version` | Apply changesets and bump versions (used by CI) |
|
| `pnpm release:version` | Apply changesets and bump versions (used by CI) |
|
||||||
| `pnpm --filter hai build:exe` | Build binary for current platform |
|
| `pnpm --filter @dustinbyrne/kb build:exe` | Build binary for current platform |
|
||||||
| `pnpm --filter hai build:exe -- --target <target>` | Cross-compile for a specific platform |
|
| `pnpm --filter @dustinbyrne/kb build:exe -- --target <target>` | Cross-compile for a specific platform |
|
||||||
| `pnpm --filter hai build:exe:all` | Build binaries for all platforms |
|
| `pnpm --filter @dustinbyrne/kb build:exe:all` | Build binaries for all platforms |
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Code Signing Setup Guide
|
# Code Signing Setup Guide
|
||||||
|
|
||||||
This document explains how to configure code signing for `hai` release binaries so they don't trigger OS security warnings on macOS (Gatekeeper) or Windows (SmartScreen).
|
This document explains how to configure code signing for `kb` release binaries so they don't trigger OS security warnings on macOS (Gatekeeper) or Windows (SmartScreen).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ Your Team ID is visible at [developer.apple.com/account](https://developer.apple
|
|||||||
|
|
||||||
1. Go to [appleid.apple.com](https://appleid.apple.com/)
|
1. Go to [appleid.apple.com](https://appleid.apple.com/)
|
||||||
2. Sign in and navigate to **Sign-In and Security** → **App-Specific Passwords**
|
2. Sign in and navigate to **Sign-In and Security** → **App-Specific Passwords**
|
||||||
3. Generate a new password and label it (e.g., "hai notarization")
|
3. Generate a new password and label it (e.g., "kb notarization")
|
||||||
4. Use this as the `APPLE_APP_PASSWORD` secret
|
4. Use this as the `APPLE_APP_PASSWORD` secret
|
||||||
|
|
||||||
### 5. Determine Your Signing Identity
|
### 5. Determine Your Signing Identity
|
||||||
|
|||||||
572
package-lock.json
generated
572
package-lock.json
generated
@@ -1,572 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "hai-workspace",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "hai-workspace",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"devDependencies": {
|
|
||||||
"tsx": "^4.19.0",
|
|
||||||
"typescript": "^5.7.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
|
||||||
"version": "0.27.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
|
||||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.27.4",
|
|
||||||
"@esbuild/android-arm": "0.27.4",
|
|
||||||
"@esbuild/android-arm64": "0.27.4",
|
|
||||||
"@esbuild/android-x64": "0.27.4",
|
|
||||||
"@esbuild/darwin-arm64": "0.27.4",
|
|
||||||
"@esbuild/darwin-x64": "0.27.4",
|
|
||||||
"@esbuild/freebsd-arm64": "0.27.4",
|
|
||||||
"@esbuild/freebsd-x64": "0.27.4",
|
|
||||||
"@esbuild/linux-arm": "0.27.4",
|
|
||||||
"@esbuild/linux-arm64": "0.27.4",
|
|
||||||
"@esbuild/linux-ia32": "0.27.4",
|
|
||||||
"@esbuild/linux-loong64": "0.27.4",
|
|
||||||
"@esbuild/linux-mips64el": "0.27.4",
|
|
||||||
"@esbuild/linux-ppc64": "0.27.4",
|
|
||||||
"@esbuild/linux-riscv64": "0.27.4",
|
|
||||||
"@esbuild/linux-s390x": "0.27.4",
|
|
||||||
"@esbuild/linux-x64": "0.27.4",
|
|
||||||
"@esbuild/netbsd-arm64": "0.27.4",
|
|
||||||
"@esbuild/netbsd-x64": "0.27.4",
|
|
||||||
"@esbuild/openbsd-arm64": "0.27.4",
|
|
||||||
"@esbuild/openbsd-x64": "0.27.4",
|
|
||||||
"@esbuild/openharmony-arm64": "0.27.4",
|
|
||||||
"@esbuild/sunos-x64": "0.27.4",
|
|
||||||
"@esbuild/win32-arm64": "0.27.4",
|
|
||||||
"@esbuild/win32-ia32": "0.27.4",
|
|
||||||
"@esbuild/win32-x64": "0.27.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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/get-tsconfig": {
|
|
||||||
"version": "4.13.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
|
||||||
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"resolve-pkg-maps": "^1.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/resolve-pkg-maps": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tsx": {
|
|
||||||
"version": "4.21.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"esbuild": "~0.27.0",
|
|
||||||
"get-tsconfig": "^4.7.5"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"tsx": "dist/cli.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "~2.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "hai-workspace",
|
"name": "kb-workspace",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx packages/cli/src/bin.ts",
|
"dev": "tsx packages/cli/src/bin.ts",
|
||||||
"dev:ui": "pnpm --filter @hai/dashboard dev",
|
"dev:ui": "pnpm --filter @kb/dashboard dev",
|
||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
"build:exe": "pnpm build && pnpm --filter hai build:exe",
|
"build:exe": "pnpm build && pnpm --filter @dustinbyrne/kb build:exe",
|
||||||
"build:exe:all": "pnpm build && pnpm --filter hai build:exe:all",
|
"build:exe:all": "pnpm build && pnpm --filter @dustinbyrne/kb build:exe:all",
|
||||||
"test": "pnpm -r test",
|
"test": "pnpm -r test",
|
||||||
"typecheck": "pnpm -r typecheck",
|
"typecheck": "pnpm -r typecheck",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Bun compile build script for the `hai` CLI.
|
* Bun compile build script for the `kb` CLI.
|
||||||
*
|
*
|
||||||
* Produces a single self-contained executable at packages/cli/dist/hai
|
* Produces a single self-contained executable at packages/cli/dist/kb
|
||||||
* with the dashboard client assets co-located at packages/cli/dist/client/.
|
* with the dashboard client assets co-located at packages/cli/dist/client/.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
@@ -38,20 +38,20 @@ type BunTarget = (typeof SUPPORTED_TARGETS)[number];
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a Bun target identifier to the output binary name.
|
* Map a Bun target identifier to the output binary name.
|
||||||
* e.g. "bun-linux-x64" → "hai-linux-x64", "bun-windows-x64" → "hai-windows-x64.exe"
|
* e.g. "bun-linux-x64" → "kb-linux-x64", "bun-windows-x64" → "kb-windows-x64.exe"
|
||||||
*/
|
*/
|
||||||
function binaryNameForTarget(target: BunTarget): string {
|
function binaryNameForTarget(target: BunTarget): string {
|
||||||
// "bun-linux-x64" → "linux-x64"
|
// "bun-linux-x64" → "linux-x64"
|
||||||
const suffix = target.replace(/^bun-/, "");
|
const suffix = target.replace(/^bun-/, "");
|
||||||
const isWindows = target.includes("windows");
|
const isWindows = target.includes("windows");
|
||||||
return `hai-${suffix}${isWindows ? ".exe" : ""}`;
|
return `kb-${suffix}${isWindows ? ".exe" : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine the default binary name for the current platform (no cross-compile).
|
* Determine the default binary name for the current platform (no cross-compile).
|
||||||
*/
|
*/
|
||||||
function defaultBinaryName(): string {
|
function defaultBinaryName(): string {
|
||||||
return process.platform === "win32" ? "hai.exe" : "hai";
|
return process.platform === "win32" ? "kb.exe" : "kb";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Parse CLI arguments ───────────────────────────────────────────────
|
// ── Parse CLI arguments ───────────────────────────────────────────────
|
||||||
@@ -146,7 +146,7 @@ const { targets } = parseArgs();
|
|||||||
copyClientAssets();
|
copyClientAssets();
|
||||||
|
|
||||||
if (targets === null) {
|
if (targets === null) {
|
||||||
// Default: build for current platform → dist/hai
|
// Default: build for current platform → dist/kb
|
||||||
const outBinary = join(outDir, defaultBinaryName());
|
const outBinary = join(outDir, defaultBinaryName());
|
||||||
const ok = compileBinary(outBinary, "bun");
|
const ok = compileBinary(outBinary, "bun");
|
||||||
if (!ok) process.exit(1);
|
if (!ok) process.exit(1);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@dustinbyrne/hai",
|
"name": "@dustinbyrne/kb",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"hai": "./dist/bin.js"
|
"kb": "./dist/bin.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -18,9 +18,9 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hai/core": "workspace:*",
|
"@kb/core": "workspace:*",
|
||||||
"@hai/dashboard": "workspace:*",
|
"@kb/dashboard": "workspace:*",
|
||||||
"@hai/engine": "workspace:*",
|
"@kb/engine": "workspace:*",
|
||||||
"@mariozechner/pi-coding-agent": "^0.62.0"
|
"@mariozechner/pi-coding-agent": "^0.62.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const SUPPORTED_TARGETS = [
|
|||||||
function expectedBinaryName(target: string): string {
|
function expectedBinaryName(target: string): string {
|
||||||
const suffix = target.replace(/^bun-/, "");
|
const suffix = target.replace(/^bun-/, "");
|
||||||
const isWindows = target.includes("windows");
|
const isWindows = target.includes("windows");
|
||||||
return `hai-${suffix}${isWindows ? ".exe" : ""}`;
|
return `kb-${suffix}${isWindows ? ".exe" : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,8 +44,8 @@ describe("build-exe-cross: single target", () => {
|
|||||||
});
|
});
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|
||||||
it("produces dist/hai-linux-x64", () => {
|
it("produces dist/kb-linux-x64", () => {
|
||||||
const bin = join(distDir, "hai-linux-x64");
|
const bin = join(distDir, "kb-linux-x64");
|
||||||
expect(existsSync(bin)).toBe(true);
|
expect(existsSync(bin)).toBe(true);
|
||||||
expect(statSync(bin).size).toBeGreaterThan(0);
|
expect(statSync(bin).size).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@@ -64,8 +64,8 @@ describe("build-exe-cross: windows target has .exe extension", () => {
|
|||||||
});
|
});
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|
||||||
it("produces dist/hai-windows-x64.exe", () => {
|
it("produces dist/kb-windows-x64.exe", () => {
|
||||||
const bin = join(distDir, "hai-windows-x64.exe");
|
const bin = join(distDir, "kb-windows-x64.exe");
|
||||||
expect(existsSync(bin)).toBe(true);
|
expect(existsSync(bin)).toBe(true);
|
||||||
expect(statSync(bin).size).toBeGreaterThan(0);
|
expect(statSync(bin).size).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@@ -108,7 +108,7 @@ describe("build-exe-cross: --all builds all platforms", () => {
|
|||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
expect(result.status).toBe(0);
|
expect(result.status).toBe(0);
|
||||||
expect(result.stdout).toContain("hai");
|
expect(result.stdout).toContain("kb");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,8 +121,8 @@ describe("build-exe-cross: default (no args) backward compatibility", () => {
|
|||||||
});
|
});
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|
||||||
it("produces dist/hai (no platform suffix)", () => {
|
it("produces dist/kb (no platform suffix)", () => {
|
||||||
const defaultName = process.platform === "win32" ? "hai.exe" : "hai";
|
const defaultName = process.platform === "win32" ? "kb.exe" : "kb";
|
||||||
const bin = join(distDir, defaultName);
|
const bin = join(distDir, defaultName);
|
||||||
expect(existsSync(bin)).toBe(true);
|
expect(existsSync(bin)).toBe(true);
|
||||||
expect(statSync(bin).size).toBeGreaterThan(0);
|
expect(statSync(bin).size).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
const cliRoot = join(import.meta.dirname!, "..", "..");
|
const cliRoot = join(import.meta.dirname!, "..", "..");
|
||||||
const outBinary = join(cliRoot, "dist", process.platform === "win32" ? "hai.exe" : "hai");
|
const outBinary = join(cliRoot, "dist", process.platform === "win32" ? "kb.exe" : "kb");
|
||||||
const binaryName = process.platform === "win32" ? "hai.exe" : "hai";
|
const binaryName = process.platform === "win32" ? "kb.exe" : "kb";
|
||||||
const clientDir = join(cliRoot, "dist", "client");
|
const clientDir = join(cliRoot, "dist", "client");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +15,7 @@ const clientDir = join(cliRoot, "dist", "client");
|
|||||||
* assets — no package.json. Returns the dir path and a cleanup function.
|
* assets — no package.json. Returns the dir path and a cleanup function.
|
||||||
*/
|
*/
|
||||||
function createIsolatedDir(): { dir: string; binary: string; cleanup: () => void } {
|
function createIsolatedDir(): { dir: string; binary: string; cleanup: () => void } {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "hai-iso-"));
|
const dir = mkdtempSync(join(tmpdir(), "kb-iso-"));
|
||||||
cpSync(outBinary, join(dir, binaryName), { recursive: true });
|
cpSync(outBinary, join(dir, binaryName), { recursive: true });
|
||||||
cpSync(clientDir, join(dir, "client"), { recursive: true });
|
cpSync(clientDir, join(dir, "client"), { recursive: true });
|
||||||
return {
|
return {
|
||||||
@@ -60,7 +60,7 @@ describe("build-exe", () => {
|
|||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
expect(result.status).toBe(0);
|
expect(result.status).toBe(0);
|
||||||
expect(result.stdout).toContain("hai — AI-orchestrated task board");
|
expect(result.stdout).toContain("kb — AI-orchestrated task board");
|
||||||
expect(result.stdout).toContain("dashboard");
|
expect(result.stdout).toContain("dashboard");
|
||||||
expect(result.stdout).toContain("task create");
|
expect(result.stdout).toContain("task create");
|
||||||
expect(result.stdout).toContain("task list");
|
expect(result.stdout).toContain("task list");
|
||||||
@@ -110,7 +110,7 @@ describe("build-exe", () => {
|
|||||||
resolve(out);
|
resolve(out);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(output).toContain("hai board");
|
expect(output).toContain("kb board");
|
||||||
} finally {
|
} finally {
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("verifies binary exists after build", () => {
|
it("verifies binary exists after build", () => {
|
||||||
expect(content).toContain("test -f packages/cli/dist/hai");
|
expect(content).toContain("test -f packages/cli/dist/kb");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes pnpm test step", () => {
|
it("includes pnpm test step", () => {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ function loadWorkflowYaml(name: string): any {
|
|||||||
describe("CLI package.json publishing config", () => {
|
describe("CLI package.json publishing config", () => {
|
||||||
const pkg = loadPackageJson("cli");
|
const pkg = loadPackageJson("cli");
|
||||||
|
|
||||||
it('has "bin" field with hai pointing to ./dist/bin.js', () => {
|
it('has "bin" field with kb pointing to ./dist/bin.js', () => {
|
||||||
expect(pkg.bin).toBeDefined();
|
expect(pkg.bin).toBeDefined();
|
||||||
expect(pkg.bin.hai).toBe("./dist/bin.js");
|
expect(pkg.bin.kb).toBe("./dist/bin.js");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has "files" array that includes "dist"', () => {
|
it('has "files" array that includes "dist"', () => {
|
||||||
@@ -35,11 +35,11 @@ describe("CLI package.json publishing config", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Scoped @hai/* packages publishing config", () => {
|
describe("Scoped @kb/* packages publishing config", () => {
|
||||||
const scopedPackages = ["core", "engine", "dashboard"];
|
const scopedPackages = ["core", "engine", "dashboard"];
|
||||||
|
|
||||||
for (const name of scopedPackages) {
|
for (const name of scopedPackages) {
|
||||||
describe(`@hai/${name}`, () => {
|
describe(`@kb/${name}`, () => {
|
||||||
const pkg = loadPackageJson(name);
|
const pkg = loadPackageJson(name);
|
||||||
|
|
||||||
it('has publishConfig with access "public"', () => {
|
it('has publishConfig with access "public"', () => {
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ if (isBunBinary) {
|
|||||||
|
|
||||||
if (!existsSync(localPkg)) {
|
if (!existsSync(localPkg)) {
|
||||||
// Write a minimal package.json to a temp dir and redirect PI_PACKAGE_DIR
|
// Write a minimal package.json to a temp dir and redirect PI_PACKAGE_DIR
|
||||||
const tmp = mkdtempSync(join(tmpdir(), "hai-pkg-"));
|
const tmp = mkdtempSync(join(tmpdir(), "kb-pkg-"));
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(tmp, "package.json"),
|
join(tmp, "package.json"),
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{ name: "hai", version: "0.1.0", type: "module", piConfig: { name: "hai", configDir: ".hai" } },
|
{ name: "kb", version: "0.1.0", type: "module", piConfig: { name: "kb", configDir: ".kb" } },
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
) + "\n",
|
) + "\n",
|
||||||
@@ -42,20 +42,20 @@ const { runDashboard } = await import("./commands/dashboard.js");
|
|||||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause } = await import("./commands/task.js");
|
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause } = await import("./commands/task.js");
|
||||||
|
|
||||||
const HELP = `
|
const HELP = `
|
||||||
hai — AI-orchestrated task board
|
kb — AI-orchestrated task board
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
hai dashboard Start the board web UI
|
kb dashboard Start the board web UI
|
||||||
hai task create [desc] [--attach f] Create a new task (goes to triage)
|
kb task create [desc] [--attach f] Create a new task (goes to triage)
|
||||||
hai task list List all tasks
|
kb task list List all tasks
|
||||||
hai task show <id> Show task details, steps, log
|
kb task show <id> Show task details, steps, log
|
||||||
hai task move <id> <col> Move a task to a column
|
kb task move <id> <col> Move a task to a column
|
||||||
hai task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
|
kb task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
|
||||||
hai task log <id> <message> Add a log entry
|
kb task log <id> <message> Add a log entry
|
||||||
hai task merge <id> Merge an in-review task and close it
|
kb task merge <id> Merge an in-review task and close it
|
||||||
hai task attach <id> <file> Attach a file to a task
|
kb task attach <id> <file> Attach a file to a task
|
||||||
hai task pause <id> Pause a task (stops all automation)
|
kb task pause <id> Pause a task (stops all automation)
|
||||||
hai task unpause <id> Unpause a task (resumes automation)
|
kb task unpause <id> Unpause a task (resumes automation)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--port, -p <port> Dashboard port (default: 4040)
|
--port, -p <port> Dashboard port (default: 4040)
|
||||||
@@ -118,7 +118,7 @@ async function main() {
|
|||||||
const id = args[2];
|
const id = args[2];
|
||||||
const column = args[3];
|
const column = args[3];
|
||||||
if (!id || !column) {
|
if (!id || !column) {
|
||||||
console.error("Usage: hai task move <id> <column>");
|
console.error("Usage: kb task move <id> <column>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
await runTaskMove(id, column);
|
await runTaskMove(id, column);
|
||||||
@@ -126,14 +126,14 @@ async function main() {
|
|||||||
}
|
}
|
||||||
case "show": {
|
case "show": {
|
||||||
const id = args[2];
|
const id = args[2];
|
||||||
if (!id) { console.error("Usage: hai task show <id>"); process.exit(1); }
|
if (!id) { console.error("Usage: kb task show <id>"); process.exit(1); }
|
||||||
await runTaskShow(id);
|
await runTaskShow(id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "update": {
|
case "update": {
|
||||||
const id = args[2], step = args[3], status = args[4];
|
const id = args[2], step = args[3], status = args[4];
|
||||||
if (!id || !step || !status) {
|
if (!id || !step || !status) {
|
||||||
console.error("Usage: hai task update <id> <step> <status>");
|
console.error("Usage: kb task update <id> <step> <status>");
|
||||||
console.error("Status: pending | in-progress | done | skipped");
|
console.error("Status: pending | in-progress | done | skipped");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -142,20 +142,20 @@ async function main() {
|
|||||||
}
|
}
|
||||||
case "log": {
|
case "log": {
|
||||||
const id = args[2], message = args.slice(3).join(" ");
|
const id = args[2], message = args.slice(3).join(" ");
|
||||||
if (!id || !message) { console.error("Usage: hai task log <id> <message>"); process.exit(1); }
|
if (!id || !message) { console.error("Usage: kb task log <id> <message>"); process.exit(1); }
|
||||||
await runTaskLog(id, message);
|
await runTaskLog(id, message);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "merge": {
|
case "merge": {
|
||||||
const id = args[2];
|
const id = args[2];
|
||||||
if (!id) { console.error("Usage: hai task merge <id>"); process.exit(1); }
|
if (!id) { console.error("Usage: kb task merge <id>"); process.exit(1); }
|
||||||
await runTaskMerge(id);
|
await runTaskMerge(id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "attach": {
|
case "attach": {
|
||||||
const id = args[2], file = args[3];
|
const id = args[2], file = args[3];
|
||||||
if (!id || !file) {
|
if (!id || !file) {
|
||||||
console.error("Usage: hai task attach <id> <file>");
|
console.error("Usage: kb task attach <id> <file>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
await runTaskAttach(id, file);
|
await runTaskAttach(id, file);
|
||||||
@@ -163,19 +163,19 @@ async function main() {
|
|||||||
}
|
}
|
||||||
case "pause": {
|
case "pause": {
|
||||||
const id = args[2];
|
const id = args[2];
|
||||||
if (!id) { console.error("Usage: hai task pause <id>"); process.exit(1); }
|
if (!id) { console.error("Usage: kb task pause <id>"); process.exit(1); }
|
||||||
await runTaskPause(id);
|
await runTaskPause(id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "unpause": {
|
case "unpause": {
|
||||||
const id = args[2];
|
const id = args[2];
|
||||||
if (!id) { console.error("Usage: hai task unpause <id>"); process.exit(1); }
|
if (!id) { console.error("Usage: kb task unpause <id>"); process.exit(1); }
|
||||||
await runTaskUnpause(id);
|
await runTaskUnpause(id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.error(`Unknown subcommand: task ${subcommand || ""}`);
|
console.error(`Unknown subcommand: task ${subcommand || ""}`);
|
||||||
console.log("Try: hai task create | list | move");
|
console.log("Try: kb task create | list | move");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -24,23 +24,23 @@ function makeMockStore() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mock @hai/core ──────────────────────────────────────────────────
|
// ── Mock @kb/core ──────────────────────────────────────────────────
|
||||||
|
|
||||||
vi.mock("@hai/core", () => ({
|
vi.mock("@kb/core", () => ({
|
||||||
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
|
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Mock @hai/dashboard ─────────────────────────────────────────────
|
// ── Mock @kb/dashboard ─────────────────────────────────────────────
|
||||||
|
|
||||||
const mockListen = vi.fn();
|
const mockListen = vi.fn();
|
||||||
vi.mock("@hai/dashboard", () => ({
|
vi.mock("@kb/dashboard", () => ({
|
||||||
createServer: vi.fn(() => ({ listen: mockListen })),
|
createServer: vi.fn(() => ({ listen: mockListen })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Mock @hai/engine ────────────────────────────────────────────────
|
// ── Mock @kb/engine ────────────────────────────────────────────────
|
||||||
|
|
||||||
vi.mock("@hai/engine", async (importOriginal) => {
|
vi.mock("@kb/engine", async (importOriginal) => {
|
||||||
const original = await importOriginal<typeof import("@hai/engine")>();
|
const original = await importOriginal<typeof import("@kb/engine")>();
|
||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
WorktreePool: original.WorktreePool,
|
WorktreePool: original.WorktreePool,
|
||||||
@@ -81,12 +81,12 @@ const { runDashboard } = await import("../dashboard.js");
|
|||||||
describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
|
describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
const { TaskStore } = await import("@hai/core");
|
const { TaskStore } = await import("@kb/core");
|
||||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes authStorage and modelRegistry to createServer", async () => {
|
it("passes authStorage and modelRegistry to createServer", async () => {
|
||||||
const { createServer } = await import("@hai/dashboard");
|
const { createServer } = await import("@kb/dashboard");
|
||||||
|
|
||||||
await runDashboard(0, { open: false });
|
await runDashboard(0, { open: false });
|
||||||
|
|
||||||
|
|||||||
@@ -26,26 +26,26 @@ function makeMockStore() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mock @hai/core ──────────────────────────────────────────────────
|
// ── Mock @kb/core ──────────────────────────────────────────────────
|
||||||
|
|
||||||
vi.mock("@hai/core", () => ({
|
vi.mock("@kb/core", () => ({
|
||||||
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
|
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Mock @hai/dashboard ─────────────────────────────────────────────
|
// ── Mock @kb/dashboard ─────────────────────────────────────────────
|
||||||
|
|
||||||
const mockListen = vi.fn();
|
const mockListen = vi.fn();
|
||||||
vi.mock("@hai/dashboard", () => ({
|
vi.mock("@kb/dashboard", () => ({
|
||||||
createServer: vi.fn(() => ({ listen: mockListen })),
|
createServer: vi.fn(() => ({ listen: mockListen })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Mock @hai/engine ────────────────────────────────────────────────
|
// ── Mock @kb/engine ────────────────────────────────────────────────
|
||||||
|
|
||||||
// We need the real WorktreePool class so we can assert `instanceof`.
|
// We need the real WorktreePool class so we can assert `instanceof`.
|
||||||
const { WorktreePool } = await import("@hai/engine");
|
const { WorktreePool } = await import("@kb/engine");
|
||||||
|
|
||||||
vi.mock("@hai/engine", async (importOriginal) => {
|
vi.mock("@kb/engine", async (importOriginal) => {
|
||||||
const original = await importOriginal<typeof import("@hai/engine")>();
|
const original = await importOriginal<typeof import("@kb/engine")>();
|
||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
// Keep real WorktreePool & AgentSemaphore
|
// Keep real WorktreePool & AgentSemaphore
|
||||||
@@ -81,10 +81,10 @@ describe("runDashboard — WorktreePool wiring", () => {
|
|||||||
capturedExecutorOpts = undefined;
|
capturedExecutorOpts = undefined;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Re-set TaskStore mock (clearAllMocks wipes implementations)
|
// Re-set TaskStore mock (clearAllMocks wipes implementations)
|
||||||
const { TaskStore } = await import("@hai/core");
|
const { TaskStore } = await import("@kb/core");
|
||||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||||
// Re-set engine mocks
|
// Re-set engine mocks
|
||||||
const engine = await import("@hai/engine");
|
const engine = await import("@kb/engine");
|
||||||
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||||
Promise.resolve({ merged: true }),
|
Promise.resolve({ merged: true }),
|
||||||
);
|
);
|
||||||
@@ -104,8 +104,8 @@ describe("runDashboard — WorktreePool wiring", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("passes a WorktreePool instance to aiMergeTask via rawMerge", async () => {
|
it("passes a WorktreePool instance to aiMergeTask via rawMerge", async () => {
|
||||||
const { aiMergeTask } = await import("@hai/engine");
|
const { aiMergeTask } = await import("@kb/engine");
|
||||||
const { createServer } = await import("@hai/dashboard");
|
const { createServer } = await import("@kb/dashboard");
|
||||||
|
|
||||||
await runDashboard(0, { open: false });
|
await runDashboard(0, { open: false });
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ describe("runDashboard — WorktreePool wiring", () => {
|
|||||||
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
|
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
|
||||||
|
|
||||||
// Invoke the merge handler
|
// Invoke the merge handler
|
||||||
await serverOpts.onMerge("HAI-TEST");
|
await serverOpts.onMerge("KB-TEST");
|
||||||
|
|
||||||
expect(aiMergeTask).toHaveBeenCalled();
|
expect(aiMergeTask).toHaveBeenCalled();
|
||||||
const mergeCallOpts = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls[0][3];
|
const mergeCallOpts = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls[0][3];
|
||||||
@@ -122,15 +122,15 @@ describe("runDashboard — WorktreePool wiring", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shares the same WorktreePool instance between executor and merger", async () => {
|
it("shares the same WorktreePool instance between executor and merger", async () => {
|
||||||
const { aiMergeTask } = await import("@hai/engine");
|
const { aiMergeTask } = await import("@kb/engine");
|
||||||
const { createServer } = await import("@hai/dashboard");
|
const { createServer } = await import("@kb/dashboard");
|
||||||
|
|
||||||
await runDashboard(0, { open: false });
|
await runDashboard(0, { open: false });
|
||||||
|
|
||||||
// Trigger merger via onMerge
|
// Trigger merger via onMerge
|
||||||
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls[0];
|
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
|
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
|
||||||
await serverOpts.onMerge("HAI-TEST");
|
await serverOpts.onMerge("KB-TEST");
|
||||||
|
|
||||||
const executorPool = capturedExecutorOpts!.pool;
|
const executorPool = capturedExecutorOpts!.pool;
|
||||||
const mergerPool = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls[0][3].pool;
|
const mergerPool = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls[0][3].pool;
|
||||||
@@ -148,9 +148,9 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
|||||||
capturedExecutorOpts = undefined;
|
capturedExecutorOpts = undefined;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockStore = makeMockStore();
|
mockStore = makeMockStore();
|
||||||
const { TaskStore } = await import("@hai/core");
|
const { TaskStore } = await import("@kb/core");
|
||||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||||
const engine = await import("@hai/engine");
|
const engine = await import("@kb/engine");
|
||||||
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||||
Promise.resolve({ merged: true }),
|
Promise.resolve({ merged: true }),
|
||||||
);
|
);
|
||||||
@@ -172,11 +172,11 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
|||||||
|
|
||||||
await runDashboard(0, { open: false });
|
await runDashboard(0, { open: false });
|
||||||
|
|
||||||
const { aiMergeTask } = await import("@hai/engine");
|
const { aiMergeTask } = await import("@kb/engine");
|
||||||
|
|
||||||
// Emit task:moved with a paused task
|
// Emit task:moved with a paused task
|
||||||
mockStore.emit("task:moved", {
|
mockStore.emit("task:moved", {
|
||||||
task: { id: "HAI-PAUSED", column: "in-review", paused: true },
|
task: { id: "KB-PAUSED", column: "in-review", paused: true },
|
||||||
from: "in-progress",
|
from: "in-progress",
|
||||||
to: "in-review",
|
to: "in-review",
|
||||||
});
|
});
|
||||||
@@ -195,11 +195,11 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
|||||||
pollIntervalMs: 60_000,
|
pollIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
mockStore.listTasks.mockResolvedValue([
|
mockStore.listTasks.mockResolvedValue([
|
||||||
{ id: "HAI-PAUSED", column: "in-review", paused: true },
|
{ id: "KB-PAUSED", column: "in-review", paused: true },
|
||||||
{ id: "HAI-ACTIVE", column: "in-review", paused: false },
|
{ id: "KB-ACTIVE", column: "in-review", paused: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { aiMergeTask } = await import("@hai/engine");
|
const { aiMergeTask } = await import("@kb/engine");
|
||||||
// Reset after import
|
// Reset after import
|
||||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
(aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||||
Promise.resolve({ merged: true }),
|
Promise.resolve({ merged: true }),
|
||||||
@@ -214,6 +214,6 @@ describe("runDashboard — auto-merge pause exclusion", () => {
|
|||||||
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
|
const mergedIds = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||||
(call: any[]) => call[2],
|
(call: any[]) => call[2],
|
||||||
);
|
);
|
||||||
expect(mergedIds).not.toContain("HAI-PAUSED");
|
expect(mergedIds).not.toContain("KB-PAUSED");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
import { TaskStore } from "@hai/core";
|
import { TaskStore } from "@kb/core";
|
||||||
import { createServer } from "@hai/dashboard";
|
import { createServer } from "@kb/dashboard";
|
||||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask } from "@hai/engine";
|
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask } from "@kb/engine";
|
||||||
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
function openBrowser(url: string): void {
|
function openBrowser(url: string): void {
|
||||||
@@ -228,11 +228,11 @@ export async function runDashboard(port: number, opts: { open?: boolean } = {})
|
|||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log();
|
console.log();
|
||||||
console.log(` hai board`);
|
console.log(` kb board`);
|
||||||
console.log(` ────────────────────────`);
|
console.log(` ────────────────────────`);
|
||||||
console.log(` → http://localhost:${port}`);
|
console.log(` → http://localhost:${port}`);
|
||||||
console.log();
|
console.log();
|
||||||
console.log(` Tasks stored in .hai/tasks/`);
|
console.log(` Tasks stored in .kb/tasks/`);
|
||||||
console.log(` Merge: AI-assisted (conflict resolution + commit messages)`);
|
console.log(` Merge: AI-assisted (conflict resolution + commit messages)`);
|
||||||
console.log(` AI engine: ✓ active`);
|
console.log(` AI engine: ✓ active`);
|
||||||
console.log(` • triage: auto-specifying tasks`);
|
console.log(` • triage: auto-specifying tasks`);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
// Mock @hai/core before importing the module under test
|
// Mock @kb/core before importing the module under test
|
||||||
vi.mock("@hai/core", () => {
|
vi.mock("@kb/core", () => {
|
||||||
const COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
|
const COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
|
||||||
const COLUMN_LABELS: Record<string, string> = {
|
const COLUMN_LABELS: Record<string, string> = {
|
||||||
triage: "Triage",
|
triage: "Triage",
|
||||||
@@ -18,15 +18,15 @@ vi.mock("@hai/core", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock @hai/engine
|
// Mock @kb/engine
|
||||||
vi.mock("@hai/engine", () => ({ aiMergeTask: vi.fn() }));
|
vi.mock("@kb/engine", () => ({ aiMergeTask: vi.fn() }));
|
||||||
|
|
||||||
import { TaskStore } from "@hai/core";
|
import { TaskStore } from "@kb/core";
|
||||||
import { runTaskShow, runTaskCreate } from "./task.js";
|
import { runTaskShow, runTaskCreate } from "./task.js";
|
||||||
|
|
||||||
function makeTask(overrides: Record<string, unknown> = {}) {
|
function makeTask(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
description: "A short description",
|
description: "A short description",
|
||||||
column: "triage",
|
column: "triage",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
@@ -59,16 +59,16 @@ describe("runTaskShow", () => {
|
|||||||
getTask: vi.fn().mockResolvedValue(task),
|
getTask: vi.fn().mockResolvedValue(task),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await runTaskShow("HAI-001");
|
await runTaskShow("KB-001");
|
||||||
|
|
||||||
const headerLine = logSpy.mock.calls.find(
|
const headerLine = logSpy.mock.calls.find(
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("HAI-001:")
|
(call) => typeof call[0] === "string" && call[0].includes("KB-001:")
|
||||||
);
|
);
|
||||||
expect(headerLine).toBeDefined();
|
expect(headerLine).toBeDefined();
|
||||||
expect(headerLine![0]).toContain(longDesc);
|
expect(headerLine![0]).toContain(longDesc);
|
||||||
// Ensure no truncation happened
|
// Ensure no truncation happened
|
||||||
expect(headerLine![0]).not.toContain(longDesc.slice(0, 60) + "…");
|
expect(headerLine![0]).not.toContain(longDesc.slice(0, 60) + "…");
|
||||||
expect(headerLine![0].length).toBeGreaterThan(60 + " HAI-001: ".length);
|
expect(headerLine![0].length).toBeGreaterThan(60 + " KB-001: ".length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays the title when present instead of description", async () => {
|
it("displays the title when present instead of description", async () => {
|
||||||
@@ -82,10 +82,10 @@ describe("runTaskShow", () => {
|
|||||||
getTask: vi.fn().mockResolvedValue(task),
|
getTask: vi.fn().mockResolvedValue(task),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await runTaskShow("HAI-001");
|
await runTaskShow("KB-001");
|
||||||
|
|
||||||
const headerLine = logSpy.mock.calls.find(
|
const headerLine = logSpy.mock.calls.find(
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("HAI-001:")
|
(call) => typeof call[0] === "string" && call[0].includes("KB-001:")
|
||||||
);
|
);
|
||||||
expect(headerLine).toBeDefined();
|
expect(headerLine).toBeDefined();
|
||||||
expect(headerLine![0]).toContain("My Task Title");
|
expect(headerLine![0]).toContain("My Task Title");
|
||||||
@@ -119,7 +119,7 @@ describe("runTaskCreate with --attach", () => {
|
|||||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||||
init: vi.fn(),
|
init: vi.fn(),
|
||||||
createTask: vi.fn().mockResolvedValue({
|
createTask: vi.fn().mockResolvedValue({
|
||||||
id: "HAI-002",
|
id: "KB-002",
|
||||||
description: "test task",
|
description: "test task",
|
||||||
column: "triage",
|
column: "triage",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
@@ -146,7 +146,7 @@ describe("runTaskCreate with --attach", () => {
|
|||||||
|
|
||||||
expect(mockAddAttachment).toHaveBeenCalledOnce();
|
expect(mockAddAttachment).toHaveBeenCalledOnce();
|
||||||
expect(mockAddAttachment).toHaveBeenCalledWith(
|
expect(mockAddAttachment).toHaveBeenCalledWith(
|
||||||
"HAI-002",
|
"KB-002",
|
||||||
"test.png",
|
"test.png",
|
||||||
expect.any(Buffer),
|
expect.any(Buffer),
|
||||||
"image/png",
|
"image/png",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type MergeResult, type StepStatus } from "@hai/core";
|
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type MergeResult, type StepStatus } from "@kb/core";
|
||||||
import { aiMergeTask } from "@hai/engine";
|
import { aiMergeTask } from "@kb/engine";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
|
|
||||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
||||||
@@ -34,7 +34,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
|
|||||||
console.log();
|
console.log();
|
||||||
console.log(` ✓ Created ${task.id}: ${label}`);
|
console.log(` ✓ Created ${task.id}: ${label}`);
|
||||||
console.log(` Column: triage`);
|
console.log(` Column: triage`);
|
||||||
console.log(` Path: .hai/tasks/${task.id}/`);
|
console.log(` Path: .kb/tasks/${task.id}/`);
|
||||||
|
|
||||||
if (attachFiles && attachFiles.length > 0) {
|
if (attachFiles && attachFiles.length > 0) {
|
||||||
const { readFile } = await import("node:fs/promises");
|
const { readFile } = await import("node:fs/promises");
|
||||||
@@ -73,7 +73,7 @@ export async function runTaskList() {
|
|||||||
const tasks = await store.listTasks();
|
const tasks = await store.listTasks();
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
console.log("\n No tasks yet. Create one with: hai task create\n");
|
console.log("\n No tasks yet. Create one with: kb task create\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ export async function runTaskAttach(id: string, filePath: string) {
|
|||||||
console.log();
|
console.log();
|
||||||
console.log(` ✓ Attached to ${id}: ${attachment.originalName}`);
|
console.log(` ✓ Attached to ${id}: ${attachment.originalName}`);
|
||||||
console.log(` File: ${attachment.filename} (${sizeKB} KB)`);
|
console.log(` File: ${attachment.filename} (${sizeKB} KB)`);
|
||||||
console.log(` Path: .hai/tasks/${id}/attachments/${attachment.filename}`);
|
console.log(` Path: .kb/tasks/${id}/attachments/${attachment.filename}`);
|
||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@hai/core",
|
"name": "@kb/core",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
|
|||||||
import type { Task } from "./types.js";
|
import type { Task } from "./types.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "hai-store-test-"));
|
return mkdtempSync(join(tmpdir(), "kb-store-test-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TaskStore", () => {
|
describe("TaskStore", () => {
|
||||||
@@ -32,7 +32,7 @@ describe("TaskStore", () => {
|
|||||||
async function createTaskWithSteps(): Promise<Task> {
|
async function createTaskWithSteps(): Promise<Task> {
|
||||||
const task = await store.createTask({ description: "Task with steps" });
|
const task = await store.createTask({ description: "Task with steps" });
|
||||||
// Write a PROMPT.md with steps so updateStep works
|
// Write a PROMPT.md with steps so updateStep works
|
||||||
const dir = join(rootDir, ".hai", "tasks", task.id);
|
const dir = join(rootDir, ".kb", "tasks", task.id);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(dir, "PROMPT.md"),
|
join(dir, "PROMPT.md"),
|
||||||
`# ${task.id}: Task with steps
|
`# ${task.id}: Task with steps
|
||||||
@@ -63,7 +63,7 @@ describe("TaskStore", () => {
|
|||||||
const detail = await store.getTask(task.id);
|
const detail = await store.getTask(task.id);
|
||||||
|
|
||||||
// Heading should be just the ID, not the description
|
// Heading should be just the ID, not the description
|
||||||
expect(detail.prompt).toMatch(/^# HAI-001\n/);
|
expect(detail.prompt).toMatch(/^# KB-001\n/);
|
||||||
// Description appears exactly once
|
// Description appears exactly once
|
||||||
const count = detail.prompt.split("Fix the login bug").length - 1;
|
const count = detail.prompt.split("Fix the login bug").length - 1;
|
||||||
expect(count).toBe(1);
|
expect(count).toBe(1);
|
||||||
@@ -76,7 +76,7 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
const detail = await store.getTask(task.id);
|
const detail = await store.getTask(task.id);
|
||||||
|
|
||||||
expect(detail.prompt).toMatch(/^# HAI-001: Login bug\n/);
|
expect(detail.prompt).toMatch(/^# KB-001: Login bug\n/);
|
||||||
expect(detail.prompt).toContain("Fix the login bug on the settings page");
|
expect(detail.prompt).toContain("Fix the login bug on the settings page");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ describe("TaskStore", () => {
|
|||||||
const detail = await store.getTask(task.id);
|
const detail = await store.getTask(task.id);
|
||||||
|
|
||||||
// Heading should be just the ID
|
// Heading should be just the ID
|
||||||
expect(detail.prompt).toMatch(/^# HAI-001\n/);
|
expect(detail.prompt).toMatch(/^# KB-001\n/);
|
||||||
// Description appears exactly once (in Mission section)
|
// Description appears exactly once (in Mission section)
|
||||||
const count = detail.prompt.split("Implement caching layer").length - 1;
|
const count = detail.prompt.split("Implement caching layer").length - 1;
|
||||||
expect(count).toBe(1);
|
expect(count).toBe(1);
|
||||||
@@ -102,7 +102,7 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
const detail = await store.getTask(task.id);
|
const detail = await store.getTask(task.id);
|
||||||
|
|
||||||
expect(detail.prompt).toMatch(/^# HAI-001: Add caching\n/);
|
expect(detail.prompt).toMatch(/^# KB-001: Add caching\n/);
|
||||||
expect(detail.prompt).toContain("Implement caching layer for API responses");
|
expect(detail.prompt).toContain("Implement caching layer for API responses");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -129,7 +129,7 @@ describe("TaskStore", () => {
|
|||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
// Read back and verify valid JSON
|
// Read back and verify valid JSON
|
||||||
const taskJsonPath = join(rootDir, ".hai", "tasks", id, "task.json");
|
const taskJsonPath = join(rootDir, ".kb", "tasks", id, "task.json");
|
||||||
const raw = await readFile(taskJsonPath, "utf-8");
|
const raw = await readFile(taskJsonPath, "utf-8");
|
||||||
const result = JSON.parse(raw) as Task;
|
const result = JSON.parse(raw) as Task;
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ describe("TaskStore", () => {
|
|||||||
describe("defensive JSON parsing", () => {
|
describe("defensive JSON parsing", () => {
|
||||||
it("throws on corrupted task.json with trailing duplicate content (atomic writes prevent this)", async () => {
|
it("throws on corrupted task.json with trailing duplicate content (atomic writes prevent this)", async () => {
|
||||||
const task = await createTestTask();
|
const task = await createTestTask();
|
||||||
const taskJsonPath = join(rootDir, ".hai", "tasks", task.id, "task.json");
|
const taskJsonPath = join(rootDir, ".kb", "tasks", task.id, "task.json");
|
||||||
|
|
||||||
// Corrupt the file: append duplicate trailing content
|
// Corrupt the file: append duplicate trailing content
|
||||||
const validJson = await readFile(taskJsonPath, "utf-8");
|
const validJson = await readFile(taskJsonPath, "utf-8");
|
||||||
@@ -157,7 +157,7 @@ describe("TaskStore", () => {
|
|||||||
|
|
||||||
it("throws a clear error when JSON is completely unrecoverable", async () => {
|
it("throws a clear error when JSON is completely unrecoverable", async () => {
|
||||||
const task = await createTestTask();
|
const task = await createTestTask();
|
||||||
const taskJsonPath = join(rootDir, ".hai", "tasks", task.id, "task.json");
|
const taskJsonPath = join(rootDir, ".kb", "tasks", task.id, "task.json");
|
||||||
|
|
||||||
// Write completely invalid content
|
// Write completely invalid content
|
||||||
await writeFile(taskJsonPath, "not json at all {{{");
|
await writeFile(taskJsonPath, "not json at all {{{");
|
||||||
@@ -171,7 +171,7 @@ describe("TaskStore", () => {
|
|||||||
describe("atomic writes", () => {
|
describe("atomic writes", () => {
|
||||||
it("produces valid JSON after write with no .tmp files left behind", async () => {
|
it("produces valid JSON after write with no .tmp files left behind", async () => {
|
||||||
const task = await createTestTask();
|
const task = await createTestTask();
|
||||||
const dir = join(rootDir, ".hai", "tasks", task.id);
|
const dir = join(rootDir, ".kb", "tasks", task.id);
|
||||||
|
|
||||||
// Perform a write
|
// Perform a write
|
||||||
await store.logEntry(task.id, "atomic test");
|
await store.logEntry(task.id, "atomic test");
|
||||||
@@ -200,18 +200,18 @@ describe("TaskStore", () => {
|
|||||||
const ids = tasks.map((t) => t.id);
|
const ids = tasks.map((t) => t.id);
|
||||||
expect(new Set(ids).size).toBe(5);
|
expect(new Set(ids).size).toBe(5);
|
||||||
|
|
||||||
// IDs should be sequential (HAI-001 through HAI-005)
|
// IDs should be sequential (KB-001 through KB-005)
|
||||||
const sortedIds = [...ids].sort();
|
const sortedIds = [...ids].sort();
|
||||||
expect(sortedIds).toEqual(["HAI-001", "HAI-002", "HAI-003", "HAI-004", "HAI-005"]);
|
expect(sortedIds).toEqual(["KB-001", "KB-002", "KB-003", "KB-004", "KB-005"]);
|
||||||
|
|
||||||
// config.json should be valid JSON with nextId = 6
|
// config.json should be valid JSON with nextId = 6
|
||||||
const configPath = join(rootDir, ".hai", "config.json");
|
const configPath = join(rootDir, ".kb", "config.json");
|
||||||
const raw = await readFile(configPath, "utf-8");
|
const raw = await readFile(configPath, "utf-8");
|
||||||
const config = JSON.parse(raw);
|
const config = JSON.parse(raw);
|
||||||
expect(config.nextId).toBe(6);
|
expect(config.nextId).toBe(6);
|
||||||
|
|
||||||
// No .tmp files left behind
|
// No .tmp files left behind
|
||||||
const haiDir = join(rootDir, ".hai");
|
const haiDir = join(rootDir, ".kb");
|
||||||
const files = await readdir(haiDir);
|
const files = await readdir(haiDir);
|
||||||
expect(files.filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
|
expect(files.filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
|
||||||
});
|
});
|
||||||
@@ -240,7 +240,7 @@ describe("TaskStore", () => {
|
|||||||
expect(updated.attachments![0].filename).toBe(attachment.filename);
|
expect(updated.attachments![0].filename).toBe(attachment.filename);
|
||||||
|
|
||||||
// Verify file on disk
|
// Verify file on disk
|
||||||
const filePath = join(rootDir, ".hai", "tasks", task.id, "attachments", attachment.filename);
|
const filePath = join(rootDir, ".kb", "tasks", task.id, "attachments", attachment.filename);
|
||||||
const content = await readFile(filePath);
|
const content = await readFile(filePath);
|
||||||
expect(content).toEqual(TINY_PNG);
|
expect(content).toEqual(TINY_PNG);
|
||||||
});
|
});
|
||||||
@@ -296,7 +296,7 @@ describe("TaskStore", () => {
|
|||||||
expect(updated.attachments).toBeUndefined();
|
expect(updated.attachments).toBeUndefined();
|
||||||
|
|
||||||
// Verify file removed from disk
|
// Verify file removed from disk
|
||||||
const filePath = join(rootDir, ".hai", "tasks", task.id, "attachments", attachment.filename);
|
const filePath = join(rootDir, ".kb", "tasks", task.id, "attachments", attachment.filename);
|
||||||
expect(existsSync(filePath)).toBe(false);
|
expect(existsSync(filePath)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,48 +369,48 @@ describe("TaskStore", () => {
|
|||||||
const task = await createTestTask();
|
const task = await createTestTask();
|
||||||
expect(task.dependencies).toEqual([]);
|
expect(task.dependencies).toEqual([]);
|
||||||
|
|
||||||
const updated = await store.updateTask(task.id, { dependencies: ["HAI-001", "HAI-002"] });
|
const updated = await store.updateTask(task.id, { dependencies: ["KB-001", "KB-002"] });
|
||||||
expect(updated.dependencies).toEqual(["HAI-001", "HAI-002"]);
|
expect(updated.dependencies).toEqual(["KB-001", "KB-002"]);
|
||||||
|
|
||||||
// Verify persistence
|
// Verify persistence
|
||||||
const fetched = await store.getTask(task.id);
|
const fetched = await store.getTask(task.id);
|
||||||
expect(fetched.dependencies).toEqual(["HAI-001", "HAI-002"]);
|
expect(fetched.dependencies).toEqual(["KB-001", "KB-002"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces existing dependencies", async () => {
|
it("replaces existing dependencies", async () => {
|
||||||
const task = await store.createTask({ description: "Dep task", dependencies: ["HAI-001"] });
|
const task = await store.createTask({ description: "Dep task", dependencies: ["KB-001"] });
|
||||||
expect(task.dependencies).toEqual(["HAI-001"]);
|
expect(task.dependencies).toEqual(["KB-001"]);
|
||||||
|
|
||||||
const updated = await store.updateTask(task.id, { dependencies: ["HAI-002", "HAI-003"] });
|
const updated = await store.updateTask(task.id, { dependencies: ["KB-002", "KB-003"] });
|
||||||
expect(updated.dependencies).toEqual(["HAI-002", "HAI-003"]);
|
expect(updated.dependencies).toEqual(["KB-002", "KB-003"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears dependencies with empty array", async () => {
|
it("clears dependencies with empty array", async () => {
|
||||||
const task = await store.createTask({ description: "Dep task", dependencies: ["HAI-001"] });
|
const task = await store.createTask({ description: "Dep task", dependencies: ["KB-001"] });
|
||||||
expect(task.dependencies).toEqual(["HAI-001"]);
|
expect(task.dependencies).toEqual(["KB-001"]);
|
||||||
|
|
||||||
const updated = await store.updateTask(task.id, { dependencies: [] });
|
const updated = await store.updateTask(task.id, { dependencies: [] });
|
||||||
expect(updated.dependencies).toEqual([]);
|
expect(updated.dependencies).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves dependencies unchanged when not provided", async () => {
|
it("leaves dependencies unchanged when not provided", async () => {
|
||||||
const task = await store.createTask({ description: "Dep task", dependencies: ["HAI-001"] });
|
const task = await store.createTask({ description: "Dep task", dependencies: ["KB-001"] });
|
||||||
|
|
||||||
const updated = await store.updateTask(task.id, { title: "New title" });
|
const updated = await store.updateTask(task.id, { title: "New title" });
|
||||||
expect(updated.dependencies).toEqual(["HAI-001"]);
|
expect(updated.dependencies).toEqual(["KB-001"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("updateTask — blockedBy", () => {
|
describe("updateTask — blockedBy", () => {
|
||||||
it("sets blockedBy to a string value", async () => {
|
it("sets blockedBy to a string value", async () => {
|
||||||
const task = await store.createTask({ title: "Blocked task", description: "A task" });
|
const task = await store.createTask({ title: "Blocked task", description: "A task" });
|
||||||
const updated = await store.updateTask(task.id, { blockedBy: "HAI-999" });
|
const updated = await store.updateTask(task.id, { blockedBy: "KB-999" });
|
||||||
expect(updated.blockedBy).toBe("HAI-999");
|
expect(updated.blockedBy).toBe("KB-999");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears blockedBy when set to null", async () => {
|
it("clears blockedBy when set to null", async () => {
|
||||||
const task = await store.createTask({ title: "Blocked task", description: "A task" });
|
const task = await store.createTask({ title: "Blocked task", description: "A task" });
|
||||||
await store.updateTask(task.id, { blockedBy: "HAI-999" });
|
await store.updateTask(task.id, { blockedBy: "KB-999" });
|
||||||
const updated = await store.updateTask(task.id, { blockedBy: null });
|
const updated = await store.updateTask(task.id, { blockedBy: null });
|
||||||
expect(updated.blockedBy).toBeUndefined();
|
expect(updated.blockedBy).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -419,9 +419,9 @@ describe("TaskStore", () => {
|
|||||||
// ── Task prefix tests ──────────────────────────────────────────
|
// ── Task prefix tests ──────────────────────────────────────────
|
||||||
|
|
||||||
describe("taskPrefix setting", () => {
|
describe("taskPrefix setting", () => {
|
||||||
it("default prefix produces HAI-001 IDs", async () => {
|
it("default prefix produces KB-001 IDs", async () => {
|
||||||
const task = await store.createTask({ description: "Default prefix" });
|
const task = await store.createTask({ description: "Default prefix" });
|
||||||
expect(task.id).toBe("HAI-001");
|
expect(task.id).toBe("KB-001");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("custom prefix produces PROJ-001 IDs", async () => {
|
it("custom prefix produces PROJ-001 IDs", async () => {
|
||||||
@@ -433,8 +433,8 @@ describe("TaskStore", () => {
|
|||||||
it("prefix change mid-stream continues sequence", async () => {
|
it("prefix change mid-stream continues sequence", async () => {
|
||||||
const t1 = await store.createTask({ description: "First" });
|
const t1 = await store.createTask({ description: "First" });
|
||||||
const t2 = await store.createTask({ description: "Second" });
|
const t2 = await store.createTask({ description: "Second" });
|
||||||
expect(t1.id).toBe("HAI-001");
|
expect(t1.id).toBe("KB-001");
|
||||||
expect(t2.id).toBe("HAI-002");
|
expect(t2.id).toBe("KB-002");
|
||||||
|
|
||||||
await store.updateSettings({ taskPrefix: "PROJ" });
|
await store.updateSettings({ taskPrefix: "PROJ" });
|
||||||
const t3 = await store.createTask({ description: "Third" });
|
const t3 = await store.createTask({ description: "Third" });
|
||||||
@@ -448,7 +448,7 @@ describe("TaskStore", () => {
|
|||||||
|
|
||||||
const tasks = await store.listTasks();
|
const tasks = await store.listTasks();
|
||||||
expect(tasks).toHaveLength(2);
|
expect(tasks).toHaveLength(2);
|
||||||
expect(tasks.map((t) => t.id).sort()).toEqual(["HAI-001", "PROJ-002"]);
|
expect(tasks.map((t) => t.id).sort()).toEqual(["KB-001", "PROJ-002"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface TaskStoreEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||||
private haiDir: string;
|
private kbDir: string;
|
||||||
private tasksDir: string;
|
private tasksDir: string;
|
||||||
private configPath: string;
|
private configPath: string;
|
||||||
|
|
||||||
@@ -37,9 +37,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
|
|
||||||
constructor(private rootDir: string) {
|
constructor(private rootDir: string) {
|
||||||
super();
|
super();
|
||||||
this.haiDir = join(rootDir, ".hai");
|
this.kbDir = join(rootDir, ".kb");
|
||||||
this.tasksDir = join(this.haiDir, "tasks");
|
this.tasksDir = join(this.kbDir, "tasks");
|
||||||
this.configPath = join(this.haiDir, "config.json");
|
this.configPath = join(this.kbDir, "config.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
@@ -160,7 +160,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
private async allocateId(): Promise<string> {
|
private async allocateId(): Promise<string> {
|
||||||
return this.withConfigLock(async () => {
|
return this.withConfigLock(async () => {
|
||||||
const config = await this.readConfig();
|
const config = await this.readConfig();
|
||||||
const prefix = config.settings?.taskPrefix || "HAI";
|
const prefix = config.settings?.taskPrefix || "KB";
|
||||||
const id = `${prefix}-${String(config.nextId).padStart(3, "0")}`;
|
const id = `${prefix}-${String(config.nextId).padStart(3, "0")}`;
|
||||||
config.nextId++;
|
config.nextId++;
|
||||||
await this.writeConfig(config);
|
await this.writeConfig(config);
|
||||||
@@ -509,7 +509,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const branch = `hai/${id.toLowerCase()}`;
|
const branch = `kb/${id.toLowerCase()}`;
|
||||||
const worktreePath = task.worktree;
|
const worktreePath = task.worktree;
|
||||||
const result: MergeResult = {
|
const result: MergeResult = {
|
||||||
task,
|
task,
|
||||||
@@ -556,7 +556,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
`Merge conflict merging '${branch}'. Resolve manually:\n` +
|
`Merge conflict merging '${branch}'. Resolve manually:\n` +
|
||||||
` cd ${this.rootDir}\n` +
|
` cd ${this.rootDir}\n` +
|
||||||
` git merge --squash ${branch}\n` +
|
` git merge --squash ${branch}\n` +
|
||||||
` # resolve conflicts, then: hai task move ${id} done`,
|
` # resolve conflicts, then: kb task move ${id} done`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,10 +877,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Append an agent log entry to the task's agent log file (JSONL format).
|
* Append an agent log entry to the task's agent log file (JSONL format).
|
||||||
* Each entry is a single JSON line appended to `.hai/tasks/{ID}/agent.log`.
|
* Each entry is a single JSON line appended to `.kb/tasks/{ID}/agent.log`.
|
||||||
* Also emits an `agent:log` event for live streaming.
|
* Also emits an `agent:log` event for live streaming.
|
||||||
*
|
*
|
||||||
* @param taskId - The task ID (e.g. "HAI-001")
|
* @param taskId - The task ID (e.g. "KB-001")
|
||||||
* @param text - The text content (delta for "text", tool name for "tool")
|
* @param text - The text content (delta for "text", tool name for "tool")
|
||||||
* @param type - Whether this is a "text" delta or a "tool" invocation marker
|
* @param type - Whether this is a "text" delta or a "tool" invocation marker
|
||||||
* @param detail - Optional human-readable summary of tool args (e.g. file path, command)
|
* @param detail - Optional human-readable summary of tool args (e.g. file path, command)
|
||||||
@@ -903,7 +903,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
* Read all historical agent log entries for a task from its agent log file.
|
* Read all historical agent log entries for a task from its agent log file.
|
||||||
* Returns entries in chronological order (oldest first).
|
* Returns entries in chronological order (oldest first).
|
||||||
*
|
*
|
||||||
* @param taskId - The task ID (e.g. "HAI-001")
|
* @param taskId - The task ID (e.g. "KB-001")
|
||||||
* @returns Array of agent log entries, empty if no log file exists
|
* @returns Array of agent log entries, empty if no log file exists
|
||||||
*/
|
*/
|
||||||
async getAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
|
async getAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
|
||||||
|
|||||||
@@ -94,12 +94,12 @@ export interface Settings {
|
|||||||
* of being deleted. New tasks acquire a warm worktree from the pool,
|
* of being deleted. New tasks acquire a warm worktree from the pool,
|
||||||
* preserving build caches (node_modules, target/, dist/). Default: false. */
|
* preserving build caches (node_modules, target/, dist/). Default: false. */
|
||||||
recycleWorktrees?: boolean;
|
recycleWorktrees?: boolean;
|
||||||
/** Prefix for generated task IDs (e.g. `"HAI"` produces `HAI-001`).
|
/** Prefix for generated task IDs (e.g. `"KB"` produces `KB-001`).
|
||||||
* Defaults to `"HAI"`. Only affects new tasks — existing tasks retain
|
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
|
||||||
* their original IDs. */
|
* their original IDs. */
|
||||||
taskPrefix?: string;
|
taskPrefix?: string;
|
||||||
/** When true, merge commit messages include the task ID as the conventional
|
/** When true, merge commit messages include the task ID as the conventional
|
||||||
* commit scope (e.g. `feat(HAI-001): ...`). When false, the scope is
|
* commit scope (e.g. `feat(KB-001): ...`). When false, the scope is
|
||||||
* omitted (e.g. `feat: ...`). Default: true. */
|
* omitted (e.g. `feat: ...`). Default: true. */
|
||||||
includeTaskIdInCommit?: boolean;
|
includeTaskIdInCommit?: boolean;
|
||||||
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).
|
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import type { TaskDetail, TaskCreateInput, Task } from "@hai/core";
|
import type { TaskDetail, TaskCreateInput, Task } from "@kb/core";
|
||||||
import { fetchConfig, fetchSettings, updateSettings } from "./api";
|
import { fetchConfig, fetchSettings, updateSettings } from "./api";
|
||||||
import { Header } from "./components/Header";
|
import { Header } from "./components/Header";
|
||||||
import { Board } from "./components/Board";
|
import { Board } from "./components/Board";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "./api";
|
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "./api";
|
||||||
import type { Task, TaskDetail } from "@hai/core";
|
import type { Task, TaskDetail } from "@kb/core";
|
||||||
|
|
||||||
const FAKE_DETAIL: TaskDetail = {
|
const FAKE_DETAIL: TaskDetail = {
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
@@ -12,7 +12,7 @@ const FAKE_DETAIL: TaskDetail = {
|
|||||||
log: [],
|
log: [],
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
prompt: "# HAI-001",
|
prompt: "# KB-001",
|
||||||
};
|
};
|
||||||
|
|
||||||
function mockFetchResponse(ok: boolean, body: unknown, status = ok ? 200 : 500) {
|
function mockFetchResponse(ok: boolean, body: unknown, status = ok ? 200 : 500) {
|
||||||
@@ -38,9 +38,9 @@ describe("fetchTaskDetail", () => {
|
|||||||
it("returns data on first success", async () => {
|
it("returns data on first success", async () => {
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_DETAIL));
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_DETAIL));
|
||||||
|
|
||||||
const result = await fetchTaskDetail("HAI-001");
|
const result = await fetchTaskDetail("KB-001");
|
||||||
|
|
||||||
expect(result.id).toBe("HAI-001");
|
expect(result.id).toBe("KB-001");
|
||||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,9 +49,9 @@ describe("fetchTaskDetail", () => {
|
|||||||
.mockReturnValueOnce(mockFetchResponse(false, { error: "Transient error" }))
|
.mockReturnValueOnce(mockFetchResponse(false, { error: "Transient error" }))
|
||||||
.mockReturnValueOnce(mockFetchResponse(true, FAKE_DETAIL));
|
.mockReturnValueOnce(mockFetchResponse(true, FAKE_DETAIL));
|
||||||
|
|
||||||
const result = await fetchTaskDetail("HAI-001");
|
const result = await fetchTaskDetail("KB-001");
|
||||||
|
|
||||||
expect(result.id).toBe("HAI-001");
|
expect(result.id).toBe("KB-001");
|
||||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ describe("fetchTaskDetail", () => {
|
|||||||
globalThis.fetch = vi.fn()
|
globalThis.fetch = vi.fn()
|
||||||
.mockReturnValue(mockFetchResponse(false, { error: "Server error" }));
|
.mockReturnValue(mockFetchResponse(false, { error: "Server error" }));
|
||||||
|
|
||||||
await expect(fetchTaskDetail("HAI-001")).rejects.toThrow("Server error");
|
await expect(fetchTaskDetail("KB-001")).rejects.toThrow("Server error");
|
||||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2); // initial + 1 retry
|
expect(globalThis.fetch).toHaveBeenCalledTimes(2); // initial + 1 retry
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -72,10 +72,10 @@ describe("updateTask", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const FAKE_TASK: Task = {
|
const FAKE_TASK: Task = {
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
dependencies: ["HAI-002"],
|
dependencies: ["KB-002"],
|
||||||
steps: [],
|
steps: [],
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
log: [],
|
log: [],
|
||||||
@@ -86,20 +86,20 @@ describe("updateTask", () => {
|
|||||||
it("sends PATCH with dependencies and returns updated task", async () => {
|
it("sends PATCH with dependencies and returns updated task", async () => {
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
||||||
|
|
||||||
const result = await updateTask("HAI-001", { dependencies: ["HAI-002"] });
|
const result = await updateTask("KB-001", { dependencies: ["KB-002"] });
|
||||||
|
|
||||||
expect(result.dependencies).toEqual(["HAI-002"]);
|
expect(result.dependencies).toEqual(["KB-002"]);
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/HAI-001", {
|
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001", {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ dependencies: ["HAI-002"] }),
|
body: JSON.stringify({ dependencies: ["KB-002"] }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on error response", async () => {
|
it("throws on error response", async () => {
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Not found" }));
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Not found" }));
|
||||||
|
|
||||||
await expect(updateTask("HAI-001", { dependencies: [] })).rejects.toThrow("Not found");
|
await expect(updateTask("KB-001", { dependencies: [] })).rejects.toThrow("Not found");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Task, TaskDetail, TaskAttachment, TaskCreateInput, AgentLogEntry, Column, MergeResult, Settings } from "@hai/core";
|
import type { Task, TaskDetail, TaskAttachment, TaskCreateInput, AgentLogEntry, Column, MergeResult, Settings } from "@kb/core";
|
||||||
|
|
||||||
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, {
|
const res = await fetch(`/api${path}`, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { AgentLogEntry } from "@hai/core";
|
import type { AgentLogEntry } from "@kb/core";
|
||||||
|
|
||||||
interface AgentLogViewerProps {
|
interface AgentLogViewerProps {
|
||||||
entries: AgentLogEntry[];
|
entries: AgentLogEntry[];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@hai/core";
|
import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@kb/core";
|
||||||
import { COLUMNS } from "@hai/core";
|
import { COLUMNS } from "@kb/core";
|
||||||
import { Column } from "./Column";
|
import { Column } from "./Column";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
|
import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
|
||||||
import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@hai/core";
|
import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@kb/core";
|
||||||
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@hai/core";
|
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@kb/core";
|
||||||
import { TaskCard } from "./TaskCard";
|
import { TaskCard } from "./TaskCard";
|
||||||
import { WorktreeGroup } from "./WorktreeGroup";
|
import { WorktreeGroup } from "./WorktreeGroup";
|
||||||
import { InlineCreateCard } from "./InlineCreateCard";
|
import { InlineCreateCard } from "./InlineCreateCard";
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export function Header({ onOpenSettings }: HeaderProps) {
|
|||||||
return (
|
return (
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<div className="header-left">
|
<div className="header-left">
|
||||||
<img src="/logo.svg" alt="hai logo" className="header-logo" width={24} height={24} />
|
<img src="/logo.svg" alt="kb logo" className="header-logo" width={24} height={24} />
|
||||||
<h1 className="logo">hai</h1>
|
<h1 className="logo">kb</h1>
|
||||||
<span className="logo-sub">board</span>
|
<span className="logo-sub">board</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { Link } from "lucide-react";
|
import { Link } from "lucide-react";
|
||||||
import type { Task, TaskCreateInput } from "@hai/core";
|
import type { Task, TaskCreateInput } from "@kb/core";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { uploadAttachment } from "../api";
|
import { uploadAttachment } from "../api";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { Settings } from "@hai/core";
|
import type { Settings } from "@kb/core";
|
||||||
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../api";
|
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../api";
|
||||||
import type { AuthProvider, ModelInfo } from "../api";
|
import type { AuthProvider, ModelInfo } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
@@ -185,7 +185,7 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
|||||||
<input
|
<input
|
||||||
id="taskPrefix"
|
id="taskPrefix"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="HAI"
|
placeholder="KB"
|
||||||
value={form.taskPrefix || ""}
|
value={form.taskPrefix || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
@@ -198,7 +198,7 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{prefixError && <small className="field-error">{prefixError}</small>}
|
{prefixError && <small className="field-error">{prefixError}</small>}
|
||||||
{!prefixError && <small>Prefix for new task IDs (e.g. HAI, PROJ)</small>}
|
{!prefixError && <small>Prefix for new task IDs (e.g. KB, PROJ)</small>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -412,7 +412,7 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
|
|||||||
/>
|
/>
|
||||||
Include task ID in commit scope
|
Include task ID in commit scope
|
||||||
</label>
|
</label>
|
||||||
<small>When disabled, merge commit messages omit the task ID from the scope (e.g. <code>feat: ...</code> instead of <code>feat(HAI-001): ...</code>)</small>
|
<small>When disabled, merge commit messages omit the task ID from the scope (e.g. <code>feat: ...</code> instead of <code>feat(KB-001): ...</code>)</small>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { TaskCard } from "./TaskCard";
|
import { TaskCard } from "./TaskCard";
|
||||||
import type { Task } from "@hai/core";
|
import type { Task } from "@kb/core";
|
||||||
|
|
||||||
// Mock lucide-react to avoid SVG rendering issues in test env
|
// Mock lucide-react to avoid SVG rendering issues in test env
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
@@ -19,7 +19,7 @@ import { uploadAttachment } from "../api";
|
|||||||
|
|
||||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
return {
|
return {
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test task",
|
title: "Test task",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
status: undefined as any,
|
status: undefined as any,
|
||||||
@@ -35,7 +35,7 @@ const noop = () => {};
|
|||||||
describe("TaskCard", () => {
|
describe("TaskCard", () => {
|
||||||
it("renders the card ID text", () => {
|
it("renders the card ID text", () => {
|
||||||
render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
|
render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
|
||||||
expect(screen.getByText("HAI-001")).toBeDefined();
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the status badge when task.status is set", () => {
|
it("renders the status badge when task.status is set", () => {
|
||||||
@@ -126,7 +126,7 @@ describe("TaskCard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpload).toHaveBeenCalledWith("HAI-001", file);
|
expect(mockUpload).toHaveBeenCalledWith("KB-001", file);
|
||||||
expect(addToast).toHaveBeenCalledWith(
|
expect(addToast).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("Attached test.png"),
|
expect.stringContaining("Attached test.png"),
|
||||||
"success",
|
"success",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Link, Clock, Layers } from "lucide-react";
|
import { Link, Clock, Layers } from "lucide-react";
|
||||||
import type { Task, TaskDetail, Column } from "@hai/core";
|
import type { Task, TaskDetail, Column } from "@kb/core";
|
||||||
import { fetchTaskDetail, uploadAttachment } from "../api";
|
import { fetchTaskDetail, uploadAttachment } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult } from "@hai/core";
|
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult } from "@kb/core";
|
||||||
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@hai/core";
|
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@kb/core";
|
||||||
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask } from "../api";
|
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Task, TaskDetail } from "@hai/core";
|
import type { Task, TaskDetail } from "@kb/core";
|
||||||
import { ClipboardList, GitBranch } from "lucide-react";
|
import { ClipboardList, GitBranch } from "lucide-react";
|
||||||
import { TaskCard } from "./TaskCard";
|
import { TaskCard } from "./TaskCard";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { AgentLogViewer } from "../AgentLogViewer";
|
import { AgentLogViewer } from "../AgentLogViewer";
|
||||||
import type { AgentLogEntry } from "@hai/core";
|
import type { AgentLogEntry } from "@kb/core";
|
||||||
|
|
||||||
function makeEntry(overrides: Partial<AgentLogEntry> = {}): AgentLogEntry {
|
function makeEntry(overrides: Partial<AgentLogEntry> = {}): AgentLogEntry {
|
||||||
return {
|
return {
|
||||||
timestamp: "2026-01-01T00:00:00Z",
|
timestamp: "2026-01-01T00:00:00Z",
|
||||||
taskId: "HAI-001",
|
taskId: "KB-001",
|
||||||
text: "Hello world",
|
text: "Hello world",
|
||||||
type: "text",
|
type: "text",
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { Board } from "../Board";
|
import { Board } from "../Board";
|
||||||
import { COLUMNS } from "@hai/core";
|
import { COLUMNS } from "@kb/core";
|
||||||
|
|
||||||
// Mock child components so we only test Board's own rendering
|
// Mock child components so we only test Board's own rendering
|
||||||
vi.mock("../Column", () => ({
|
vi.mock("../Column", () => ({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { Column } from "../Column";
|
import { Column } from "../Column";
|
||||||
import type { Task, Column as ColumnType } from "@hai/core";
|
import type { Task, Column as ColumnType } from "@kb/core";
|
||||||
|
|
||||||
// Mock child components to keep tests focused on the Column badge behavior
|
// Mock child components to keep tests focused on the Column badge behavior
|
||||||
vi.mock("../TaskCard", () => ({
|
vi.mock("../TaskCard", () => ({
|
||||||
@@ -43,7 +43,7 @@ const defaultProps = {
|
|||||||
|
|
||||||
describe("Column count-flash", () => {
|
describe("Column count-flash", () => {
|
||||||
it("does not apply count-flash class on initial render", () => {
|
it("does not apply count-flash class on initial render", () => {
|
||||||
const tasks = [makeTask("HAI-001")];
|
const tasks = [makeTask("KB-001")];
|
||||||
render(<Column {...defaultProps} tasks={tasks} />);
|
render(<Column {...defaultProps} tasks={tasks} />);
|
||||||
|
|
||||||
const badge = screen.getByText("1");
|
const badge = screen.getByText("1");
|
||||||
@@ -52,10 +52,10 @@ describe("Column count-flash", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("applies count-flash class when task count increases", () => {
|
it("applies count-flash class when task count increases", () => {
|
||||||
const tasks = [makeTask("HAI-001")];
|
const tasks = [makeTask("KB-001")];
|
||||||
const { rerender } = render(<Column {...defaultProps} tasks={tasks} />);
|
const { rerender } = render(<Column {...defaultProps} tasks={tasks} />);
|
||||||
|
|
||||||
const moreTasks = [makeTask("HAI-001"), makeTask("HAI-002")];
|
const moreTasks = [makeTask("KB-001"), makeTask("KB-002")];
|
||||||
rerender(<Column {...defaultProps} tasks={moreTasks} />);
|
rerender(<Column {...defaultProps} tasks={moreTasks} />);
|
||||||
|
|
||||||
const badge = screen.getByText("2");
|
const badge = screen.getByText("2");
|
||||||
@@ -63,10 +63,10 @@ describe("Column count-flash", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not apply count-flash class when task count decreases", () => {
|
it("does not apply count-flash class when task count decreases", () => {
|
||||||
const tasks = [makeTask("HAI-001"), makeTask("HAI-002")];
|
const tasks = [makeTask("KB-001"), makeTask("KB-002")];
|
||||||
const { rerender } = render(<Column {...defaultProps} tasks={tasks} />);
|
const { rerender } = render(<Column {...defaultProps} tasks={tasks} />);
|
||||||
|
|
||||||
const fewerTasks = [makeTask("HAI-001")];
|
const fewerTasks = [makeTask("KB-001")];
|
||||||
rerender(<Column {...defaultProps} tasks={fewerTasks} />);
|
rerender(<Column {...defaultProps} tasks={fewerTasks} />);
|
||||||
|
|
||||||
const badge = screen.getByText("1");
|
const badge = screen.getByText("1");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Header } from "../Header";
|
|||||||
describe("Header", () => {
|
describe("Header", () => {
|
||||||
it("renders a logo image with correct src and alt", () => {
|
it("renders a logo image with correct src and alt", () => {
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
const logo = screen.getByAltText("hai logo");
|
const logo = screen.getByAltText("kb logo");
|
||||||
expect(logo).toBeDefined();
|
expect(logo).toBeDefined();
|
||||||
expect(logo.tagName).toBe("IMG");
|
expect(logo.tagName).toBe("IMG");
|
||||||
expect((logo as HTMLImageElement).src).toContain("/logo.svg");
|
expect((logo as HTMLImageElement).src).toContain("/logo.svg");
|
||||||
@@ -13,7 +13,7 @@ describe("Header", () => {
|
|||||||
|
|
||||||
it("renders the logo before the h1 element", () => {
|
it("renders the logo before the h1 element", () => {
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
const logo = screen.getByAltText("hai logo");
|
const logo = screen.getByAltText("kb logo");
|
||||||
const h1 = screen.getByRole("heading", { level: 1 });
|
const h1 = screen.getByRole("heading", { level: 1 });
|
||||||
// Logo should be a preceding sibling of the h1
|
// Logo should be a preceding sibling of the h1
|
||||||
expect(logo.compareDocumentPosition(h1) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
expect(logo.compareDocumentPosition(h1) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ vi.mock("../../api", () => ({
|
|||||||
function renderCard() {
|
function renderCard() {
|
||||||
const props = {
|
const props = {
|
||||||
tasks: [],
|
tasks: [],
|
||||||
onSubmit: vi.fn().mockResolvedValue({ id: "HAI-001" }),
|
onSubmit: vi.fn().mockResolvedValue({ id: "KB-001" }),
|
||||||
onCancel: vi.fn(),
|
onCancel: vi.fn(),
|
||||||
addToast: vi.fn(),
|
addToast: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { SettingsModal } from "../SettingsModal";
|
import { SettingsModal } from "../SettingsModal";
|
||||||
import type { Settings } from "@hai/core";
|
import type { Settings } from "@kb/core";
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { Column } from "@hai/core";
|
import type { Column } from "@kb/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for the agent-active class logic in TaskCard.
|
* Tests for the agent-active class logic in TaskCard.
|
||||||
@@ -139,11 +139,11 @@ describe("TaskCard dependency tooltip", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("returns comma-separated dependency IDs when dependencies are present", () => {
|
it("returns comma-separated dependency IDs when dependencies are present", () => {
|
||||||
expect(computeDepTooltip(["HAI-001", "HAI-042"])).toBe("HAI-001, HAI-042");
|
expect(computeDepTooltip(["KB-001", "KB-042"])).toBe("KB-001, KB-042");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns single dependency ID when only one dependency", () => {
|
it("returns single dependency ID when only one dependency", () => {
|
||||||
expect(computeDepTooltip(["HAI-010"])).toBe("HAI-010");
|
expect(computeDepTooltip(["KB-010"])).toBe("KB-010");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns undefined when dependencies array is empty", () => {
|
it("returns undefined when dependencies array is empty", () => {
|
||||||
@@ -151,12 +151,12 @@ describe("TaskCard dependency tooltip", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles many dependencies", () => {
|
it("handles many dependencies", () => {
|
||||||
const deps = ["HAI-001", "HAI-002", "HAI-003", "HAI-004"];
|
const deps = ["KB-001", "KB-002", "KB-003", "KB-004"];
|
||||||
expect(computeDepTooltip(deps)).toBe("HAI-001, HAI-002, HAI-003, HAI-004");
|
expect(computeDepTooltip(deps)).toBe("KB-001, KB-002, KB-003, KB-004");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("data-tooltip attribute contains dependency IDs as a readable string", () => {
|
it("data-tooltip attribute contains dependency IDs as a readable string", () => {
|
||||||
const deps = ["HAI-005", "HAI-012"];
|
const deps = ["KB-005", "KB-012"];
|
||||||
const tooltip = computeDepTooltip(deps);
|
const tooltip = computeDepTooltip(deps);
|
||||||
expect(tooltip).toBeDefined();
|
expect(tooltip).toBeDefined();
|
||||||
// Each dependency ID should appear in the tooltip
|
// Each dependency ID should appear in the tooltip
|
||||||
@@ -179,7 +179,7 @@ describe("TaskCard file-scope overlap badge logic", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("shows scope badge when blockedBy is set", () => {
|
it("shows scope badge when blockedBy is set", () => {
|
||||||
expect(shouldShowScopeBadge("HAI-003")).toBe(true);
|
expect(shouldShowScopeBadge("KB-003")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT show scope badge when blockedBy is undefined", () => {
|
it("does NOT show scope badge when blockedBy is undefined", () => {
|
||||||
@@ -187,7 +187,7 @@ describe("TaskCard file-scope overlap badge logic", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows card-meta when blockedBy is set even with no deps or queued status", () => {
|
it("shows card-meta when blockedBy is set even with no deps or queued status", () => {
|
||||||
expect(shouldShowCardMeta({ blockedBy: "HAI-003" })).toBe(true);
|
expect(shouldShowCardMeta({ blockedBy: "KB-003" })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT show card-meta when no deps, not queued, and no blockedBy", () => {
|
it("does NOT show card-meta when no deps, not queued, and no blockedBy", () => {
|
||||||
@@ -200,7 +200,7 @@ describe("TaskCard file-scope overlap badge logic", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("generates correct tooltip text", () => {
|
it("generates correct tooltip text", () => {
|
||||||
expect(computeScopeTooltip("HAI-005")).toBe("Blocked by HAI-005 (file overlap)");
|
expect(computeScopeTooltip("KB-005")).toBe("Blocked by KB-005 (file overlap)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||||
import { TaskDetailModal } from "../TaskDetailModal";
|
import { TaskDetailModal } from "../TaskDetailModal";
|
||||||
import type { TaskDetail, Column, MergeResult, Task } from "@hai/core";
|
import type { TaskDetail, Column, MergeResult, Task } from "@kb/core";
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
uploadAttachment: vi.fn(),
|
uploadAttachment: vi.fn(),
|
||||||
@@ -16,7 +16,7 @@ vi.mock("../../hooks/useAgentLogs", () => ({
|
|||||||
|
|
||||||
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||||
return {
|
return {
|
||||||
id: "HAI-099",
|
id: "KB-099",
|
||||||
description: "Test task",
|
description: "Test task",
|
||||||
column: "in-progress" as Column,
|
column: "in-progress" as Column,
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
@@ -157,7 +157,7 @@ describe("TaskDetailModal", () => {
|
|||||||
task={makeTask({
|
task={makeTask({
|
||||||
title: undefined,
|
title: undefined,
|
||||||
description: "Fix the login bug",
|
description: "Fix the login bug",
|
||||||
prompt: "# HAI-099\n\nFix the login bug\n",
|
prompt: "# KB-099\n\nFix the login bug\n",
|
||||||
})}
|
})}
|
||||||
onClose={noop}
|
onClose={noop}
|
||||||
onMoveTask={noopMove}
|
onMoveTask={noopMove}
|
||||||
@@ -167,13 +167,13 @@ describe("TaskDetailModal", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// The heading "HAI-099" should be stripped from the markdown
|
// The heading "KB-099" should be stripped from the markdown
|
||||||
const markdownBody = container.querySelector(".markdown-body");
|
const markdownBody = container.querySelector(".markdown-body");
|
||||||
expect(markdownBody?.innerHTML).not.toContain("HAI-099");
|
expect(markdownBody?.innerHTML).not.toContain("KB-099");
|
||||||
// Description appears in the markdown body
|
// Description appears in the markdown body
|
||||||
expect(markdownBody?.textContent).toContain("Fix the login bug");
|
expect(markdownBody?.textContent).toContain("Fix the login bug");
|
||||||
// The detail header shows the ID (not duplicated as markdown heading)
|
// The detail header shows the ID (not duplicated as markdown heading)
|
||||||
expect(container.querySelector(".detail-id")?.textContent).toBe("HAI-099");
|
expect(container.querySelector(".detail-id")?.textContent).toBe("KB-099");
|
||||||
// The h2 title shows description, not the task ID
|
// The h2 title shows description, not the task ID
|
||||||
const h2 = container.querySelector("h2.detail-title");
|
const h2 = container.querySelector("h2.detail-title");
|
||||||
expect(h2?.textContent).toBe("Fix the login bug");
|
expect(h2?.textContent).toBe("Fix the login bug");
|
||||||
@@ -210,7 +210,7 @@ describe("TaskDetailModal", () => {
|
|||||||
addToast={noop}
|
addToast={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(withTitle.querySelector(".detail-id")?.textContent).toBe("HAI-099");
|
expect(withTitle.querySelector(".detail-id")?.textContent).toBe("KB-099");
|
||||||
|
|
||||||
// Without title
|
// Without title
|
||||||
const { container: withoutTitle } = render(
|
const { container: withoutTitle } = render(
|
||||||
@@ -223,7 +223,7 @@ describe("TaskDetailModal", () => {
|
|||||||
addToast={noop}
|
addToast={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(withoutTitle.querySelector(".detail-id")?.textContent).toBe("HAI-099");
|
expect(withoutTitle.querySelector(".detail-id")?.textContent).toBe("KB-099");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("paste image upload", () => {
|
describe("paste image upload", () => {
|
||||||
@@ -267,7 +267,7 @@ describe("TaskDetailModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpload).toHaveBeenCalledWith("HAI-099", imageFile);
|
expect(mockUpload).toHaveBeenCalledWith("KB-099", imageFile);
|
||||||
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -394,7 +394,7 @@ describe("TaskDetailModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpload).toHaveBeenCalledWith("HAI-099", imageFile);
|
expect(mockUpload).toHaveBeenCalledWith("KB-099", imageFile);
|
||||||
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -418,7 +418,7 @@ describe("TaskDetailModal", () => {
|
|||||||
it("renders dependency list when dependencies exist", () => {
|
it("renders dependency list when dependencies exist", () => {
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
task={makeTask({ dependencies: ["HAI-001", "HAI-002"] })}
|
task={makeTask({ dependencies: ["KB-001", "KB-002"] })}
|
||||||
onClose={noop}
|
onClose={noop}
|
||||||
onMoveTask={noopMove}
|
onMoveTask={noopMove}
|
||||||
onDeleteTask={noopDelete}
|
onDeleteTask={noopDelete}
|
||||||
@@ -427,16 +427,16 @@ describe("TaskDetailModal", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("HAI-001")).toBeTruthy();
|
expect(screen.getByText("KB-001")).toBeTruthy();
|
||||||
expect(screen.getByText("HAI-002")).toBeTruthy();
|
expect(screen.getByText("KB-002")).toBeTruthy();
|
||||||
expect(screen.queryByText("(no dependencies)")).toBeNull();
|
expect(screen.queryByText("(no dependencies)")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can add a dependency via the dropdown", async () => {
|
it("can add a dependency via the dropdown", async () => {
|
||||||
const { updateTask } = await import("../../api");
|
const { updateTask } = await import("../../api");
|
||||||
const allTasks: Task[] = [
|
const allTasks: Task[] = [
|
||||||
{ id: "HAI-001", description: "Dep 1", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
{ id: "KB-001", description: "Dep 1", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
||||||
{ id: "HAI-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
{ id: "KB-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
||||||
];
|
];
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -452,16 +452,16 @@ describe("TaskDetailModal", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Add Dependency"));
|
fireEvent.click(screen.getByText("Add Dependency"));
|
||||||
// Should show HAI-001 in the dropdown but not HAI-099 (self is excluded)
|
// Should show KB-001 in the dropdown but not KB-099 (self is excluded)
|
||||||
const dropdown = document.querySelector(".dep-dropdown")!;
|
const dropdown = document.querySelector(".dep-dropdown")!;
|
||||||
expect(dropdown).toBeTruthy();
|
expect(dropdown).toBeTruthy();
|
||||||
expect(dropdown.textContent).toContain("HAI-001");
|
expect(dropdown.textContent).toContain("KB-001");
|
||||||
expect(dropdown.querySelectorAll(".dep-dropdown-item")).toHaveLength(1);
|
expect(dropdown.querySelectorAll(".dep-dropdown-item")).toHaveLength(1);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("HAI-001"));
|
fireEvent.click(screen.getByText("KB-001"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updateTask).toHaveBeenCalledWith("HAI-099", { dependencies: ["HAI-001"] });
|
expect(updateTask).toHaveBeenCalledWith("KB-099", { dependencies: ["KB-001"] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -470,7 +470,7 @@ describe("TaskDetailModal", () => {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
task={makeTask({ dependencies: ["HAI-001", "HAI-002"] })}
|
task={makeTask({ dependencies: ["KB-001", "KB-002"] })}
|
||||||
onClose={noop}
|
onClose={noop}
|
||||||
onMoveTask={noopMove}
|
onMoveTask={noopMove}
|
||||||
onDeleteTask={noopDelete}
|
onDeleteTask={noopDelete}
|
||||||
@@ -480,10 +480,10 @@ describe("TaskDetailModal", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const removeButtons = screen.getAllByTitle(/Remove dependency/);
|
const removeButtons = screen.getAllByTitle(/Remove dependency/);
|
||||||
fireEvent.click(removeButtons[0]); // Remove HAI-001
|
fireEvent.click(removeButtons[0]); // Remove KB-001
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updateTask).toHaveBeenCalledWith("HAI-099", { dependencies: ["HAI-002"] });
|
expect(updateTask).toHaveBeenCalledWith("KB-099", { dependencies: ["KB-002"] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe("useAgentLogs", () => {
|
describe("useAgentLogs", () => {
|
||||||
it("does not fetch or connect when enabled=false", () => {
|
it("does not fetch or connect when enabled=false", () => {
|
||||||
const { result } = renderHook(() => useAgentLogs("HAI-001", false));
|
const { result } = renderHook(() => useAgentLogs("KB-001", false));
|
||||||
|
|
||||||
expect(mockFetchAgentLogs).not.toHaveBeenCalled();
|
expect(mockFetchAgentLogs).not.toHaveBeenCalled();
|
||||||
expect(MockEventSource.instances).toHaveLength(0);
|
expect(MockEventSource.instances).toHaveLength(0);
|
||||||
@@ -60,27 +60,27 @@ describe("useAgentLogs", () => {
|
|||||||
|
|
||||||
it("fetches historical logs and opens SSE when enabled=true", async () => {
|
it("fetches historical logs and opens SSE when enabled=true", async () => {
|
||||||
const historicalLogs = [
|
const historicalLogs = [
|
||||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "HAI-001", text: "old", type: "text" as const },
|
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "old", type: "text" as const },
|
||||||
];
|
];
|
||||||
mockFetchAgentLogs.mockResolvedValueOnce(historicalLogs);
|
mockFetchAgentLogs.mockResolvedValueOnce(historicalLogs);
|
||||||
|
|
||||||
const { result } = renderHook(() => useAgentLogs("HAI-001", true));
|
const { result } = renderHook(() => useAgentLogs("KB-001", true));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.entries).toEqual(historicalLogs);
|
expect(result.current.entries).toEqual(historicalLogs);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("HAI-001");
|
expect(mockFetchAgentLogs).toHaveBeenCalledWith("KB-001");
|
||||||
expect(MockEventSource.instances).toHaveLength(1);
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/HAI-001/logs/stream");
|
expect(MockEventSource.instances[0].url).toBe("/api/tasks/KB-001/logs/stream");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("appends live SSE entries to historical entries", async () => {
|
it("appends live SSE entries to historical entries", async () => {
|
||||||
mockFetchAgentLogs.mockResolvedValueOnce([
|
mockFetchAgentLogs.mockResolvedValueOnce([
|
||||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "HAI-001", text: "old", type: "text" as const },
|
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "old", type: "text" as const },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { result } = renderHook(() => useAgentLogs("HAI-001", true));
|
const { result } = renderHook(() => useAgentLogs("KB-001", true));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.entries).toHaveLength(1);
|
expect(result.current.entries).toHaveLength(1);
|
||||||
@@ -90,7 +90,7 @@ describe("useAgentLogs", () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
es._emit("agent:log", {
|
es._emit("agent:log", {
|
||||||
timestamp: "2026-01-01T00:01:00Z",
|
timestamp: "2026-01-01T00:01:00Z",
|
||||||
taskId: "HAI-001",
|
taskId: "KB-001",
|
||||||
text: "new",
|
text: "new",
|
||||||
type: "text",
|
type: "text",
|
||||||
});
|
});
|
||||||
@@ -104,7 +104,7 @@ describe("useAgentLogs", () => {
|
|||||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
({ enabled }) => useAgentLogs("HAI-001", enabled),
|
({ enabled }) => useAgentLogs("KB-001", enabled),
|
||||||
{ initialProps: { enabled: true } },
|
{ initialProps: { enabled: true } },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ describe("useAgentLogs", () => {
|
|||||||
it("closes SSE on unmount", async () => {
|
it("closes SSE on unmount", async () => {
|
||||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
const { unmount } = renderHook(() => useAgentLogs("HAI-001", true));
|
const { unmount } = renderHook(() => useAgentLogs("KB-001", true));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(MockEventSource.instances).toHaveLength(1);
|
expect(MockEventSource.instances).toHaveLength(1);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import type { AgentLogEntry } from "@hai/core";
|
import type { AgentLogEntry } from "@kb/core";
|
||||||
import { fetchAgentLogs } from "../api";
|
import { fetchAgentLogs } from "../api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { Task, Column, TaskCreateInput, MergeResult } from "@hai/core";
|
import type { Task, Column, TaskCreateInput, MergeResult } from "@kb/core";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
|
||||||
export function useTasks() {
|
export function useTasks() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>hai | board</title>
|
<title>kb | board</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { groupByWorktree, getWorktreeLabel } from "./worktreeGrouping";
|
import { groupByWorktree, getWorktreeLabel } from "./worktreeGrouping";
|
||||||
import type { Task } from "@hai/core";
|
import type { Task } from "@kb/core";
|
||||||
|
|
||||||
function makeTask(overrides: Partial<Task> & { id: string }): Task {
|
function makeTask(overrides: Partial<Task> & { id: string }): Task {
|
||||||
return {
|
return {
|
||||||
@@ -18,8 +18,8 @@ function makeTask(overrides: Partial<Task> & { id: string }): Task {
|
|||||||
|
|
||||||
describe("getWorktreeLabel", () => {
|
describe("getWorktreeLabel", () => {
|
||||||
it("extracts last path segment", () => {
|
it("extracts last path segment", () => {
|
||||||
expect(getWorktreeLabel(".worktrees/HAI-001")).toBe("HAI-001");
|
expect(getWorktreeLabel(".worktrees/KB-001")).toBe("KB-001");
|
||||||
expect(getWorktreeLabel("/path/to/hai/hai-001")).toBe("hai-001");
|
expect(getWorktreeLabel("/path/to/kb/kb-001")).toBe("kb-001");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts humanized worktree names", () => {
|
it("extracts humanized worktree names", () => {
|
||||||
@@ -31,8 +31,8 @@ describe("getWorktreeLabel", () => {
|
|||||||
|
|
||||||
describe("groupByWorktree", () => {
|
describe("groupByWorktree", () => {
|
||||||
it("groups active in-progress tasks by worktree", () => {
|
it("groups active in-progress tasks by worktree", () => {
|
||||||
const t1 = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
|
const t1 = makeTask({ id: "KB-001", worktree: ".worktrees/swift-falcon" });
|
||||||
const t2 = makeTask({ id: "HAI-002", worktree: ".worktrees/quiet-robin" });
|
const t2 = makeTask({ id: "KB-002", worktree: ".worktrees/quiet-robin" });
|
||||||
|
|
||||||
const groups = groupByWorktree([t1, t2], [t1, t2], 2);
|
const groups = groupByWorktree([t1, t2], [t1, t2], 2);
|
||||||
|
|
||||||
@@ -44,9 +44,9 @@ describe("groupByWorktree", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("places queued tasks only in the Up Next group, never in worktree groups", () => {
|
it("places queued tasks only in the Up Next group, never in worktree groups", () => {
|
||||||
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
|
const active = makeTask({ id: "KB-001", worktree: ".worktrees/swift-falcon" });
|
||||||
const queued = makeTask({
|
const queued = makeTask({
|
||||||
id: "HAI-002",
|
id: "KB-002",
|
||||||
column: "todo",
|
column: "todo",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
});
|
});
|
||||||
@@ -66,7 +66,7 @@ describe("groupByWorktree", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not create Up Next group when there are no eligible queued tasks", () => {
|
it("does not create Up Next group when there are no eligible queued tasks", () => {
|
||||||
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
|
const active = makeTask({ id: "KB-001", worktree: ".worktrees/swift-falcon" });
|
||||||
|
|
||||||
const groups = groupByWorktree([active], [active], 2);
|
const groups = groupByWorktree([active], [active], 2);
|
||||||
|
|
||||||
@@ -74,11 +74,11 @@ describe("groupByWorktree", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not create Up Next when queued tasks have unsatisfied dependencies", () => {
|
it("does not create Up Next when queued tasks have unsatisfied dependencies", () => {
|
||||||
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
|
const active = makeTask({ id: "KB-001", worktree: ".worktrees/swift-falcon" });
|
||||||
const blocked = makeTask({
|
const blocked = makeTask({
|
||||||
id: "HAI-002",
|
id: "KB-002",
|
||||||
column: "todo",
|
column: "todo",
|
||||||
dependencies: ["HAI-003"], // HAI-003 doesn't exist or isn't done
|
dependencies: ["KB-003"], // KB-003 doesn't exist or isn't done
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = groupByWorktree([active], [active, blocked], 2);
|
const groups = groupByWorktree([active], [active, blocked], 2);
|
||||||
@@ -87,10 +87,10 @@ describe("groupByWorktree", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("respects maxConcurrent limit on queued tasks shown", () => {
|
it("respects maxConcurrent limit on queued tasks shown", () => {
|
||||||
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
|
const active = makeTask({ id: "KB-001", worktree: ".worktrees/swift-falcon" });
|
||||||
const q1 = makeTask({ id: "HAI-010", column: "todo" });
|
const q1 = makeTask({ id: "KB-010", column: "todo" });
|
||||||
const q2 = makeTask({ id: "HAI-011", column: "todo" });
|
const q2 = makeTask({ id: "KB-011", column: "todo" });
|
||||||
const q3 = makeTask({ id: "HAI-012", column: "todo" });
|
const q3 = makeTask({ id: "KB-012", column: "todo" });
|
||||||
|
|
||||||
const groups = groupByWorktree([active], [active, q1, q2, q3], 2);
|
const groups = groupByWorktree([active], [active, q1, q2, q3], 2);
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ describe("groupByWorktree", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("places unassigned in-progress tasks in Unassigned group", () => {
|
it("places unassigned in-progress tasks in Unassigned group", () => {
|
||||||
const unassigned = makeTask({ id: "HAI-001" }); // no worktree
|
const unassigned = makeTask({ id: "KB-001" }); // no worktree
|
||||||
|
|
||||||
const groups = groupByWorktree([unassigned], [unassigned], 2);
|
const groups = groupByWorktree([unassigned], [unassigned], 2);
|
||||||
|
|
||||||
@@ -110,15 +110,15 @@ describe("groupByWorktree", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("excludes paused todo tasks from Up Next", () => {
|
it("excludes paused todo tasks from Up Next", () => {
|
||||||
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
|
const active = makeTask({ id: "KB-001", worktree: ".worktrees/swift-falcon" });
|
||||||
const paused = makeTask({
|
const paused = makeTask({
|
||||||
id: "HAI-002",
|
id: "KB-002",
|
||||||
column: "todo",
|
column: "todo",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
paused: true,
|
paused: true,
|
||||||
});
|
});
|
||||||
const normal = makeTask({
|
const normal = makeTask({
|
||||||
id: "HAI-003",
|
id: "KB-003",
|
||||||
column: "todo",
|
column: "todo",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
});
|
});
|
||||||
@@ -127,16 +127,16 @@ describe("groupByWorktree", () => {
|
|||||||
|
|
||||||
const upNext = groups.find((g) => g.label === "Up Next");
|
const upNext = groups.find((g) => g.label === "Up Next");
|
||||||
expect(upNext).toBeDefined();
|
expect(upNext).toBeDefined();
|
||||||
expect(upNext!.queuedTasks.map((t) => t.id)).toEqual(["HAI-003"]);
|
expect(upNext!.queuedTasks.map((t) => t.id)).toEqual(["KB-003"]);
|
||||||
expect(upNext!.queuedTasks.map((t) => t.id)).not.toContain("HAI-002");
|
expect(upNext!.queuedTasks.map((t) => t.id)).not.toContain("KB-002");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("queued tasks with satisfied deps appear in Up Next", () => {
|
it("queued tasks with satisfied deps appear in Up Next", () => {
|
||||||
const done = makeTask({ id: "HAI-001", column: "done" });
|
const done = makeTask({ id: "KB-001", column: "done" });
|
||||||
const queued = makeTask({
|
const queued = makeTask({
|
||||||
id: "HAI-002",
|
id: "KB-002",
|
||||||
column: "todo",
|
column: "todo",
|
||||||
dependencies: ["HAI-001"],
|
dependencies: ["KB-001"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = groupByWorktree([], [done, queued], 2);
|
const groups = groupByWorktree([], [done, queued], 2);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Task } from "@hai/core";
|
import type { Task } from "@kb/core";
|
||||||
|
|
||||||
export interface WorktreeGroupData {
|
export interface WorktreeGroupData {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -8,7 +8,7 @@ export interface WorktreeGroupData {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a clean display name from a worktree path.
|
* Extract a clean display name from a worktree path.
|
||||||
* e.g. ".worktrees/HAI-001" → "HAI-001", "/path/to/hai/hai-001" → "hai-001"
|
* e.g. ".worktrees/KB-001" → "KB-001", "/path/to/kb/kb-001" → "kb-001"
|
||||||
*/
|
*/
|
||||||
export function getWorktreeLabel(worktreePath: string): string {
|
export function getWorktreeLabel(worktreePath: string): string {
|
||||||
// Take the last segment of the path
|
// Take the last segment of the path
|
||||||
@@ -18,8 +18,8 @@ export function getWorktreeLabel(worktreePath: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Topological sort of tasks by dependency order.
|
* Topological sort of tasks by dependency order.
|
||||||
* Mirrors resolveDependencyOrder from @hai/core but inlined to avoid
|
* Mirrors resolveDependencyOrder from @kb/core but inlined to avoid
|
||||||
* build alias issues (Vite aliases @hai/core to types.ts only).
|
* build alias issues (Vite aliases @kb/core to types.ts only).
|
||||||
*/
|
*/
|
||||||
function resolveDependencyOrder(tasks: Task[]): string[] {
|
function resolveDependencyOrder(tasks: Task[]): string[] {
|
||||||
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@hai/dashboard",
|
"name": "@kb/dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hai/core": "workspace:*",
|
"@kb/core": "workspace:*",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>hai | board</title>
|
<title>kb | board</title>
|
||||||
<link rel="stylesheet" href="/style.css" />
|
<link rel="stylesheet" href="/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="logo">hai</h1>
|
<h1 class="logo">kb</h1>
|
||||||
<span class="logo-sub">board</span>
|
<span class="logo-sub">board</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" id="add-task-btn">+ New Task</button>
|
<button class="btn btn-primary" id="add-task-btn">+ New Task</button>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
>Dependencies
|
>Dependencies
|
||||||
<span class="optional">(comma-separated IDs)</span></label
|
<span class="optional">(comma-separated IDs)</span></label
|
||||||
>
|
>
|
||||||
<input type="text" id="task-deps" placeholder="HAI-001, HAI-002" />
|
<input type="text" id="task-deps" placeholder="KB-001, KB-002" />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn" data-close="create-modal">
|
<button type="button" class="btn" data-close="create-modal">
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import { createApiRoutes } from "./routes.js";
|
import { createApiRoutes } from "./routes.js";
|
||||||
import type { TaskStore, TaskAttachment } from "@hai/core";
|
import type { TaskStore, TaskAttachment } from "@kb/core";
|
||||||
import type { TaskDetail } from "@hai/core";
|
import type { TaskDetail } from "@kb/core";
|
||||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||||
|
|
||||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||||
@@ -24,7 +24,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FAKE_TASK_DETAIL: TaskDetail = {
|
const FAKE_TASK_DETAIL: TaskDetail = {
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
description: "Test task",
|
description: "Test task",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
@@ -33,7 +33,7 @@ const FAKE_TASK_DETAIL: TaskDetail = {
|
|||||||
log: [],
|
log: [],
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
prompt: "# HAI-001\n\nTest task",
|
prompt: "# KB-001\n\nTest task",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Helper: send GET and return { status, body } */
|
/** Helper: send GET and return { status, body } */
|
||||||
@@ -117,11 +117,11 @@ describe("GET /tasks/:id", () => {
|
|||||||
it("returns task detail on success", async () => {
|
it("returns task detail on success", async () => {
|
||||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);
|
||||||
|
|
||||||
const res = await GET(buildApp(), "/api/tasks/HAI-001");
|
const res = await GET(buildApp(), "/api/tasks/KB-001");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.id).toBe("HAI-001");
|
expect(res.body.id).toBe("KB-001");
|
||||||
expect(res.body.prompt).toBe("# HAI-001\n\nTest task");
|
expect(res.body.prompt).toBe("# KB-001\n\nTest task");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 when task genuinely does not exist (ENOENT)", async () => {
|
it("returns 404 when task genuinely does not exist (ENOENT)", async () => {
|
||||||
@@ -129,7 +129,7 @@ describe("GET /tasks/:id", () => {
|
|||||||
err.code = "ENOENT";
|
err.code = "ENOENT";
|
||||||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
||||||
|
|
||||||
const res = await GET(buildApp(), "/api/tasks/HAI-999");
|
const res = await GET(buildApp(), "/api/tasks/KB-999");
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
expect(res.body.error).toContain("not found");
|
expect(res.body.error).toContain("not found");
|
||||||
@@ -139,7 +139,7 @@ describe("GET /tasks/:id", () => {
|
|||||||
const err = new Error("Unexpected end of JSON input");
|
const err = new Error("Unexpected end of JSON input");
|
||||||
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
||||||
|
|
||||||
const res = await GET(buildApp(), "/api/tasks/HAI-001");
|
const res = await GET(buildApp(), "/api/tasks/KB-001");
|
||||||
|
|
||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
expect(res.body.error).toContain("Unexpected end of JSON input");
|
expect(res.body.error).toContain("Unexpected end of JSON input");
|
||||||
@@ -167,20 +167,20 @@ describe("POST /tasks/:id/retry", () => {
|
|||||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTask);
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(failedTask);
|
||||||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/retry", JSON.stringify({}), {
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", { status: undefined });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined });
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-001", "todo");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when task is not in failed state", async () => {
|
it("returns 400 when task is not in failed state", async () => {
|
||||||
const activeTask = { ...FAKE_TASK_DETAIL, status: "executing" };
|
const activeTask = { ...FAKE_TASK_DETAIL, status: "executing" };
|
||||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(activeTask);
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(activeTask);
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/retry", JSON.stringify({}), {
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ describe("POST /tasks/:id/retry", () => {
|
|||||||
const doneTask = { ...FAKE_TASK_DETAIL, column: "done", status: "failed" };
|
const doneTask = { ...FAKE_TASK_DETAIL, column: "done", status: "failed" };
|
||||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(doneTask);
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/retry", JSON.stringify({}), {
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,32 +216,32 @@ describe("PATCH /tasks/:id", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("forwards dependencies to store.updateTask", async () => {
|
it("forwards dependencies to store.updateTask", async () => {
|
||||||
const updatedTask = { ...FAKE_TASK_DETAIL, dependencies: ["HAI-002"] };
|
const updatedTask = { ...FAKE_TASK_DETAIL, dependencies: ["KB-002"] };
|
||||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/HAI-001", JSON.stringify({ dependencies: ["HAI-002"] }), {
|
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ dependencies: ["KB-002"] }), {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", {
|
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||||
title: undefined,
|
title: undefined,
|
||||||
description: undefined,
|
description: undefined,
|
||||||
prompt: undefined,
|
prompt: undefined,
|
||||||
dependencies: ["HAI-002"],
|
dependencies: ["KB-002"],
|
||||||
});
|
});
|
||||||
expect(res.body.dependencies).toEqual(["HAI-002"]);
|
expect(res.body.dependencies).toEqual(["KB-002"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards title and description without dependencies", async () => {
|
it("forwards title and description without dependencies", async () => {
|
||||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, title: "New" });
|
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, title: "New" });
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/HAI-001", JSON.stringify({ title: "New" }), {
|
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ title: "New" }), {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", {
|
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||||
title: "New",
|
title: "New",
|
||||||
description: undefined,
|
description: undefined,
|
||||||
prompt: undefined,
|
prompt: undefined,
|
||||||
@@ -280,14 +280,14 @@ describe("Attachment routes", () => {
|
|||||||
const content = Buffer.from("fake png content");
|
const content = Buffer.from("fake png content");
|
||||||
const { body, boundary } = buildMultipart("file", "screenshot.png", "image/png", content);
|
const { body, boundary } = buildMultipart("file", "screenshot.png", "image/png", content);
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/attachments", body, {
|
||||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(res.body.filename).toBe("1234-screenshot.png");
|
expect(res.body.filename).toBe("1234-screenshot.png");
|
||||||
expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||||
"HAI-001",
|
"KB-001",
|
||||||
"screenshot.png",
|
"screenshot.png",
|
||||||
expect.any(Buffer),
|
expect.any(Buffer),
|
||||||
"image/png",
|
"image/png",
|
||||||
@@ -302,7 +302,7 @@ describe("Attachment routes", () => {
|
|||||||
const content = Buffer.from("not an image");
|
const content = Buffer.from("not an image");
|
||||||
const { body, boundary } = buildMultipart("file", "file.txt", "text/plain", content);
|
const { body, boundary } = buildMultipart("file", "file.txt", "text/plain", content);
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/attachments", body, {
|
||||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,7 +318,7 @@ describe("Attachment routes", () => {
|
|||||||
const content = Buffer.from("small but store rejects");
|
const content = Buffer.from("small but store rejects");
|
||||||
const { body, boundary } = buildMultipart("file", "big.png", "image/png", content);
|
const { body, boundary } = buildMultipart("file", "big.png", "image/png", content);
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/attachments", body, {
|
||||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -327,10 +327,10 @@ describe("Attachment routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("DELETE /tasks/:id/attachments/:filename — deletes attachment", async () => {
|
it("DELETE /tasks/:id/attachments/:filename — deletes attachment", async () => {
|
||||||
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/HAI-001/attachments/1234-screenshot.png");
|
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001/attachments/1234-screenshot.png");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("HAI-001", "1234-screenshot.png");
|
expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("KB-001", "1234-screenshot.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("DELETE /tasks/:id/attachments/:filename — returns 404 for missing", async () => {
|
it("DELETE /tasks/:id/attachments/:filename — returns 404 for missing", async () => {
|
||||||
@@ -338,29 +338,29 @@ describe("Attachment routes", () => {
|
|||||||
err.code = "ENOENT";
|
err.code = "ENOENT";
|
||||||
(store.deleteAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
(store.deleteAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
||||||
|
|
||||||
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/HAI-001/attachments/nope.png");
|
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001/attachments/nope.png");
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("GET /tasks/:id/logs — returns agent logs", async () => {
|
it("GET /tasks/:id/logs — returns agent logs", async () => {
|
||||||
const fakeLogs = [
|
const fakeLogs = [
|
||||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "HAI-001", text: "Hello", type: "text" },
|
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "Hello", type: "text" },
|
||||||
{ timestamp: "2026-01-01T00:00:01Z", taskId: "HAI-001", text: "Read", type: "tool" },
|
{ timestamp: "2026-01-01T00:00:01Z", taskId: "KB-001", text: "Read", type: "tool" },
|
||||||
];
|
];
|
||||||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue(fakeLogs);
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue(fakeLogs);
|
||||||
|
|
||||||
const res = await GET(buildApp(), "/api/tasks/HAI-001/logs");
|
const res = await GET(buildApp(), "/api/tasks/KB-001/logs");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toEqual(fakeLogs);
|
expect(res.body).toEqual(fakeLogs);
|
||||||
expect(store.getAgentLogs).toHaveBeenCalledWith("HAI-001");
|
expect(store.getAgentLogs).toHaveBeenCalledWith("KB-001");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("GET /tasks/:id/logs — returns empty array when no logs", async () => {
|
it("GET /tasks/:id/logs — returns empty array when no logs", async () => {
|
||||||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
||||||
const res = await GET(buildApp(), "/api/tasks/HAI-001/logs");
|
const res = await GET(buildApp(), "/api/tasks/KB-001/logs");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toEqual([]);
|
expect(res.body).toEqual([]);
|
||||||
@@ -369,7 +369,7 @@ describe("Attachment routes", () => {
|
|||||||
it("GET /tasks/:id/logs — returns 500 on store error", async () => {
|
it("GET /tasks/:id/logs — returns 500 on store error", async () => {
|
||||||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("disk error"));
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("disk error"));
|
||||||
|
|
||||||
const res = await GET(buildApp(), "/api/tasks/HAI-001/logs");
|
const res = await GET(buildApp(), "/api/tasks/KB-001/logs");
|
||||||
|
|
||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
expect(res.body.error).toBe("disk error");
|
expect(res.body.error).toBe("disk error");
|
||||||
@@ -631,27 +631,27 @@ describe("Pause/Unpause endpoints", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store = createMockStore({
|
store = createMockStore({
|
||||||
pauseTask: vi.fn().mockResolvedValue({ id: "HAI-001", paused: true }),
|
pauseTask: vi.fn().mockResolvedValue({ id: "KB-001", paused: true }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /tasks/:id/pause — pauses a task", async () => {
|
it("POST /tasks/:id/pause — pauses a task", async () => {
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/pause");
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/pause");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toEqual({ id: "HAI-001", paused: true });
|
expect(res.body).toEqual({ id: "KB-001", paused: true });
|
||||||
expect(store.pauseTask).toHaveBeenCalledWith("HAI-001", true);
|
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /tasks/:id/unpause — unpauses a task", async () => {
|
it("POST /tasks/:id/unpause — unpauses a task", async () => {
|
||||||
(store.pauseTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "HAI-001" });
|
(store.pauseTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "KB-001" });
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/unpause");
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unpause");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(store.pauseTask).toHaveBeenCalledWith("HAI-001", false);
|
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /tasks/:id/pause — returns 500 on error", async () => {
|
it("POST /tasks/:id/pause — returns 500 on error", async () => {
|
||||||
(store.pauseTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("not found"));
|
(store.pauseTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("not found"));
|
||||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/pause");
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/pause");
|
||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
expect(res.body.error).toBe("not found");
|
expect(res.body.error).toBe("not found");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
import type { TaskStore, Column, MergeResult } from "@hai/core";
|
import type { TaskStore, Column, MergeResult } from "@kb/core";
|
||||||
import { COLUMNS } from "@hai/core";
|
import { COLUMNS } from "@kb/core";
|
||||||
import type { ServerOptions } from "./server.js";
|
import type { ServerOptions } from "./server.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import express from "express";
|
|||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { TaskStore, MergeResult } from "@hai/core";
|
import type { TaskStore, MergeResult } from "@kb/core";
|
||||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||||
import { createApiRoutes } from "./routes.js";
|
import { createApiRoutes } from "./routes.js";
|
||||||
import { createSSE } from "./sse.js";
|
import { createSSE } from "./sse.js";
|
||||||
@@ -27,14 +27,14 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
|
|
||||||
// Serve built React app
|
// Serve built React app
|
||||||
// Resolution order:
|
// Resolution order:
|
||||||
// 1. HAI_CLIENT_DIR env override (explicit)
|
// 1. KB_CLIENT_DIR env override (explicit)
|
||||||
// 2. Next to process.execPath (bun-compiled binary: dist/hai + dist/client/)
|
// 2. Next to process.execPath (bun-compiled binary: dist/kb + dist/client/)
|
||||||
// 3. __dirname/../dist/client (running from src/ via tsx/ts-node)
|
// 3. __dirname/../dist/client (running from src/ via tsx/ts-node)
|
||||||
// 4. __dirname/../client (running from dist/ after tsc)
|
// 4. __dirname/../client (running from dist/ after tsc)
|
||||||
// 5. __dirname/../public (fallback for dev)
|
// 5. __dirname/../public (fallback for dev)
|
||||||
const execDir = dirname(process.execPath);
|
const execDir = dirname(process.execPath);
|
||||||
const clientDir = process.env.HAI_CLIENT_DIR
|
const clientDir = process.env.KB_CLIENT_DIR
|
||||||
? process.env.HAI_CLIENT_DIR
|
? process.env.KB_CLIENT_DIR
|
||||||
: existsSync(join(execDir, "client", "index.html"))
|
: existsSync(join(execDir, "client", "index.html"))
|
||||||
? join(execDir, "client")
|
? join(execDir, "client")
|
||||||
: existsSync(join(__dirname, "..", "dist", "client"))
|
: existsSync(join(__dirname, "..", "dist", "client"))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { TaskStore } from "@hai/core";
|
import type { TaskStore } from "@kb/core";
|
||||||
|
|
||||||
export function createSSE(store: TaskStore) {
|
export function createSSE(store: TaskStore) {
|
||||||
return (_req: Request, res: Response) => {
|
return (_req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@hai/core": resolve(__dirname, "../core/src/types.ts"),
|
"@kb/core": resolve(__dirname, "../core/src/types.ts"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@hai/core": resolve(__dirname, "../core/src/types.ts"),
|
"@kb/core": resolve(__dirname, "../core/src/types.ts"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@hai/engine",
|
"name": "@kb/engine",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hai/core": "workspace:*",
|
"@kb/core": "workspace:*",
|
||||||
"@mariozechner/pi-ai": "^0.62.0",
|
"@mariozechner/pi-ai": "^0.62.0",
|
||||||
"@mariozechner/pi-coding-agent": "^0.62.0"
|
"@mariozechner/pi-coding-agent": "^0.62.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { AgentLogger, summarizeToolArgs } from "./agent-logger.js";
|
import { AgentLogger, summarizeToolArgs } from "./agent-logger.js";
|
||||||
import type { TaskStore } from "@hai/core";
|
import type { TaskStore } from "@kb/core";
|
||||||
|
|
||||||
// ── summarizeToolArgs tests ──────────────────────────────────────────
|
// ── summarizeToolArgs tests ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ describe("AgentLogger", () => {
|
|||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const logger = new AgentLogger({
|
const logger = new AgentLogger({
|
||||||
store,
|
store,
|
||||||
taskId: "HAI-001",
|
taskId: "KB-001",
|
||||||
flushSizeBytes: 10,
|
flushSizeBytes: 10,
|
||||||
flushIntervalMs: 500,
|
flushIntervalMs: 500,
|
||||||
});
|
});
|
||||||
@@ -75,14 +75,14 @@ describe("AgentLogger", () => {
|
|||||||
logger.onText("worldextra");
|
logger.onText("worldextra");
|
||||||
// Allow async flush
|
// Allow async flush
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-001", "helloworldextra", "text");
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-001", "helloworldextra", "text");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flushes on timer when under size threshold", async () => {
|
it("flushes on timer when under size threshold", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const logger = new AgentLogger({
|
const logger = new AgentLogger({
|
||||||
store,
|
store,
|
||||||
taskId: "HAI-002",
|
taskId: "KB-002",
|
||||||
flushSizeBytes: 1024,
|
flushSizeBytes: 1024,
|
||||||
flushIntervalMs: 500,
|
flushIntervalMs: 500,
|
||||||
});
|
});
|
||||||
@@ -91,14 +91,14 @@ describe("AgentLogger", () => {
|
|||||||
expect(store.appendAgentLog).not.toHaveBeenCalled();
|
expect(store.appendAgentLog).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-002", "small", "text");
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-002", "small", "text");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flushes text before logging tool start", async () => {
|
it("flushes text before logging tool start", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const logger = new AgentLogger({
|
const logger = new AgentLogger({
|
||||||
store,
|
store,
|
||||||
taskId: "HAI-003",
|
taskId: "KB-003",
|
||||||
flushSizeBytes: 1024,
|
flushSizeBytes: 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,36 +110,36 @@ describe("AgentLogger", () => {
|
|||||||
const calls = (store.appendAgentLog as ReturnType<typeof vi.fn>).mock.calls;
|
const calls = (store.appendAgentLog as ReturnType<typeof vi.fn>).mock.calls;
|
||||||
expect(calls.length).toBe(2);
|
expect(calls.length).toBe(2);
|
||||||
// Text flushed first
|
// Text flushed first
|
||||||
expect(calls[0]).toEqual(["HAI-003", "pending text", "text"]);
|
expect(calls[0]).toEqual(["KB-003", "pending text", "text"]);
|
||||||
// Tool logged second with detail
|
// Tool logged second with detail
|
||||||
expect(calls[1]).toEqual(["HAI-003", "Bash", "tool", "ls"]);
|
expect(calls[1]).toEqual(["KB-003", "Bash", "tool", "ls"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs tool detail using summarizeToolArgs", async () => {
|
it("logs tool detail using summarizeToolArgs", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const logger = new AgentLogger({ store, taskId: "HAI-004" });
|
const logger = new AgentLogger({ store, taskId: "KB-004" });
|
||||||
|
|
||||||
logger.onToolStart("Read", { path: "src/index.ts" });
|
logger.onToolStart("Read", { path: "src/index.ts" });
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-004", "Read", "tool", "src/index.ts");
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-004", "Read", "tool", "src/index.ts");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs tool with undefined detail for unknown args", async () => {
|
it("logs tool with undefined detail for unknown args", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const logger = new AgentLogger({ store, taskId: "HAI-005" });
|
const logger = new AgentLogger({ store, taskId: "KB-005" });
|
||||||
|
|
||||||
logger.onToolStart("task_done", { count: 42 });
|
logger.onToolStart("task_done", { count: 42 });
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-005", "task_done", "tool", undefined);
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-005", "task_done", "tool", undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flush() clears timer and writes remaining text", async () => {
|
it("flush() clears timer and writes remaining text", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const logger = new AgentLogger({
|
const logger = new AgentLogger({
|
||||||
store,
|
store,
|
||||||
taskId: "HAI-006",
|
taskId: "KB-006",
|
||||||
flushSizeBytes: 1024,
|
flushSizeBytes: 1024,
|
||||||
flushIntervalMs: 500,
|
flushIntervalMs: 500,
|
||||||
});
|
});
|
||||||
@@ -147,12 +147,12 @@ describe("AgentLogger", () => {
|
|||||||
logger.onText("remaining");
|
logger.onText("remaining");
|
||||||
await logger.flush();
|
await logger.flush();
|
||||||
|
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-006", "remaining", "text");
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-006", "remaining", "text");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flush() is safe to call when buffer is empty", async () => {
|
it("flush() is safe to call when buffer is empty", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const logger = new AgentLogger({ store, taskId: "HAI-007" });
|
const logger = new AgentLogger({ store, taskId: "KB-007" });
|
||||||
|
|
||||||
await logger.flush();
|
await logger.flush();
|
||||||
expect(store.appendAgentLog).not.toHaveBeenCalled();
|
expect(store.appendAgentLog).not.toHaveBeenCalled();
|
||||||
@@ -164,23 +164,23 @@ describe("AgentLogger", () => {
|
|||||||
const onAgentTool = vi.fn();
|
const onAgentTool = vi.fn();
|
||||||
const logger = new AgentLogger({
|
const logger = new AgentLogger({
|
||||||
store,
|
store,
|
||||||
taskId: "HAI-008",
|
taskId: "KB-008",
|
||||||
onAgentText,
|
onAgentText,
|
||||||
onAgentTool,
|
onAgentTool,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.onText("delta");
|
logger.onText("delta");
|
||||||
expect(onAgentText).toHaveBeenCalledWith("HAI-008", "delta");
|
expect(onAgentText).toHaveBeenCalledWith("KB-008", "delta");
|
||||||
|
|
||||||
logger.onToolStart("Bash", { command: "echo hi" });
|
logger.onToolStart("Bash", { command: "echo hi" });
|
||||||
expect(onAgentTool).toHaveBeenCalledWith("HAI-008", "Bash");
|
expect(onAgentTool).toHaveBeenCalledWith("KB-008", "Bash");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not schedule multiple timers for consecutive small writes", async () => {
|
it("does not schedule multiple timers for consecutive small writes", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const logger = new AgentLogger({
|
const logger = new AgentLogger({
|
||||||
store,
|
store,
|
||||||
taskId: "HAI-009",
|
taskId: "KB-009",
|
||||||
flushSizeBytes: 1024,
|
flushSizeBytes: 1024,
|
||||||
flushIntervalMs: 500,
|
flushIntervalMs: 500,
|
||||||
});
|
});
|
||||||
@@ -193,6 +193,6 @@ describe("AgentLogger", () => {
|
|||||||
|
|
||||||
// All text should be flushed in a single call
|
// All text should be flushed in a single call
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledTimes(1);
|
expect(store.appendAgentLog).toHaveBeenCalledTimes(1);
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-009", "abc", "text");
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-009", "abc", "text");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TaskStore } from "@hai/core";
|
import type { TaskStore } from "@kb/core";
|
||||||
|
|
||||||
/** Default byte threshold before an automatic flush. */
|
/** Default byte threshold before an automatic flush. */
|
||||||
const FLUSH_SIZE_BYTES = 1024;
|
const FLUSH_SIZE_BYTES = 1024;
|
||||||
@@ -56,12 +56,12 @@ export interface AgentLoggerOptions {
|
|||||||
* detailed argument summaries via {@link summarizeToolArgs}.
|
* detailed argument summaries via {@link summarizeToolArgs}.
|
||||||
*
|
*
|
||||||
* Produces `onText` and `onToolStart` callbacks compatible with
|
* Produces `onText` and `onToolStart` callbacks compatible with
|
||||||
* `createHaiAgent`'s `AgentOptions` interface.
|
* `createKbAgent`'s `AgentOptions` interface.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const logger = new AgentLogger({ store, taskId, onAgentText, onAgentTool });
|
* const logger = new AgentLogger({ store, taskId, onAgentText, onAgentTool });
|
||||||
* const { session } = await createHaiAgent({
|
* const { session } = await createKbAgent({
|
||||||
* cwd: worktreePath,
|
* cwd: worktreePath,
|
||||||
* onText: logger.onText,
|
* onText: logger.onText,
|
||||||
* onToolStart: logger.onToolStart,
|
* onToolStart: logger.onToolStart,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { AgentSemaphore } from "./concurrency.js";
|
|||||||
|
|
||||||
// Mock external dependencies
|
// Mock external dependencies
|
||||||
vi.mock("./pi.js", () => ({
|
vi.mock("./pi.js", () => ({
|
||||||
createHaiAgent: vi.fn(),
|
createKbAgent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("./reviewer.js", () => ({
|
vi.mock("./reviewer.js", () => ({
|
||||||
reviewStep: vi.fn(),
|
reviewStep: vi.fn(),
|
||||||
@@ -28,14 +28,14 @@ vi.mock("node:fs", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { TaskExecutor, buildExecutionPrompt } from "./executor.js";
|
import { TaskExecutor, buildExecutionPrompt } from "./executor.js";
|
||||||
import { createHaiAgent } from "./pi.js";
|
import { createKbAgent } from "./pi.js";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { findWorktreeUser, aiMergeTask } from "./merger.js";
|
import { findWorktreeUser, aiMergeTask } from "./merger.js";
|
||||||
import { WorktreePool } from "./worktree-pool.js";
|
import { WorktreePool } from "./worktree-pool.js";
|
||||||
import { generateWorktreeName } from "./worktree-names.js";
|
import { generateWorktreeName } from "./worktree-names.js";
|
||||||
import type { Column, Task, TaskDetail } from "@hai/core";
|
import type { Column, Task, TaskDetail } from "@kb/core";
|
||||||
|
|
||||||
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
|
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
|
||||||
|
|
||||||
function createMockStore() {
|
function createMockStore() {
|
||||||
const listeners = new Map<string, Function[]>();
|
const listeners = new Map<string, Function[]>();
|
||||||
@@ -52,7 +52,7 @@ function createMockStore() {
|
|||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
listTasks: vi.fn().mockResolvedValue([]),
|
listTasks: vi.fn().mockResolvedValue([]),
|
||||||
getTask: vi.fn().mockResolvedValue({
|
getTask: vi.fn().mockResolvedValue({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test task",
|
description: "Test task",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
@@ -102,7 +102,7 @@ describe("TaskExecutor with semaphore", () => {
|
|||||||
const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem });
|
const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem });
|
||||||
|
|
||||||
await executor.execute({
|
await executor.execute({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
@@ -132,7 +132,7 @@ describe("TaskExecutor with semaphore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await executor.execute({
|
await executor.execute({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
@@ -157,7 +157,7 @@ describe("TaskExecutor with semaphore", () => {
|
|||||||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||||||
|
|
||||||
await executor.execute({
|
await executor.execute({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
@@ -169,7 +169,7 @@ describe("TaskExecutor with semaphore", () => {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", { status: "failed" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "failed" });
|
||||||
expect(onError).toHaveBeenCalled();
|
expect(onError).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,9 +209,9 @@ describe("TaskExecutor with semaphore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
executor.execute(task("HAI-001")),
|
executor.execute(task("KB-001")),
|
||||||
executor.execute(task("HAI-002")),
|
executor.execute(task("KB-002")),
|
||||||
executor.execute(task("HAI-003")),
|
executor.execute(task("KB-003")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(maxConcurrent).toBe(1);
|
expect(maxConcurrent).toBe(1);
|
||||||
@@ -224,7 +224,7 @@ const { existsSync: mockedExistsSyncRaw } = await import("node:fs");
|
|||||||
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||||
|
|
||||||
describe("TaskExecutor worktreeInitCommand", () => {
|
describe("TaskExecutor worktreeInitCommand", () => {
|
||||||
const makeTask = (id = "HAI-010") => ({
|
const makeTask = (id = "KB-010") => ({
|
||||||
id,
|
id,
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
@@ -275,7 +275,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
|
|||||||
|
|
||||||
// Should log success
|
// Should log success
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
"HAI-010",
|
"KB-010",
|
||||||
"Worktree init command completed",
|
"Worktree init command completed",
|
||||||
"pnpm install",
|
"pnpm install",
|
||||||
);
|
);
|
||||||
@@ -322,7 +322,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
|
|||||||
|
|
||||||
// Should log the failure
|
// Should log the failure
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
"HAI-010",
|
"KB-010",
|
||||||
expect.stringContaining("Worktree init command failed"),
|
expect.stringContaining("Worktree init command failed"),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("TaskExecutor worktree naming", () => {
|
describe("TaskExecutor worktree naming", () => {
|
||||||
const makeTask = (id = "HAI-030", worktree?: string) => ({
|
const makeTask = (id = "KB-030", worktree?: string) => ({
|
||||||
id,
|
id,
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
@@ -391,7 +391,7 @@ describe("TaskExecutor worktree naming", () => {
|
|||||||
await executor.execute(makeTask());
|
await executor.execute(makeTask());
|
||||||
|
|
||||||
// The worktree path stored should use the generated name, not the task ID
|
// The worktree path stored should use the generated name, not the task ID
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-030", {
|
expect(store.updateTask).toHaveBeenCalledWith("KB-030", {
|
||||||
worktree: "/tmp/test/.worktrees/swift-falcon",
|
worktree: "/tmp/test/.worktrees/swift-falcon",
|
||||||
});
|
});
|
||||||
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
|
||||||
@@ -401,7 +401,7 @@ describe("TaskExecutor worktree naming", () => {
|
|||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
|
|
||||||
await executor.execute(makeTask("HAI-099"));
|
await executor.execute(makeTask("KB-099"));
|
||||||
|
|
||||||
// Verify the worktree path does NOT contain the task ID
|
// Verify the worktree path does NOT contain the task ID
|
||||||
const updateCalls = store.updateTask.mock.calls;
|
const updateCalls = store.updateTask.mock.calls;
|
||||||
@@ -409,7 +409,7 @@ describe("TaskExecutor worktree naming", () => {
|
|||||||
(call: any[]) => call[1]?.worktree !== undefined,
|
(call: any[]) => call[1]?.worktree !== undefined,
|
||||||
);
|
);
|
||||||
expect(worktreeUpdate).toBeDefined();
|
expect(worktreeUpdate).toBeDefined();
|
||||||
expect(worktreeUpdate![1].worktree).not.toContain("HAI-099");
|
expect(worktreeUpdate![1].worktree).not.toContain("KB-099");
|
||||||
expect(worktreeUpdate![1].worktree).toContain("swift-falcon");
|
expect(worktreeUpdate![1].worktree).toContain("swift-falcon");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ describe("TaskExecutor worktree naming", () => {
|
|||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
|
|
||||||
await executor.execute(makeTask("HAI-031", existingPath));
|
await executor.execute(makeTask("KB-031", existingPath));
|
||||||
|
|
||||||
// Should NOT generate a new name — reuse the stored path
|
// Should NOT generate a new name — reuse the stored path
|
||||||
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
|
||||||
@@ -428,7 +428,7 @@ describe("TaskExecutor worktree naming", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("TaskExecutor worktree pool integration", () => {
|
describe("TaskExecutor worktree pool integration", () => {
|
||||||
const makeTask = (id = "HAI-020") => ({
|
const makeTask = (id = "KB-020") => ({
|
||||||
id,
|
id,
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
@@ -482,7 +482,7 @@ describe("TaskExecutor worktree pool integration", () => {
|
|||||||
|
|
||||||
// Should log pool acquisition
|
// Should log pool acquisition
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
"HAI-020",
|
"KB-020",
|
||||||
expect.stringContaining("Acquired worktree from pool"),
|
expect.stringContaining("Acquired worktree from pool"),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -515,7 +515,7 @@ describe("TaskExecutor worktree pool integration", () => {
|
|||||||
|
|
||||||
// Should log worktree creation, NOT pool acquisition
|
// Should log worktree creation, NOT pool acquisition
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
"HAI-020",
|
"KB-020",
|
||||||
expect.stringContaining("Worktree created at"),
|
expect.stringContaining("Worktree created at"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -597,12 +597,12 @@ describe("Merger worktree pool integration", () => {
|
|||||||
}),
|
}),
|
||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
getTask: vi.fn().mockResolvedValue({
|
getTask: vi.fn().mockResolvedValue({
|
||||||
id: "HAI-050",
|
id: "KB-050",
|
||||||
title: "Test merge",
|
title: "Test merge",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "in-review",
|
column: "in-review",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
worktree: "/tmp/test/.worktrees/HAI-050",
|
worktree: "/tmp/test/.worktrees/KB-050",
|
||||||
steps: [],
|
steps: [],
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
log: [],
|
log: [],
|
||||||
@@ -612,7 +612,7 @@ describe("Merger worktree pool integration", () => {
|
|||||||
}),
|
}),
|
||||||
updateTask: vi.fn().mockResolvedValue({}),
|
updateTask: vi.fn().mockResolvedValue({}),
|
||||||
moveTask: vi.fn().mockResolvedValue({
|
moveTask: vi.fn().mockResolvedValue({
|
||||||
id: "HAI-050",
|
id: "KB-050",
|
||||||
column: "done",
|
column: "done",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
steps: [],
|
steps: [],
|
||||||
@@ -659,10 +659,10 @@ describe("Merger worktree pool integration", () => {
|
|||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const result = await aiMergeTask(store, "/tmp/test", "HAI-050", { pool });
|
const result = await aiMergeTask(store, "/tmp/test", "KB-050", { pool });
|
||||||
|
|
||||||
// Worktree should be in the pool, NOT removed
|
// Worktree should be in the pool, NOT removed
|
||||||
expect(pool.has("/tmp/test/.worktrees/HAI-050")).toBe(true);
|
expect(pool.has("/tmp/test/.worktrees/KB-050")).toBe(true);
|
||||||
expect(result.worktreeRemoved).toBe(false);
|
expect(result.worktreeRemoved).toBe(false);
|
||||||
|
|
||||||
// git worktree remove should NOT have been called
|
// git worktree remove should NOT have been called
|
||||||
@@ -686,7 +686,7 @@ describe("Merger worktree pool integration", () => {
|
|||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const result = await aiMergeTask(store, "/tmp/test", "HAI-050", { pool });
|
const result = await aiMergeTask(store, "/tmp/test", "KB-050", { pool });
|
||||||
|
|
||||||
// Worktree should NOT be in the pool
|
// Worktree should NOT be in the pool
|
||||||
expect(pool.size).toBe(0);
|
expect(pool.size).toBe(0);
|
||||||
@@ -702,7 +702,7 @@ describe("Merger worktree pool integration", () => {
|
|||||||
|
|
||||||
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||||
return {
|
return {
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test Task",
|
title: "Test Task",
|
||||||
description: "A test task",
|
description: "A test task",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
@@ -728,7 +728,7 @@ describe("buildExecutionPrompt", () => {
|
|||||||
|
|
||||||
expect(result).toContain("## Attachments");
|
expect(result).toContain("## Attachments");
|
||||||
expect(result).toContain("**screenshot.png** (screenshot)");
|
expect(result).toContain("**screenshot.png** (screenshot)");
|
||||||
expect(result).toContain("/home/user/project/.hai/tasks/HAI-001/attachments/abc123-screenshot.png");
|
expect(result).toContain("/home/user/project/.kb/tasks/KB-001/attachments/abc123-screenshot.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes attachment section with absolute paths for text attachments", () => {
|
it("includes attachment section with absolute paths for text attachments", () => {
|
||||||
@@ -742,7 +742,7 @@ describe("buildExecutionPrompt", () => {
|
|||||||
expect(result).toContain("## Attachments");
|
expect(result).toContain("## Attachments");
|
||||||
expect(result).toContain("**error.log** (text/plain)");
|
expect(result).toContain("**error.log** (text/plain)");
|
||||||
expect(result).toContain("read for context");
|
expect(result).toContain("read for context");
|
||||||
expect(result).toContain("/home/user/project/.hai/tasks/HAI-001/attachments/def456-error.log");
|
expect(result).toContain("/home/user/project/.kb/tasks/KB-001/attachments/def456-error.log");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes both image and text attachments", () => {
|
it("includes both image and text attachments", () => {
|
||||||
@@ -853,7 +853,7 @@ describe("buildExecutionPrompt", () => {
|
|||||||
|
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
await executor.execute({
|
await executor.execute({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
@@ -929,7 +929,7 @@ describe("TaskExecutor pause behavior", () => {
|
|||||||
session: {
|
session: {
|
||||||
prompt: vi.fn().mockImplementation(async () => {
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
// Simulate pause happening during agent execution
|
// Simulate pause happening during agent execution
|
||||||
store._trigger("task:updated", { id: "HAI-001", paused: true, column: "in-progress" });
|
store._trigger("task:updated", { id: "KB-001", paused: true, column: "in-progress" });
|
||||||
// Simulate the dispose causing an error (session terminated)
|
// Simulate the dispose causing an error (session terminated)
|
||||||
throw new Error("Session terminated");
|
throw new Error("Session terminated");
|
||||||
}),
|
}),
|
||||||
@@ -940,7 +940,7 @@ describe("TaskExecutor pause behavior", () => {
|
|||||||
|
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
await executor.execute({
|
await executor.execute({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
@@ -953,8 +953,8 @@ describe("TaskExecutor pause behavior", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should move to todo, NOT mark as failed
|
// Should move to todo, NOT mark as failed
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-001", "todo");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||||
expect(store.updateTask).not.toHaveBeenCalledWith("HAI-001", { status: "failed" });
|
expect(store.updateTask).not.toHaveBeenCalledWith("KB-001", { status: "failed" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not move to in-review when paused during execution (graceful session end)", async () => {
|
it("does not move to in-review when paused during execution (graceful session end)", async () => {
|
||||||
@@ -965,7 +965,7 @@ describe("TaskExecutor pause behavior", () => {
|
|||||||
session: {
|
session: {
|
||||||
prompt: vi.fn().mockImplementation(async () => {
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
// Simulate pause — session ends gracefully (no throw)
|
// Simulate pause — session ends gracefully (no throw)
|
||||||
store._trigger("task:updated", { id: "HAI-001", paused: true, column: "in-progress" });
|
store._trigger("task:updated", { id: "KB-001", paused: true, column: "in-progress" });
|
||||||
}),
|
}),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -974,7 +974,7 @@ describe("TaskExecutor pause behavior", () => {
|
|||||||
|
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
await executor.execute({
|
await executor.execute({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "in-progress",
|
column: "in-progress",
|
||||||
@@ -987,14 +987,14 @@ describe("TaskExecutor pause behavior", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should NOT move to in-review (paused tasks skip that logic)
|
// Should NOT move to in-review (paused tasks skip that logic)
|
||||||
expect(store.moveTask).not.toHaveBeenCalledWith("HAI-001", "in-review");
|
expect(store.moveTask).not.toHaveBeenCalledWith("KB-001", "in-review");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips paused tasks during resumeOrphaned", async () => {
|
it("skips paused tasks during resumeOrphaned", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.listTasks.mockResolvedValue([
|
store.listTasks.mockResolvedValue([
|
||||||
{ id: "HAI-001", column: "in-progress", paused: true, title: "Paused task" },
|
{ id: "KB-001", column: "in-progress", paused: true, title: "Paused task" },
|
||||||
{ id: "HAI-002", column: "in-progress", paused: false, title: "Active task" },
|
{ id: "KB-002", column: "in-progress", paused: false, title: "Active task" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
mockedCreateHaiAgent.mockResolvedValue({
|
mockedCreateHaiAgent.mockResolvedValue({
|
||||||
@@ -1007,8 +1007,8 @@ describe("TaskExecutor pause behavior", () => {
|
|||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
await executor.resumeOrphaned();
|
await executor.resumeOrphaned();
|
||||||
|
|
||||||
// Only HAI-002 should be resumed (HAI-001 is paused)
|
// Only KB-002 should be resumed (KB-001 is paused)
|
||||||
expect(store.logEntry).toHaveBeenCalledWith("HAI-002", "Resumed after engine restart");
|
expect(store.logEntry).toHaveBeenCalledWith("KB-002", "Resumed after engine restart");
|
||||||
expect(store.logEntry).not.toHaveBeenCalledWith("HAI-001", expect.anything());
|
expect(store.logEntry).not.toHaveBeenCalledWith("KB-001", expect.anything());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import type { TaskStore, Task, TaskDetail, StepStatus, Settings } from "@hai/core";
|
import type { TaskStore, Task, TaskDetail, StepStatus, Settings } from "@kb/core";
|
||||||
import { findWorktreeUser } from "./merger.js";
|
import { findWorktreeUser } from "./merger.js";
|
||||||
import { generateWorktreeName } from "./worktree-names.js";
|
import { generateWorktreeName } from "./worktree-names.js";
|
||||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||||
import { createHaiAgent } from "./pi.js";
|
import { createKbAgent } from "./pi.js";
|
||||||
import { reviewStep } from "./reviewer.js";
|
import { reviewStep } from "./reviewer.js";
|
||||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
import type { AgentSemaphore } from "./concurrency.js";
|
import type { AgentSemaphore } from "./concurrency.js";
|
||||||
@@ -36,7 +36,7 @@ const taskLogParams = Type.Object({
|
|||||||
const taskCreateParams = Type.Object({
|
const taskCreateParams = Type.Object({
|
||||||
description: Type.String({ description: "What needs to be done" }),
|
description: Type.String({ description: "What needs to be done" }),
|
||||||
dependencies: Type.Optional(
|
dependencies: Type.Optional(
|
||||||
Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"HAI-001\"])" }),
|
Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"KB-001\"])" }),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ const reviewStepParams = Type.Object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const EXECUTOR_SYSTEM_PROMPT = `You are a task execution agent for "hai", an AI-orchestrated task board.
|
const EXECUTOR_SYSTEM_PROMPT = `You are a task execution agent for "kb", an AI-orchestrated task board.
|
||||||
|
|
||||||
You are working in a git worktree isolated from the main branch. Your job is to implement the task described in the PROMPT.md specification you're given.
|
You are working in a git worktree isolated from the main branch. Your job is to implement the task described in the PROMPT.md specification you're given.
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ export class TaskExecutor {
|
|||||||
* than being named after the task ID. This decouples directory names from
|
* than being named after the task ID. This decouples directory names from
|
||||||
* tasks, enabling worktree reuse across dependency chains. When resuming
|
* tasks, enabling worktree reuse across dependency chains. When resuming
|
||||||
* a task that already has `task.worktree` set, the existing path is used
|
* a task that already has `task.worktree` set, the existing path is used
|
||||||
* as-is. Branches remain task-scoped (`hai/{task-id}`).
|
* as-is. Branches remain task-scoped (`kb/{task-id}`).
|
||||||
*/
|
*/
|
||||||
async execute(task: Task): Promise<void> {
|
async execute(task: Task): Promise<void> {
|
||||||
if (this.executing.has(task.id)) return;
|
if (this.executing.has(task.id)) return;
|
||||||
@@ -261,7 +261,7 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create or reuse worktree — try pool first when recycling is enabled
|
// Create or reuse worktree — try pool first when recycling is enabled
|
||||||
const branchName = `hai/${task.id.toLowerCase()}`;
|
const branchName = `kb/${task.id.toLowerCase()}`;
|
||||||
// Use generateWorktreeName for human-friendly directory names (adjective-noun pattern)
|
// Use generateWorktreeName for human-friendly directory names (adjective-noun pattern)
|
||||||
// instead of task.id, so worktrees are named like ".worktrees/swift-falcon"
|
// instead of task.id, so worktrees are named like ".worktrees/swift-falcon"
|
||||||
let worktreePath = task.worktree || join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
|
let worktreePath = task.worktree || join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
|
||||||
@@ -343,7 +343,7 @@ export class TaskExecutor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const agentWork = async () => {
|
const agentWork = async () => {
|
||||||
const { session } = await createHaiAgent({
|
const { session } = await createKbAgent({
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
|
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
|
||||||
tools: "coding",
|
tools: "coding",
|
||||||
@@ -664,7 +664,7 @@ git log --oneline
|
|||||||
const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||||
const lines = ["## Attachments", ""];
|
const lines = ["## Attachments", ""];
|
||||||
for (const att of task.attachments) {
|
for (const att of task.attachments) {
|
||||||
const absPath = `${rootDir}/.hai/tasks/${task.id}/attachments/${att.filename}`;
|
const absPath = `${rootDir}/.kb/tasks/${task.id}/attachments/${att.filename}`;
|
||||||
if (IMAGE_MIMES.has(att.mimeType)) {
|
if (IMAGE_MIMES.has(att.mimeType)) {
|
||||||
lines.push(`- **${att.originalName}** (screenshot): \`${absPath}\``);
|
lines.push(`- **${att.originalName}** (screenshot): \`${absPath}\``);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ export { TaskExecutor, type TaskExecutorOptions } from "./executor.js";
|
|||||||
export { Scheduler, type SchedulerOptions } from "./scheduler.js";
|
export { Scheduler, type SchedulerOptions } from "./scheduler.js";
|
||||||
export { aiMergeTask, type MergerOptions } from "./merger.js";
|
export { aiMergeTask, type MergerOptions } from "./merger.js";
|
||||||
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
||||||
export { createHaiAgent, type AgentOptions, type AgentResult } from "./pi.js";
|
export { createKbAgent, type AgentOptions, type AgentResult } from "./pi.js";
|
||||||
export { WorktreePool } from "./worktree-pool.js";
|
export { WorktreePool } from "./worktree-pool.js";
|
||||||
export { createLogger, type Logger } from "./logger.js";
|
export { createLogger, type Logger } from "./logger.js";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Lightweight structured logger for the `@hai/engine` package.
|
* Lightweight structured logger for the `@kb/engine` package.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* ```ts
|
* ```ts
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
|
|
||||||
// Mock external dependencies
|
// Mock external dependencies
|
||||||
vi.mock("./pi.js", () => ({
|
vi.mock("./pi.js", () => ({
|
||||||
createHaiAgent: vi.fn(),
|
createKbAgent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("node:child_process", () => ({
|
vi.mock("node:child_process", () => ({
|
||||||
@@ -14,23 +14,23 @@ vi.mock("node:fs", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { aiMergeTask, findWorktreeUser } from "./merger.js";
|
import { aiMergeTask, findWorktreeUser } from "./merger.js";
|
||||||
import { createHaiAgent } from "./pi.js";
|
import { createKbAgent } from "./pi.js";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@hai/core";
|
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@kb/core";
|
||||||
|
|
||||||
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
|
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
|
||||||
const mockedExecSync = vi.mocked(execSync);
|
const mockedExecSync = vi.mocked(execSync);
|
||||||
const { existsSync: mockedExistsSyncRaw } = await import("node:fs");
|
const { existsSync: mockedExistsSyncRaw } = await import("node:fs");
|
||||||
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||||
|
|
||||||
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
||||||
const baseTask: Task = {
|
const baseTask: Task = {
|
||||||
id: "HAI-050",
|
id: "KB-050",
|
||||||
title: "Test task",
|
title: "Test task",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "in-review",
|
column: "in-review",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
worktree: "/tmp/root/.worktrees/HAI-050",
|
worktree: "/tmp/root/.worktrees/KB-050",
|
||||||
steps: [],
|
steps: [],
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
log: [],
|
log: [],
|
||||||
@@ -73,27 +73,27 @@ function setupHappyPathExecSync() {
|
|||||||
describe("findWorktreeUser", () => {
|
describe("findWorktreeUser", () => {
|
||||||
it("returns null when no other task uses the worktree", async () => {
|
it("returns null when no other task uses the worktree", async () => {
|
||||||
const store = createMockStore({}, [
|
const store = createMockStore({}, [
|
||||||
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
|
{ id: "KB-050", worktree: "/tmp/wt", column: "done" } as Task,
|
||||||
]);
|
]);
|
||||||
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
|
const result = await findWorktreeUser(store, "/tmp/wt", "KB-050");
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns task ID when another non-done task uses the worktree", async () => {
|
it("returns task ID when another non-done task uses the worktree", async () => {
|
||||||
const store = createMockStore({}, [
|
const store = createMockStore({}, [
|
||||||
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
|
{ id: "KB-050", worktree: "/tmp/wt", column: "done" } as Task,
|
||||||
{ id: "HAI-051", worktree: "/tmp/wt", column: "in-progress" } as Task,
|
{ id: "KB-051", worktree: "/tmp/wt", column: "in-progress" } as Task,
|
||||||
]);
|
]);
|
||||||
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
|
const result = await findWorktreeUser(store, "/tmp/wt", "KB-050");
|
||||||
expect(result).toBe("HAI-051");
|
expect(result).toBe("KB-051");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores done tasks", async () => {
|
it("ignores done tasks", async () => {
|
||||||
const store = createMockStore({}, [
|
const store = createMockStore({}, [
|
||||||
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
|
{ id: "KB-050", worktree: "/tmp/wt", column: "done" } as Task,
|
||||||
{ id: "HAI-051", worktree: "/tmp/wt", column: "done" } as Task,
|
{ id: "KB-051", worktree: "/tmp/wt", column: "done" } as Task,
|
||||||
]);
|
]);
|
||||||
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
|
const result = await findWorktreeUser(store, "/tmp/wt", "KB-050");
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -112,16 +112,16 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT remove worktree when another task references the same path", async () => {
|
it("does NOT remove worktree when another task references the same path", async () => {
|
||||||
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
const worktreePath = "/tmp/root/.worktrees/KB-050";
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: worktreePath },
|
{ id: "KB-050", worktree: worktreePath },
|
||||||
[
|
[
|
||||||
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
|
{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task,
|
||||||
{ id: "HAI-051", worktree: worktreePath, column: "in-progress" } as Task,
|
{ id: "KB-051", worktree: worktreePath, column: "in-progress" } as Task,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
|
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
|
|
||||||
// Worktree should NOT be removed
|
// Worktree should NOT be removed
|
||||||
const removeCall = mockedExecSync.mock.calls.find(
|
const removeCall = mockedExecSync.mock.calls.find(
|
||||||
@@ -132,15 +132,15 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("removes worktree when no other task references it", async () => {
|
it("removes worktree when no other task references it", async () => {
|
||||||
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
const worktreePath = "/tmp/root/.worktrees/KB-050";
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: worktreePath },
|
{ id: "KB-050", worktree: worktreePath },
|
||||||
[
|
[
|
||||||
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
|
{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
|
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
|
|
||||||
const removeCall = mockedExecSync.mock.calls.find(
|
const removeCall = mockedExecSync.mock.calls.find(
|
||||||
(call) => String(call[0]).includes("worktree remove"),
|
(call) => String(call[0]).includes("worktree remove"),
|
||||||
@@ -150,16 +150,16 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("always deletes the branch regardless of worktree sharing", async () => {
|
it("always deletes the branch regardless of worktree sharing", async () => {
|
||||||
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
const worktreePath = "/tmp/root/.worktrees/KB-050";
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: worktreePath },
|
{ id: "KB-050", worktree: worktreePath },
|
||||||
[
|
[
|
||||||
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
|
{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task,
|
||||||
{ id: "HAI-051", worktree: worktreePath, column: "in-progress" } as Task,
|
{ id: "KB-051", worktree: worktreePath, column: "in-progress" } as Task,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
|
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
|
|
||||||
// Branch should be deleted even though worktree is shared
|
// Branch should be deleted even though worktree is shared
|
||||||
const branchDeleteCall = mockedExecSync.mock.calls.find(
|
const branchDeleteCall = mockedExecSync.mock.calls.find(
|
||||||
@@ -170,16 +170,16 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("result.worktreeRemoved is false when worktree is retained", async () => {
|
it("result.worktreeRemoved is false when worktree is retained", async () => {
|
||||||
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
const worktreePath = "/tmp/root/.worktrees/KB-050";
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: worktreePath },
|
{ id: "KB-050", worktree: worktreePath },
|
||||||
[
|
[
|
||||||
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
|
{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task,
|
||||||
{ id: "HAI-051", worktree: worktreePath, column: "todo" } as Task,
|
{ id: "KB-051", worktree: worktreePath, column: "todo" } as Task,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
|
const result = await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
expect(result.worktreeRemoved).toBe(false);
|
expect(result.worktreeRemoved).toBe(false);
|
||||||
expect(result.merged).toBe(true);
|
expect(result.merged).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -200,11 +200,11 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
|
|||||||
|
|
||||||
it("includes task ID in system prompt by default (includeTaskIdInCommit: true)", async () => {
|
it("includes task ID in system prompt by default (includeTaskIdInCommit: true)", async () => {
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
|
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
|
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
);
|
);
|
||||||
|
|
||||||
await aiMergeTask(store, "/tmp/root", "HAI-050");
|
await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
|
|
||||||
const agentCall = mockedCreateHaiAgent.mock.calls[0][0] as any;
|
const agentCall = mockedCreateHaiAgent.mock.calls[0][0] as any;
|
||||||
expect(agentCall.systemPrompt).toContain("<type>(<scope>): <summary>");
|
expect(agentCall.systemPrompt).toContain("<type>(<scope>): <summary>");
|
||||||
@@ -213,15 +213,15 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
|
|||||||
|
|
||||||
it("omits task ID scope in system prompt when includeTaskIdInCommit is false", async () => {
|
it("omits task ID scope in system prompt when includeTaskIdInCommit is false", async () => {
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
|
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
|
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
);
|
);
|
||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
includeTaskIdInCommit: false,
|
includeTaskIdInCommit: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await aiMergeTask(store, "/tmp/root", "HAI-050");
|
await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
|
|
||||||
const agentCall = mockedCreateHaiAgent.mock.calls[0][0] as any;
|
const agentCall = mockedCreateHaiAgent.mock.calls[0][0] as any;
|
||||||
expect(agentCall.systemPrompt).toContain("<type>: <summary>");
|
expect(agentCall.systemPrompt).toContain("<type>: <summary>");
|
||||||
@@ -245,17 +245,17 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
|
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
|
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
);
|
);
|
||||||
|
|
||||||
await aiMergeTask(store, "/tmp/root", "HAI-050");
|
await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
|
|
||||||
const commitCall = mockedExecSync.mock.calls.find(
|
const commitCall = mockedExecSync.mock.calls.find(
|
||||||
(call) => String(call[0]).includes("git commit"),
|
(call) => String(call[0]).includes("git commit"),
|
||||||
);
|
);
|
||||||
expect(commitCall).toBeDefined();
|
expect(commitCall).toBeDefined();
|
||||||
expect(String(commitCall![0])).toContain("feat(HAI-050):");
|
expect(String(commitCall![0])).toContain("feat(KB-050):");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fallback commit omits task ID when includeTaskIdInCommit is false", async () => {
|
it("fallback commit omits task ID when includeTaskIdInCommit is false", async () => {
|
||||||
@@ -273,22 +273,22 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
|
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
|
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
);
|
);
|
||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
includeTaskIdInCommit: false,
|
includeTaskIdInCommit: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await aiMergeTask(store, "/tmp/root", "HAI-050");
|
await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
|
|
||||||
const commitCall = mockedExecSync.mock.calls.find(
|
const commitCall = mockedExecSync.mock.calls.find(
|
||||||
(call) => String(call[0]).includes("git commit"),
|
(call) => String(call[0]).includes("git commit"),
|
||||||
);
|
);
|
||||||
expect(commitCall).toBeDefined();
|
expect(commitCall).toBeDefined();
|
||||||
expect(String(commitCall![0])).toContain("feat: merge");
|
expect(String(commitCall![0])).toContain("feat: merge");
|
||||||
expect(String(commitCall![0])).not.toContain("feat(HAI-050)");
|
expect(String(commitCall![0])).not.toContain("feat(KB-050)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,10 +305,10 @@ describe("aiMergeTask — model settings threading", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes defaultProvider and defaultModelId from settings to createHaiAgent", async () => {
|
it("passes defaultProvider and defaultModelId from settings to createKbAgent", async () => {
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
|
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
|
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
);
|
);
|
||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
@@ -316,7 +316,7 @@ describe("aiMergeTask — model settings threading", () => {
|
|||||||
defaultModelId: "gpt-4o",
|
defaultModelId: "gpt-4o",
|
||||||
});
|
});
|
||||||
|
|
||||||
await aiMergeTask(store, "/tmp/root", "HAI-050");
|
await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
|
|
||||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||||
const opts = mockedCreateHaiAgent.mock.calls[0][0] as any;
|
const opts = mockedCreateHaiAgent.mock.calls[0][0] as any;
|
||||||
@@ -326,11 +326,11 @@ describe("aiMergeTask — model settings threading", () => {
|
|||||||
|
|
||||||
it("does not set model fields when settings omit them", async () => {
|
it("does not set model fields when settings omit them", async () => {
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
|
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||||
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
|
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||||
);
|
);
|
||||||
|
|
||||||
await aiMergeTask(store, "/tmp/root", "HAI-050");
|
await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
|
|
||||||
const opts = mockedCreateHaiAgent.mock.calls[0][0] as any;
|
const opts = mockedCreateHaiAgent.mock.calls[0][0] as any;
|
||||||
expect(opts.defaultProvider).toBeUndefined();
|
expect(opts.defaultProvider).toBeUndefined();
|
||||||
@@ -361,15 +361,15 @@ describe("aiMergeTask — agent log persistence", () => {
|
|||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
const worktreePath = "/tmp/root/.worktrees/KB-050";
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: worktreePath },
|
{ id: "KB-050", worktree: worktreePath },
|
||||||
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
|
[{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task],
|
||||||
);
|
);
|
||||||
|
|
||||||
await aiMergeTask(store, "/tmp/root", "HAI-050");
|
await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
|
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-050", "Hello merge", "text");
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-050", "Hello merge", "text");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs tool invocations to store.appendAgentLog", async () => {
|
it("logs tool invocations to store.appendAgentLog", async () => {
|
||||||
@@ -387,15 +387,15 @@ describe("aiMergeTask — agent log persistence", () => {
|
|||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
const worktreePath = "/tmp/root/.worktrees/KB-050";
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: worktreePath },
|
{ id: "KB-050", worktree: worktreePath },
|
||||||
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
|
[{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task],
|
||||||
);
|
);
|
||||||
|
|
||||||
await aiMergeTask(store, "/tmp/root", "HAI-050");
|
await aiMergeTask(store, "/tmp/root", "KB-050");
|
||||||
|
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-050", "Bash", "tool", "git status");
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-050", "Bash", "tool", "git status");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still fires onAgentText callback alongside logging", async () => {
|
it("still fires onAgentText callback alongside logging", async () => {
|
||||||
@@ -414,15 +414,15 @@ describe("aiMergeTask — agent log persistence", () => {
|
|||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const worktreePath = "/tmp/root/.worktrees/HAI-050";
|
const worktreePath = "/tmp/root/.worktrees/KB-050";
|
||||||
const store = createMockStore(
|
const store = createMockStore(
|
||||||
{ id: "HAI-050", worktree: worktreePath },
|
{ id: "KB-050", worktree: worktreePath },
|
||||||
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
|
[{ id: "KB-050", worktree: worktreePath, column: "in-review" } as Task],
|
||||||
);
|
);
|
||||||
|
|
||||||
await aiMergeTask(store, "/tmp/root", "HAI-050", { onAgentText });
|
await aiMergeTask(store, "/tmp/root", "KB-050", { onAgentText });
|
||||||
|
|
||||||
expect(onAgentText).toHaveBeenCalledWith("hi");
|
expect(onAgentText).toHaveBeenCalledWith("hi");
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-050", "hi", "text");
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-050", "hi", "text");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import type { TaskStore, Task, MergeResult } from "@hai/core";
|
import type { TaskStore, Task, MergeResult } from "@kb/core";
|
||||||
import { createHaiAgent } from "./pi.js";
|
import { createKbAgent } from "./pi.js";
|
||||||
import type { WorktreePool } from "./worktree-pool.js";
|
import type { WorktreePool } from "./worktree-pool.js";
|
||||||
import { AgentLogger } from "./agent-logger.js";
|
import { AgentLogger } from "./agent-logger.js";
|
||||||
import { mergerLog } from "./logger.js";
|
import { mergerLog } from "./logger.js";
|
||||||
@@ -19,13 +19,13 @@ git commit -m "<type>(<scope>): <summary>" -m "<body>"
|
|||||||
|
|
||||||
Message format:
|
Message format:
|
||||||
- **Type:** feat, fix, refactor, docs, test, chore
|
- **Type:** feat, fix, refactor, docs, test, chore
|
||||||
- **Scope:** the task ID (e.g., HAI-001)
|
- **Scope:** the task ID (e.g., KB-001)
|
||||||
- **Summary:** one line describing what the squash brings in (imperative mood)
|
- **Summary:** one line describing what the squash brings in (imperative mood)
|
||||||
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
|
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
\`\`\`
|
\`\`\`
|
||||||
git commit -m "feat(HAI-003): add user profile page" -m "- Add /profile route with avatar upload
|
git commit -m "feat(KB-003): add user profile page" -m "- Add /profile route with avatar upload
|
||||||
- Create ProfileCard and EditProfileForm components
|
- Create ProfileCard and EditProfileForm components
|
||||||
- Add profile image resizing via sharp
|
- Add profile image resizing via sharp
|
||||||
- Update nav bar with profile link
|
- Update nav bar with profile link
|
||||||
@@ -51,7 +51,7 @@ git commit -m "feat: add user profile page" -m "- Add /profile route with avatar
|
|||||||
- Add profile e2e tests"
|
- Add profile e2e tests"
|
||||||
\`\`\``;
|
\`\`\``;
|
||||||
|
|
||||||
return `You are a merge agent for "hai", an AI-orchestrated task board.
|
return `You are a merge agent for "kb", an AI-orchestrated task board.
|
||||||
|
|
||||||
Your job is to finalize a squash merge: resolve any conflicts and write a good commit message.
|
Your job is to finalize a squash merge: resolve any conflicts and write a good commit message.
|
||||||
All changes from the branch are squashed into a single commit.
|
All changes from the branch are squashed into a single commit.
|
||||||
@@ -130,7 +130,7 @@ export async function aiMergeTask(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const branch = `hai/${taskId.toLowerCase()}`;
|
const branch = `kb/${taskId.toLowerCase()}`;
|
||||||
const worktreePath = task.worktree;
|
const worktreePath = task.worktree;
|
||||||
const result: MergeResult = {
|
const result: MergeResult = {
|
||||||
task,
|
task,
|
||||||
@@ -231,7 +231,7 @@ export async function aiMergeTask(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Forward model settings from store so the merger honours the user's model choice
|
// Forward model settings from store so the merger honours the user's model choice
|
||||||
const { session } = await createHaiAgent({
|
const { session } = await createKbAgent({
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
systemPrompt: buildMergeSystemPrompt(includeTaskId),
|
systemPrompt: buildMergeSystemPrompt(includeTaskId),
|
||||||
tools: "coding",
|
tools: "coding",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Shared pi SDK setup for hai engine agents.
|
* Shared pi SDK setup for kb engine agents.
|
||||||
*
|
*
|
||||||
* Uses the user's existing pi auth (API keys / OAuth from ~/.pi/agent/auth.json).
|
* Uses the user's existing pi auth (API keys / OAuth from ~/.pi/agent/auth.json).
|
||||||
* Provides factory functions for creating triage and executor agent sessions.
|
* Provides factory functions for creating triage and executor agent sessions.
|
||||||
@@ -37,10 +37,10 @@ export interface AgentOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a pi agent session configured for hai.
|
* Create a pi agent session configured for kb.
|
||||||
* Reuses the user's existing pi auth and model configuration.
|
* Reuses the user's existing pi auth and model configuration.
|
||||||
*/
|
*/
|
||||||
export async function createHaiAgent(options: AgentOptions): Promise<AgentResult> {
|
export async function createKbAgent(options: AgentOptions): Promise<AgentResult> {
|
||||||
const authStorage = AuthStorage.create();
|
const authStorage = AuthStorage.create();
|
||||||
const modelRegistry = new ModelRegistry(authStorage);
|
const modelRegistry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { AgentSemaphore } from "./concurrency.js";
|
|||||||
// ── Module-level mocks (matching existing test patterns) ──────────────────
|
// ── Module-level mocks (matching existing test patterns) ──────────────────
|
||||||
|
|
||||||
vi.mock("./pi.js", () => ({
|
vi.mock("./pi.js", () => ({
|
||||||
createHaiAgent: vi.fn(),
|
createKbAgent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("./reviewer.js", () => ({
|
vi.mock("./reviewer.js", () => ({
|
||||||
reviewStep: vi.fn(),
|
reviewStep: vi.fn(),
|
||||||
@@ -31,12 +31,12 @@ import { TaskExecutor } from "./executor.js";
|
|||||||
import { TriageProcessor } from "./triage.js";
|
import { TriageProcessor } from "./triage.js";
|
||||||
import { Scheduler } from "./scheduler.js";
|
import { Scheduler } from "./scheduler.js";
|
||||||
import { aiMergeTask } from "./merger.js";
|
import { aiMergeTask } from "./merger.js";
|
||||||
import { createHaiAgent } from "./pi.js";
|
import { createKbAgent } from "./pi.js";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import type { Task, TaskDetail, TaskStep, Column, Settings, StepStatus } from "@hai/core";
|
import type { Task, TaskDetail, TaskStep, Column, Settings, StepStatus } from "@kb/core";
|
||||||
|
|
||||||
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
|
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
|
||||||
const mockedExecSync = vi.mocked(execSync);
|
const mockedExecSync = vi.mocked(execSync);
|
||||||
const mockedExistsSync = vi.mocked(existsSync);
|
const mockedExistsSync = vi.mocked(existsSync);
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
|
|||||||
}),
|
}),
|
||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
listTasks: vi.fn().mockResolvedValue([]),
|
listTasks: vi.fn().mockResolvedValue([]),
|
||||||
getTask: vi.fn().mockResolvedValue(makeTaskDetail("HAI-001", "in-progress")),
|
getTask: vi.fn().mockResolvedValue(makeTaskDetail("KB-001", "in-progress")),
|
||||||
updateTask: vi.fn().mockResolvedValue({}),
|
updateTask: vi.fn().mockResolvedValue({}),
|
||||||
moveTask: vi.fn().mockImplementation(async (id: string, col: Column) => {
|
moveTask: vi.fn().mockImplementation(async (id: string, col: Column) => {
|
||||||
return makeTask(id, col);
|
return makeTask(id, col);
|
||||||
@@ -74,7 +74,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
|
|||||||
return makeTaskDetail(id, "in-progress");
|
return makeTaskDetail(id, "in-progress");
|
||||||
}),
|
}),
|
||||||
createTask: vi.fn().mockImplementation(async (input: any) => {
|
createTask: vi.fn().mockImplementation(async (input: any) => {
|
||||||
return makeTask("HAI-NEW", "triage");
|
return makeTask("KB-NEW", "triage");
|
||||||
}),
|
}),
|
||||||
deleteTask: vi.fn().mockResolvedValue(undefined),
|
deleteTask: vi.fn().mockResolvedValue(undefined),
|
||||||
_listeners: listeners,
|
_listeners: listeners,
|
||||||
@@ -139,9 +139,9 @@ beforeEach(() => {
|
|||||||
describe("In-progress task resume after restart", () => {
|
describe("In-progress task resume after restart", () => {
|
||||||
it("resumeOrphaned() calls execute() for each in-progress task not already executing", async () => {
|
it("resumeOrphaned() calls execute() for each in-progress task not already executing", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const task1 = makeTask("HAI-001", "in-progress");
|
const task1 = makeTask("KB-001", "in-progress");
|
||||||
const task2 = makeTask("HAI-002", "in-progress");
|
const task2 = makeTask("KB-002", "in-progress");
|
||||||
const taskDone = makeTask("HAI-003", "done");
|
const taskDone = makeTask("KB-003", "done");
|
||||||
store.listTasks.mockResolvedValue([task1, task2, taskDone]);
|
store.listTasks.mockResolvedValue([task1, task2, taskDone]);
|
||||||
|
|
||||||
mockAgentSuccess();
|
mockAgentSuccess();
|
||||||
@@ -152,18 +152,18 @@ describe("In-progress task resume after restart", () => {
|
|||||||
// Wait for async execute calls to complete
|
// Wait for async execute calls to complete
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
// createHaiAgent should have been called once per in-progress task
|
// createKbAgent should have been called once per in-progress task
|
||||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
|
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resumed task reuses existing worktree — no git worktree add called", async () => {
|
it("resumed task reuses existing worktree — no git worktree add called", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const task = makeTask("HAI-010", "in-progress", {
|
const task = makeTask("KB-010", "in-progress", {
|
||||||
worktree: "/tmp/wt/HAI-010",
|
worktree: "/tmp/wt/KB-010",
|
||||||
});
|
});
|
||||||
store.listTasks.mockResolvedValue([task]);
|
store.listTasks.mockResolvedValue([task]);
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-010", "in-progress", {
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-010", "in-progress", {
|
||||||
worktree: "/tmp/wt/HAI-010",
|
worktree: "/tmp/wt/KB-010",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Worktree exists on disk
|
// Worktree exists on disk
|
||||||
@@ -184,9 +184,9 @@ describe("In-progress task resume after restart", () => {
|
|||||||
it("resumed task with step progress includes RESUMING section in agent prompt", async () => {
|
it("resumed task with step progress includes RESUMING section in agent prompt", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const steps = makeSteps("done", "done", "done", "in-progress", "pending");
|
const steps = makeSteps("done", "done", "done", "in-progress", "pending");
|
||||||
const task = makeTask("HAI-020", "in-progress", { steps, currentStep: 3 });
|
const task = makeTask("KB-020", "in-progress", { steps, currentStep: 3 });
|
||||||
store.listTasks.mockResolvedValue([task]);
|
store.listTasks.mockResolvedValue([task]);
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-020", "in-progress", {
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-020", "in-progress", {
|
||||||
steps,
|
steps,
|
||||||
currentStep: 3,
|
currentStep: 3,
|
||||||
}));
|
}));
|
||||||
@@ -217,9 +217,9 @@ describe("In-progress task resume after restart", () => {
|
|||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
worktreeInitCommand: "pnpm install",
|
worktreeInitCommand: "pnpm install",
|
||||||
});
|
});
|
||||||
const task = makeTask("HAI-030", "in-progress");
|
const task = makeTask("KB-030", "in-progress");
|
||||||
store.listTasks.mockResolvedValue([task]);
|
store.listTasks.mockResolvedValue([task]);
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-030", "in-progress"));
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-030", "in-progress"));
|
||||||
|
|
||||||
mockedExistsSync.mockReturnValue(true); // worktree exists
|
mockedExistsSync.mockReturnValue(true); // worktree exists
|
||||||
mockAgentSuccess();
|
mockAgentSuccess();
|
||||||
@@ -240,8 +240,8 @@ describe("In-progress task resume after restart", () => {
|
|||||||
|
|
||||||
it("resumeOrphaned() logs 'Resumed after engine restart' for each orphaned task", async () => {
|
it("resumeOrphaned() logs 'Resumed after engine restart' for each orphaned task", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const task1 = makeTask("HAI-040", "in-progress");
|
const task1 = makeTask("KB-040", "in-progress");
|
||||||
const task2 = makeTask("HAI-041", "in-progress");
|
const task2 = makeTask("KB-041", "in-progress");
|
||||||
store.listTasks.mockResolvedValue([task1, task2]);
|
store.listTasks.mockResolvedValue([task1, task2]);
|
||||||
store.getTask.mockImplementation(async (id: string) =>
|
store.getTask.mockImplementation(async (id: string) =>
|
||||||
makeTaskDetail(id, "in-progress"),
|
makeTaskDetail(id, "in-progress"),
|
||||||
@@ -253,25 +253,25 @@ describe("In-progress task resume after restart", () => {
|
|||||||
await executor.resumeOrphaned();
|
await executor.resumeOrphaned();
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
expect(store.logEntry).toHaveBeenCalledWith("HAI-040", "Resumed after engine restart");
|
expect(store.logEntry).toHaveBeenCalledWith("KB-040", "Resumed after engine restart");
|
||||||
expect(store.logEntry).toHaveBeenCalledWith("HAI-041", "Resumed after engine restart");
|
expect(store.logEntry).toHaveBeenCalledWith("KB-041", "Resumed after engine restart");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Step 3: In-review merge re-queue tests ────────────────────────────────
|
// ── Step 3: In-review merge re-queue tests ────────────────────────────────
|
||||||
//
|
//
|
||||||
// The merge queue/enqueueMerge logic lives in dashboard.ts (CLI layer).
|
// The merge queue/enqueueMerge logic lives in dashboard.ts (CLI layer).
|
||||||
// These tests focus on what @hai/engine owns: aiMergeTask() behaviour
|
// These tests focus on what @kb/engine owns: aiMergeTask() behaviour
|
||||||
// relevant to restart resilience — state validation, status lifecycle,
|
// relevant to restart resilience — state validation, status lifecycle,
|
||||||
// and error handling with git reset --merge cleanup.
|
// and error handling with git reset --merge cleanup.
|
||||||
|
|
||||||
describe("In-review merge handling after restart", () => {
|
describe("In-review merge handling after restart", () => {
|
||||||
it("aiMergeTask validates task is in 'in-review' before merging", async () => {
|
it("aiMergeTask validates task is in 'in-review' before merging", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-050", "in-progress"));
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-050", "in-progress"));
|
||||||
|
|
||||||
await expect(aiMergeTask(store, "/tmp/root", "HAI-050")).rejects.toThrow(
|
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).rejects.toThrow(
|
||||||
"Cannot merge HAI-050: task is in 'in-progress', must be in 'in-review'",
|
"Cannot merge KB-050: task is in 'in-progress', must be in 'in-review'",
|
||||||
);
|
);
|
||||||
|
|
||||||
// No git commands should have been executed
|
// No git commands should have been executed
|
||||||
@@ -280,8 +280,8 @@ describe("In-review merge handling after restart", () => {
|
|||||||
|
|
||||||
it("aiMergeTask sets status to 'merging' during execution and clears on success", async () => {
|
it("aiMergeTask sets status to 'merging' during execution and clears on success", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-051", "in-review"));
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-051", "in-review"));
|
||||||
store.moveTask.mockResolvedValue(makeTask("HAI-051", "done"));
|
store.moveTask.mockResolvedValue(makeTask("KB-051", "done"));
|
||||||
|
|
||||||
// Branch exists, merge succeeds, no conflicts
|
// Branch exists, merge succeeds, no conflicts
|
||||||
mockedExecSync.mockImplementation((cmd: any) => {
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
@@ -294,16 +294,16 @@ describe("In-review merge handling after restart", () => {
|
|||||||
|
|
||||||
mockAgentSuccess();
|
mockAgentSuccess();
|
||||||
|
|
||||||
await aiMergeTask(store, "/tmp/root", "HAI-051");
|
await aiMergeTask(store, "/tmp/root", "KB-051");
|
||||||
|
|
||||||
// Should have set status to "merging"
|
// Should have set status to "merging"
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-051", { status: "merging" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-051", { status: "merging" });
|
||||||
// Should have cleared status via completeTask (status: null before moveTask)
|
// Should have cleared status via completeTask (status: null before moveTask)
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-051", { status: null });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-051", { status: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sequential aiMergeTask calls for multiple in-review tasks all succeed", async () => {
|
it("sequential aiMergeTask calls for multiple in-review tasks all succeed", async () => {
|
||||||
const taskIds = ["HAI-052", "HAI-053", "HAI-054"];
|
const taskIds = ["KB-052", "KB-053", "KB-054"];
|
||||||
|
|
||||||
for (const taskId of taskIds) {
|
for (const taskId of taskIds) {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
@@ -326,7 +326,7 @@ describe("In-review merge handling after restart", () => {
|
|||||||
|
|
||||||
it("aiMergeTask throws on agent failure during session.prompt and calls git reset --merge", async () => {
|
it("aiMergeTask throws on agent failure during session.prompt and calls git reset --merge", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-055", "in-review"));
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-055", "in-review"));
|
||||||
|
|
||||||
// Branch exists, merge starts, agent creates but prompt fails
|
// Branch exists, merge starts, agent creates but prompt fails
|
||||||
mockedExecSync.mockImplementation((cmd: any) => {
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
@@ -343,8 +343,8 @@ describe("In-review merge handling after restart", () => {
|
|||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
await expect(aiMergeTask(store, "/tmp/root", "HAI-055")).rejects.toThrow(
|
await expect(aiMergeTask(store, "/tmp/root", "KB-055")).rejects.toThrow(
|
||||||
"AI merge failed for HAI-055: merge agent crashed",
|
"AI merge failed for KB-055: merge agent crashed",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should have attempted git reset --merge cleanup
|
// Should have attempted git reset --merge cleanup
|
||||||
@@ -354,13 +354,13 @@ describe("In-review merge handling after restart", () => {
|
|||||||
expect(resetCalls.length).toBeGreaterThan(0);
|
expect(resetCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Status was set to "merging" but NOT cleared by aiMergeTask (that's the dashboard's job)
|
// Status was set to "merging" but NOT cleared by aiMergeTask (that's the dashboard's job)
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-055", { status: "merging" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-055", { status: "merging" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("aiMergeTask moves task to done when branch does not exist", async () => {
|
it("aiMergeTask moves task to done when branch does not exist", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-056", "in-review"));
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-056", "in-review"));
|
||||||
store.moveTask.mockResolvedValue(makeTask("HAI-056", "done"));
|
store.moveTask.mockResolvedValue(makeTask("KB-056", "done"));
|
||||||
|
|
||||||
// git rev-parse --verify throws (branch not found)
|
// git rev-parse --verify throws (branch not found)
|
||||||
mockedExecSync.mockImplementation((cmd: any) => {
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
@@ -370,11 +370,11 @@ describe("In-review merge handling after restart", () => {
|
|||||||
return Buffer.from("");
|
return Buffer.from("");
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await aiMergeTask(store, "/tmp/root", "HAI-056");
|
const result = await aiMergeTask(store, "/tmp/root", "KB-056");
|
||||||
|
|
||||||
expect(result.merged).toBe(false);
|
expect(result.merged).toBe(false);
|
||||||
expect(result.error).toContain("Branch");
|
expect(result.error).toContain("Branch");
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-056", "done");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-056", "done");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -383,8 +383,8 @@ describe("In-review merge handling after restart", () => {
|
|||||||
describe("Triage re-pick after restart", () => {
|
describe("Triage re-pick after restart", () => {
|
||||||
it("TriageProcessor.start() after restart picks up triage tasks (processing set is fresh)", async () => {
|
it("TriageProcessor.start() after restart picks up triage tasks (processing set is fresh)", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const triageTask1 = makeTask("HAI-060", "triage");
|
const triageTask1 = makeTask("KB-060", "triage");
|
||||||
const triageTask2 = makeTask("HAI-061", "triage");
|
const triageTask2 = makeTask("KB-061", "triage");
|
||||||
store.listTasks.mockResolvedValue([triageTask1, triageTask2]);
|
store.listTasks.mockResolvedValue([triageTask1, triageTask2]);
|
||||||
store.getTask.mockImplementation(async (id: string) =>
|
store.getTask.mockImplementation(async (id: string) =>
|
||||||
makeTaskDetail(id, "triage"),
|
makeTaskDetail(id, "triage"),
|
||||||
@@ -402,15 +402,15 @@ describe("Triage re-pick after restart", () => {
|
|||||||
triage.stop();
|
triage.stop();
|
||||||
|
|
||||||
// Both triage tasks should have been picked up for specification
|
// Both triage tasks should have been picked up for specification
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-060", { status: "specifying" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-060", { status: "specifying" });
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-061", { status: "specifying" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-061", { status: "specifying" });
|
||||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
|
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("specifyTask() skips task already in processing set (no double-specification)", async () => {
|
it("specifyTask() skips task already in processing set (no double-specification)", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const task = makeTask("HAI-062", "triage");
|
const task = makeTask("KB-062", "triage");
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-062", "triage"));
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-062", "triage"));
|
||||||
|
|
||||||
// Slow agent to keep task in processing
|
// Slow agent to keep task in processing
|
||||||
let resolvePrompt: Function;
|
let resolvePrompt: Function;
|
||||||
@@ -444,7 +444,7 @@ describe("Triage re-pick after restart", () => {
|
|||||||
describe("Scheduler after restart", () => {
|
describe("Scheduler after restart", () => {
|
||||||
it("schedule() moves todo tasks to in-progress when deps are satisfied", async () => {
|
it("schedule() moves todo tasks to in-progress when deps are satisfied", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const todoTask = makeTask("HAI-070", "todo");
|
const todoTask = makeTask("KB-070", "todo");
|
||||||
store.listTasks.mockResolvedValue([todoTask]);
|
store.listTasks.mockResolvedValue([todoTask]);
|
||||||
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS });
|
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS });
|
||||||
|
|
||||||
@@ -463,16 +463,16 @@ describe("Scheduler after restart", () => {
|
|||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
|
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-070", "in-progress");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-070", "in-progress");
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-070", { status: null, blockedBy: null });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-070", { status: null, blockedBy: null });
|
||||||
expect(onSchedule).toHaveBeenCalledWith(todoTask);
|
expect(onSchedule).toHaveBeenCalledWith(todoTask);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("schedule() respects dependency ordering — blocked tasks stay in todo", async () => {
|
it("schedule() respects dependency ordering — blocked tasks stay in todo", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const depTask = makeTask("HAI-071", "in-progress");
|
const depTask = makeTask("KB-071", "in-progress");
|
||||||
const blockedTask = makeTask("HAI-072", "todo", {
|
const blockedTask = makeTask("KB-072", "todo", {
|
||||||
dependencies: ["HAI-071"],
|
dependencies: ["KB-071"],
|
||||||
});
|
});
|
||||||
store.listTasks.mockResolvedValue([depTask, blockedTask]);
|
store.listTasks.mockResolvedValue([depTask, blockedTask]);
|
||||||
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS });
|
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS });
|
||||||
@@ -489,19 +489,19 @@ describe("Scheduler after restart", () => {
|
|||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
|
|
||||||
// Task should NOT have been moved
|
// Task should NOT have been moved
|
||||||
expect(store.moveTask).not.toHaveBeenCalledWith("HAI-072", "in-progress");
|
expect(store.moveTask).not.toHaveBeenCalledWith("KB-072", "in-progress");
|
||||||
expect(onBlocked).toHaveBeenCalledWith(blockedTask, ["HAI-071"]);
|
expect(onBlocked).toHaveBeenCalledWith(blockedTask, ["KB-071"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("full column coverage: restart with tasks in every column", async () => {
|
it("full column coverage: restart with tasks in every column", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
|
|
||||||
// Tasks across all columns
|
// Tasks across all columns
|
||||||
const triageTask = makeTask("HAI-080", "triage");
|
const triageTask = makeTask("KB-080", "triage");
|
||||||
const todoTask = makeTask("HAI-081", "todo");
|
const todoTask = makeTask("KB-081", "todo");
|
||||||
const inProgressTask = makeTask("HAI-082", "in-progress");
|
const inProgressTask = makeTask("KB-082", "in-progress");
|
||||||
const inReviewTask = makeTask("HAI-083", "in-review");
|
const inReviewTask = makeTask("KB-083", "in-review");
|
||||||
const doneTask = makeTask("HAI-084", "done");
|
const doneTask = makeTask("KB-084", "done");
|
||||||
|
|
||||||
const allTasks = [triageTask, todoTask, inProgressTask, inReviewTask, doneTask];
|
const allTasks = [triageTask, todoTask, inProgressTask, inReviewTask, doneTask];
|
||||||
store.listTasks.mockResolvedValue(allTasks);
|
store.listTasks.mockResolvedValue(allTasks);
|
||||||
@@ -521,7 +521,7 @@ describe("Scheduler after restart", () => {
|
|||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
triage.stop();
|
triage.stop();
|
||||||
|
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-080", { status: "specifying" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-080", { status: "specifying" });
|
||||||
|
|
||||||
// 2. Scheduler moves todo → in-progress
|
// 2. Scheduler moves todo → in-progress
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -533,7 +533,7 @@ describe("Scheduler after restart", () => {
|
|||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
|
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-081", "in-progress");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-081", "in-progress");
|
||||||
|
|
||||||
// 3. Executor resumes in-progress tasks
|
// 3. Executor resumes in-progress tasks
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -548,13 +548,13 @@ describe("Scheduler after restart", () => {
|
|||||||
await executor.resumeOrphaned();
|
await executor.resumeOrphaned();
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
expect(store.logEntry).toHaveBeenCalledWith("HAI-082", "Resumed after engine restart");
|
expect(store.logEntry).toHaveBeenCalledWith("KB-082", "Resumed after engine restart");
|
||||||
|
|
||||||
// 4. Done tasks are untouched (no operations on HAI-084)
|
// 4. Done tasks are untouched (no operations on KB-084)
|
||||||
const doneCalls = [
|
const doneCalls = [
|
||||||
...store.updateTask.mock.calls,
|
...store.updateTask.mock.calls,
|
||||||
...store.moveTask.mock.calls,
|
...store.moveTask.mock.calls,
|
||||||
].filter((call) => call[0] === "HAI-084");
|
].filter((call) => call[0] === "KB-084");
|
||||||
expect(doneCalls).toHaveLength(0);
|
expect(doneCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -565,9 +565,9 @@ describe("Crash scenario edge cases", () => {
|
|||||||
it("agent dies mid-step — onError is called, semaphore slot released, task eligible for resume", async () => {
|
it("agent dies mid-step — onError is called, semaphore slot released, task eligible for resume", async () => {
|
||||||
const sem = new AgentSemaphore(2);
|
const sem = new AgentSemaphore(2);
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const task = makeTask("HAI-090", "in-progress");
|
const task = makeTask("KB-090", "in-progress");
|
||||||
store.listTasks.mockResolvedValue([task]);
|
store.listTasks.mockResolvedValue([task]);
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-090", "in-progress"));
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-090", "in-progress"));
|
||||||
|
|
||||||
// Agent session.prompt rejects (simulating crash mid-step)
|
// Agent session.prompt rejects (simulating crash mid-step)
|
||||||
mockedCreateHaiAgent.mockResolvedValue({
|
mockedCreateHaiAgent.mockResolvedValue({
|
||||||
@@ -596,7 +596,7 @@ describe("Crash scenario edge cases", () => {
|
|||||||
// Verify by calling resumeOrphaned again — it should try to execute again
|
// Verify by calling resumeOrphaned again — it should try to execute again
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
store.listTasks.mockResolvedValue([task]);
|
store.listTasks.mockResolvedValue([task]);
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-090", "in-progress"));
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-090", "in-progress"));
|
||||||
mockAgentSuccess();
|
mockAgentSuccess();
|
||||||
|
|
||||||
await executor.resumeOrphaned();
|
await executor.resumeOrphaned();
|
||||||
@@ -608,7 +608,7 @@ describe("Crash scenario edge cases", () => {
|
|||||||
|
|
||||||
it("engine killed during merge — git reset --merge cleanup, task stays in-review", async () => {
|
it("engine killed during merge — git reset --merge cleanup, task stays in-review", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-091", "in-review"));
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-091", "in-review"));
|
||||||
|
|
||||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||||
|
|
||||||
@@ -620,7 +620,7 @@ describe("Crash scenario edge cases", () => {
|
|||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
await expect(aiMergeTask(store, "/tmp/root", "HAI-091")).rejects.toThrow();
|
await expect(aiMergeTask(store, "/tmp/root", "KB-091")).rejects.toThrow();
|
||||||
|
|
||||||
// git reset --merge should have been called
|
// git reset --merge should have been called
|
||||||
const resetCalls = mockedExecSync.mock.calls.filter(
|
const resetCalls = mockedExecSync.mock.calls.filter(
|
||||||
@@ -629,17 +629,17 @@ describe("Crash scenario edge cases", () => {
|
|||||||
expect(resetCalls.length).toBeGreaterThan(0);
|
expect(resetCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Task should NOT have been moved to done
|
// Task should NOT have been moved to done
|
||||||
expect(store.moveTask).not.toHaveBeenCalledWith("HAI-091", "done");
|
expect(store.moveTask).not.toHaveBeenCalledWith("KB-091", "done");
|
||||||
|
|
||||||
// Status was set to "merging" during execution
|
// Status was set to "merging" during execution
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-091", { status: "merging" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-091", { status: "merging" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("concurrent resumeOrphaned() calls don't double-execute the same task", async () => {
|
it("concurrent resumeOrphaned() calls don't double-execute the same task", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const task = makeTask("HAI-092", "in-progress");
|
const task = makeTask("KB-092", "in-progress");
|
||||||
store.listTasks.mockResolvedValue([task]);
|
store.listTasks.mockResolvedValue([task]);
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-092", "in-progress"));
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-092", "in-progress"));
|
||||||
|
|
||||||
let resolvePrompt: Function;
|
let resolvePrompt: Function;
|
||||||
mockedCreateHaiAgent.mockResolvedValue({
|
mockedCreateHaiAgent.mockResolvedValue({
|
||||||
@@ -676,9 +676,9 @@ describe("Crash scenario edge cases", () => {
|
|||||||
await sem.acquire();
|
await sem.acquire();
|
||||||
expect(sem.activeCount).toBe(1);
|
expect(sem.activeCount).toBe(1);
|
||||||
|
|
||||||
const task = makeTask("HAI-093", "in-progress");
|
const task = makeTask("KB-093", "in-progress");
|
||||||
store.listTasks.mockResolvedValue([task]);
|
store.listTasks.mockResolvedValue([task]);
|
||||||
store.getTask.mockResolvedValue(makeTaskDetail("HAI-093", "in-progress"));
|
store.getTask.mockResolvedValue(makeTaskDetail("KB-093", "in-progress"));
|
||||||
|
|
||||||
// Agent creation itself fails
|
// Agent creation itself fails
|
||||||
mockedCreateHaiAgent.mockRejectedValue(new Error("cannot create agent"));
|
mockedCreateHaiAgent.mockRejectedValue(new Error("cannot create agent"));
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("./pi.js", () => ({
|
vi.mock("./pi.js", () => ({
|
||||||
createHaiAgent: vi.fn(),
|
createKbAgent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { reviewStep } from "./reviewer.js";
|
import { reviewStep } from "./reviewer.js";
|
||||||
import { createHaiAgent } from "./pi.js";
|
import { createKbAgent } from "./pi.js";
|
||||||
|
|
||||||
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
|
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
|
||||||
|
|
||||||
function createMockSession(reviewText: string) {
|
function createMockSession(reviewText: string) {
|
||||||
return {
|
return {
|
||||||
@@ -30,13 +30,13 @@ describe("reviewStep — model settings threading", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes defaultProvider and defaultModelId to createHaiAgent when provided", async () => {
|
it("passes defaultProvider and defaultModelId to createKbAgent when provided", async () => {
|
||||||
mockedCreateHaiAgent.mockResolvedValue(
|
mockedCreateHaiAgent.mockResolvedValue(
|
||||||
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
|
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
|
||||||
);
|
);
|
||||||
|
|
||||||
await reviewStep(
|
await reviewStep(
|
||||||
"/tmp/worktree", "HAI-100", 1, "Test Step", "plan", "# prompt",
|
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
defaultProvider: "anthropic",
|
defaultProvider: "anthropic",
|
||||||
@@ -56,7 +56,7 @@ describe("reviewStep — model settings threading", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await reviewStep(
|
await reviewStep(
|
||||||
"/tmp/worktree", "HAI-100", 1, "Test Step", "plan", "# prompt",
|
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
|
||||||
undefined,
|
undefined,
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@@ -73,7 +73,7 @@ describe("reviewStep — model settings threading", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await reviewStep(
|
const result = await reviewStep(
|
||||||
"/tmp/worktree", "HAI-100", 1, "Test Step", "plan", "# prompt",
|
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.verdict).toBe("APPROVE");
|
expect(result.verdict).toBe("APPROVE");
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* - Verdict + feedback is returned to the worker
|
* - Verdict + feedback is returned to the worker
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHaiAgent } from "./pi.js";
|
import { createKbAgent } from "./pi.js";
|
||||||
|
|
||||||
const REVIEWER_SYSTEM_PROMPT = `You are an independent code and plan reviewer.
|
const REVIEWER_SYSTEM_PROMPT = `You are an independent code and plan reviewer.
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ export async function reviewStep(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Spawn a reviewer agent with read-only tools
|
// Spawn a reviewer agent with read-only tools
|
||||||
const { session } = await createHaiAgent({
|
const { session } = await createKbAgent({
|
||||||
cwd,
|
cwd,
|
||||||
systemPrompt: REVIEWER_SYSTEM_PROMPT,
|
systemPrompt: REVIEWER_SYSTEM_PROMPT,
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Scheduler } from "./scheduler.js";
|
|||||||
|
|
||||||
function makeTask(overrides: Record<string, unknown> = {}) {
|
function makeTask(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test Task",
|
title: "Test Task",
|
||||||
column: "todo",
|
column: "todo",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
@@ -49,40 +49,40 @@ describe("Scheduler concurrency", () => {
|
|||||||
|
|
||||||
it("respects maxConcurrent with only in-progress tasks", async () => {
|
it("respects maxConcurrent with only in-progress tasks", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "in-progress" }),
|
makeTask({ id: "KB-002", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-003", column: "todo" }),
|
makeTask({ id: "KB-003", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
|
|
||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
// HAI-003 should NOT be moved — 2 in-progress already fills maxConcurrent
|
// KB-003 should NOT be moved — 2 in-progress already fills maxConcurrent
|
||||||
expect(store.moveTask).not.toHaveBeenCalled();
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("counts specifying tasks toward concurrency", async () => {
|
it("counts specifying tasks toward concurrency", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "triage", status: "specifying" }),
|
makeTask({ id: "KB-002", column: "triage", status: "specifying" }),
|
||||||
makeTask({ id: "HAI-003", column: "todo" }),
|
makeTask({ id: "KB-003", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
|
|
||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
// 1 in-progress + 1 specifying = 2 agent slots, no room for HAI-003
|
// 1 in-progress + 1 specifying = 2 agent slots, no room for KB-003
|
||||||
expect(store.moveTask).not.toHaveBeenCalled();
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks all todo tasks when specifying fills all slots", async () => {
|
it("blocks all todo tasks when specifying fills all slots", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "triage", status: "specifying" }),
|
makeTask({ id: "KB-001", column: "triage", status: "specifying" }),
|
||||||
makeTask({ id: "HAI-002", column: "triage", status: "specifying" }),
|
makeTask({ id: "KB-002", column: "triage", status: "specifying" }),
|
||||||
makeTask({ id: "HAI-003", column: "todo" }),
|
makeTask({ id: "KB-003", column: "todo" }),
|
||||||
makeTask({ id: "HAI-004", column: "todo" }),
|
makeTask({ id: "KB-004", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
@@ -94,9 +94,9 @@ describe("Scheduler concurrency", () => {
|
|||||||
|
|
||||||
it("allows scheduling when mixed slots leave room", async () => {
|
it("allows scheduling when mixed slots leave room", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "triage", status: "specifying" }),
|
makeTask({ id: "KB-002", column: "triage", status: "specifying" }),
|
||||||
makeTask({ id: "HAI-003", column: "todo" }),
|
makeTask({ id: "KB-003", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
@@ -111,14 +111,14 @@ describe("Scheduler concurrency", () => {
|
|||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
// 1 in-progress + 1 specifying = 2 slots used, 1 available
|
// 1 in-progress + 1 specifying = 2 slots used, 1 available
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-003", "in-progress");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-003", "in-progress");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("behaves normally when no tasks are specifying", async () => {
|
it("behaves normally when no tasks are specifying", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "triage" }), // no status: "specifying"
|
makeTask({ id: "KB-002", column: "triage" }), // no status: "specifying"
|
||||||
makeTask({ id: "HAI-003", column: "todo" }),
|
makeTask({ id: "KB-003", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
@@ -126,7 +126,7 @@ describe("Scheduler concurrency", () => {
|
|||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
// Only 1 in-progress, triage task without "specifying" doesn't count
|
// Only 1 in-progress, triage task without "specifying" doesn't count
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-003", "in-progress");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-003", "in-progress");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,8 +142,8 @@ describe("Scheduler dynamic settings reload", () => {
|
|||||||
|
|
||||||
it("reads maxConcurrent from store settings on each schedule() call", async () => {
|
it("reads maxConcurrent from store settings on each schedule() call", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "todo" }),
|
makeTask({ id: "KB-002", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
// Start with maxConcurrent: 1 — no room
|
// Start with maxConcurrent: 1 — no room
|
||||||
@@ -159,7 +159,7 @@ describe("Scheduler dynamic settings reload", () => {
|
|||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
expect(store.moveTask).not.toHaveBeenCalled();
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Now bump maxConcurrent to 2 — room for HAI-002
|
// Now bump maxConcurrent to 2 — room for KB-002
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
@@ -169,14 +169,14 @@ describe("Scheduler dynamic settings reload", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-002", "in-progress");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-002", "in-progress");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads maxWorktrees from store settings on each schedule() call", async () => {
|
it("reads maxWorktrees from store settings on each schedule() call", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "in-review", worktree: "/tmp/wt" }),
|
makeTask({ id: "KB-002", column: "in-review", worktree: "/tmp/wt" }),
|
||||||
makeTask({ id: "HAI-003", column: "todo" }),
|
makeTask({ id: "KB-003", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
// Start with maxWorktrees: 2 — no room (2 active worktrees)
|
// Start with maxWorktrees: 2 — no room (2 active worktrees)
|
||||||
@@ -192,7 +192,7 @@ describe("Scheduler dynamic settings reload", () => {
|
|||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
expect(store.moveTask).not.toHaveBeenCalled();
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Bump maxWorktrees to 3 — room for HAI-003
|
// Bump maxWorktrees to 3 — room for KB-003
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
maxConcurrent: 4,
|
maxConcurrent: 4,
|
||||||
maxWorktrees: 3,
|
maxWorktrees: 3,
|
||||||
@@ -202,7 +202,7 @@ describe("Scheduler dynamic settings reload", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-003", "in-progress");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-003", "in-progress");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes poll interval when settings.pollIntervalMs changes", async () => {
|
it("refreshes poll interval when settings.pollIntervalMs changes", async () => {
|
||||||
@@ -254,8 +254,8 @@ describe("Scheduler file-scope overlap", () => {
|
|||||||
|
|
||||||
it("sets status 'queued' for a todo task deferred due to file scope overlap", async () => {
|
it("sets status 'queued' for a todo task deferred due to file scope overlap", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "todo" }),
|
makeTask({ id: "KB-002", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
// Enable file scope grouping
|
// Enable file scope grouping
|
||||||
@@ -268,24 +268,24 @@ describe("Scheduler file-scope overlap", () => {
|
|||||||
});
|
});
|
||||||
// Both tasks share overlapping file scopes
|
// Both tasks share overlapping file scopes
|
||||||
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
|
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
|
||||||
if (id === "HAI-001") return ["packages/shared/utils.ts"];
|
if (id === "KB-001") return ["packages/shared/utils.ts"];
|
||||||
if (id === "HAI-002") return ["packages/shared/utils.ts"];
|
if (id === "KB-002") return ["packages/shared/utils.ts"];
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
|
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
|
||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
// HAI-002 should NOT be moved to in-progress (deferred)
|
// KB-002 should NOT be moved to in-progress (deferred)
|
||||||
expect(store.moveTask).not.toHaveBeenCalled();
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
// HAI-002 should have status set to "queued" with blockedBy
|
// KB-002 should have status set to "queued" with blockedBy
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-002", { status: "queued", blockedBy: "HAI-001" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-002", { status: "queued", blockedBy: "KB-001" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets blockedBy to the overlapping task ID when deferred due to file scope overlap", async () => {
|
it("sets blockedBy to the overlapping task ID when deferred due to file scope overlap", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "todo" }),
|
makeTask({ id: "KB-002", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
@@ -296,20 +296,20 @@ describe("Scheduler file-scope overlap", () => {
|
|||||||
autoMerge: false,
|
autoMerge: false,
|
||||||
});
|
});
|
||||||
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
|
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
|
||||||
if (id === "HAI-001") return ["packages/shared/utils.ts"];
|
if (id === "KB-001") return ["packages/shared/utils.ts"];
|
||||||
if (id === "HAI-002") return ["packages/shared/utils.ts"];
|
if (id === "KB-002") return ["packages/shared/utils.ts"];
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
|
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
|
||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-002", { status: "queued", blockedBy: "HAI-001" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-002", { status: "queued", blockedBy: "KB-001" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears blockedBy when a task is started", async () => {
|
it("clears blockedBy when a task is started", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "todo" }),
|
makeTask({ id: "KB-001", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
@@ -323,15 +323,15 @@ describe("Scheduler file-scope overlap", () => {
|
|||||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", { status: null, blockedBy: null });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: null, blockedBy: null });
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-001", "in-progress");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-progress");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not emit console.log when deferring due to file overlap", async () => {
|
it("does not emit console.log when deferring due to file overlap", async () => {
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "todo" }),
|
makeTask({ id: "KB-002", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
@@ -342,8 +342,8 @@ describe("Scheduler file-scope overlap", () => {
|
|||||||
autoMerge: false,
|
autoMerge: false,
|
||||||
});
|
});
|
||||||
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
|
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
|
||||||
if (id === "HAI-001") return ["packages/shared/utils.ts"];
|
if (id === "KB-001") return ["packages/shared/utils.ts"];
|
||||||
if (id === "HAI-002") return ["packages/shared/utils.ts"];
|
if (id === "KB-002") return ["packages/shared/utils.ts"];
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -358,8 +358,8 @@ describe("Scheduler file-scope overlap", () => {
|
|||||||
|
|
||||||
it("does not set status 'queued' when file scopes do not overlap", async () => {
|
it("does not set status 'queued' when file scopes do not overlap", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "todo" }),
|
makeTask({ id: "KB-002", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
@@ -370,16 +370,16 @@ describe("Scheduler file-scope overlap", () => {
|
|||||||
autoMerge: false,
|
autoMerge: false,
|
||||||
});
|
});
|
||||||
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
|
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
|
||||||
if (id === "HAI-001") return ["packages/a/file.ts"];
|
if (id === "KB-001") return ["packages/a/file.ts"];
|
||||||
if (id === "HAI-002") return ["packages/b/file.ts"];
|
if (id === "KB-002") return ["packages/b/file.ts"];
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
|
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
|
||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
// HAI-002 should be moved (no overlap)
|
// KB-002 should be moved (no overlap)
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-002", "in-progress");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-002", "in-progress");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -395,7 +395,7 @@ describe("Scheduler paused tasks", () => {
|
|||||||
|
|
||||||
it("does not schedule paused todo tasks", async () => {
|
it("does not schedule paused todo tasks", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "todo", paused: true }),
|
makeTask({ id: "KB-001", column: "todo", paused: true }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
@@ -407,20 +407,20 @@ describe("Scheduler paused tasks", () => {
|
|||||||
|
|
||||||
it("schedules non-paused todo tasks normally", async () => {
|
it("schedules non-paused todo tasks normally", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "todo", paused: false }),
|
makeTask({ id: "KB-001", column: "todo", paused: false }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
|
||||||
|
|
||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-001", "in-progress");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-progress");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not count paused specifying tasks toward agent slots", async () => {
|
it("does not count paused specifying tasks toward agent slots", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "triage", status: "specifying", paused: true }),
|
makeTask({ id: "KB-001", column: "triage", status: "specifying", paused: true }),
|
||||||
makeTask({ id: "HAI-002", column: "todo" }),
|
makeTask({ id: "KB-002", column: "todo" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
@@ -434,8 +434,8 @@ describe("Scheduler paused tasks", () => {
|
|||||||
|
|
||||||
await runSchedule(scheduler);
|
await runSchedule(scheduler);
|
||||||
|
|
||||||
// The paused specifying task doesn't consume a slot, so HAI-002 should be scheduled
|
// The paused specifying task doesn't consume a slot, so KB-002 should be scheduled
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("HAI-002", "in-progress");
|
expect(store.moveTask).toHaveBeenCalledWith("KB-002", "in-progress");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -451,8 +451,8 @@ describe("Scheduler worktree limit logging", () => {
|
|||||||
|
|
||||||
it("logs worktree limit on the first pass when maxed out", async () => {
|
it("logs worktree limit on the first pass when maxed out", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "in-progress" }),
|
makeTask({ id: "KB-002", column: "in-progress" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
@@ -475,8 +475,8 @@ describe("Scheduler worktree limit logging", () => {
|
|||||||
|
|
||||||
it("does not log worktree limit on subsequent passes while still maxed", async () => {
|
it("does not log worktree limit on subsequent passes while still maxed", async () => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "in-progress" }),
|
makeTask({ id: "KB-002", column: "in-progress" }),
|
||||||
];
|
];
|
||||||
const store = createMockStore(tasks);
|
const store = createMockStore(tasks);
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
@@ -505,11 +505,11 @@ describe("Scheduler worktree limit logging", () => {
|
|||||||
|
|
||||||
it("logs worktree limit again after worktrees free up and become maxed again", async () => {
|
it("logs worktree limit again after worktrees free up and become maxed again", async () => {
|
||||||
const maxedTasks = [
|
const maxedTasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
makeTask({ id: "HAI-002", column: "in-progress" }),
|
makeTask({ id: "KB-002", column: "in-progress" }),
|
||||||
];
|
];
|
||||||
const freeTasks = [
|
const freeTasks = [
|
||||||
makeTask({ id: "HAI-001", column: "in-progress" }),
|
makeTask({ id: "KB-001", column: "in-progress" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const store = createMockStore(maxedTasks);
|
const store = createMockStore(maxedTasks);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { resolveDependencyOrder, type TaskStore, type Task } from "@hai/core";
|
import { resolveDependencyOrder, type TaskStore, type Task } from "@kb/core";
|
||||||
import type { AgentSemaphore } from "./concurrency.js";
|
import type { AgentSemaphore } from "./concurrency.js";
|
||||||
import { schedulerLog } from "./logger.js";
|
import { schedulerLog } from "./logger.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { AgentSemaphore } from "./concurrency.js";
|
import { AgentSemaphore } from "./concurrency.js";
|
||||||
|
|
||||||
// Mock createHaiAgent before importing TriageProcessor
|
// Mock createKbAgent before importing TriageProcessor
|
||||||
vi.mock("./pi.js", () => ({
|
vi.mock("./pi.js", () => ({
|
||||||
createHaiAgent: vi.fn(),
|
createKbAgent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { TriageProcessor, buildSpecificationPrompt, type AttachmentContent } from "./triage.js";
|
import { TriageProcessor, buildSpecificationPrompt, type AttachmentContent } from "./triage.js";
|
||||||
import { createHaiAgent } from "./pi.js";
|
import { createKbAgent } from "./pi.js";
|
||||||
import type { TaskDetail } from "@hai/core";
|
import type { TaskDetail } from "@kb/core";
|
||||||
|
|
||||||
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
|
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
|
||||||
|
|
||||||
function createMockStore(tasks: any[] = []) {
|
function createMockStore(tasks: any[] = []) {
|
||||||
return {
|
return {
|
||||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||||
getTask: vi.fn().mockResolvedValue({
|
getTask: vi.fn().mockResolvedValue({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test task",
|
description: "Test task",
|
||||||
column: "triage",
|
column: "triage",
|
||||||
@@ -43,7 +43,7 @@ function createMockStore(tasks: any[] = []) {
|
|||||||
|
|
||||||
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||||
return {
|
return {
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test Task",
|
title: "Test Task",
|
||||||
description: "A test task",
|
description: "A test task",
|
||||||
column: "triage",
|
column: "triage",
|
||||||
@@ -79,7 +79,7 @@ describe("TriageProcessor with semaphore", () => {
|
|||||||
const triage = new TriageProcessor(store, "/tmp/test", { semaphore: sem });
|
const triage = new TriageProcessor(store, "/tmp/test", { semaphore: sem });
|
||||||
|
|
||||||
await triage.specifyTask({
|
await triage.specifyTask({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "triage",
|
column: "triage",
|
||||||
@@ -111,7 +111,7 @@ describe("TriageProcessor with semaphore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await triage.specifyTask({
|
await triage.specifyTask({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "triage",
|
column: "triage",
|
||||||
@@ -163,9 +163,9 @@ describe("TriageProcessor with semaphore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
triage.specifyTask(task("HAI-001")),
|
triage.specifyTask(task("KB-001")),
|
||||||
triage.specifyTask(task("HAI-002")),
|
triage.specifyTask(task("KB-002")),
|
||||||
triage.specifyTask(task("HAI-003")),
|
triage.specifyTask(task("KB-003")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(maxConcurrent).toBe(1);
|
expect(maxConcurrent).toBe(1);
|
||||||
@@ -223,7 +223,7 @@ describe("TriageProcessor paused tasks", () => {
|
|||||||
|
|
||||||
it("skips paused triage tasks in poll()", async () => {
|
it("skips paused triage tasks in poll()", async () => {
|
||||||
const pausedTask = {
|
const pausedTask = {
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Paused",
|
title: "Paused",
|
||||||
description: "Paused task",
|
description: "Paused task",
|
||||||
column: "triage" as const,
|
column: "triage" as const,
|
||||||
@@ -255,7 +255,7 @@ describe("TriageProcessor paused tasks", () => {
|
|||||||
|
|
||||||
it("processes non-paused triage tasks normally", async () => {
|
it("processes non-paused triage tasks normally", async () => {
|
||||||
const normalTask = {
|
const normalTask = {
|
||||||
id: "HAI-002",
|
id: "KB-002",
|
||||||
title: "Normal",
|
title: "Normal",
|
||||||
description: "Normal task",
|
description: "Normal task",
|
||||||
column: "triage" as const,
|
column: "triage" as const,
|
||||||
@@ -280,14 +280,14 @@ describe("TriageProcessor paused tasks", () => {
|
|||||||
await (triage as any).poll();
|
await (triage as any).poll();
|
||||||
|
|
||||||
// Agent should be created for a non-paused task
|
// Agent should be created for a non-paused task
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-002", { status: "specifying" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-002", { status: "specifying" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildSpecificationPrompt", () => {
|
describe("buildSpecificationPrompt", () => {
|
||||||
it("includes project commands when testCommand is set", () => {
|
it("includes project commands when testCommand is set", () => {
|
||||||
const task = createMockTaskDetail();
|
const task = createMockTaskDetail();
|
||||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", {
|
const result = buildSpecificationPrompt(task, ".kb/tasks/KB-001/PROMPT.md", {
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
@@ -303,7 +303,7 @@ describe("buildSpecificationPrompt", () => {
|
|||||||
|
|
||||||
it("includes project commands when buildCommand is set", () => {
|
it("includes project commands when buildCommand is set", () => {
|
||||||
const task = createMockTaskDetail();
|
const task = createMockTaskDetail();
|
||||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", {
|
const result = buildSpecificationPrompt(task, ".kb/tasks/KB-001/PROMPT.md", {
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
@@ -318,7 +318,7 @@ describe("buildSpecificationPrompt", () => {
|
|||||||
|
|
||||||
it("includes both commands when both are set", () => {
|
it("includes both commands when both are set", () => {
|
||||||
const task = createMockTaskDetail();
|
const task = createMockTaskDetail();
|
||||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", {
|
const result = buildSpecificationPrompt(task, ".kb/tasks/KB-001/PROMPT.md", {
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
@@ -334,7 +334,7 @@ describe("buildSpecificationPrompt", () => {
|
|||||||
|
|
||||||
it("omits project commands section when neither command is set", () => {
|
it("omits project commands section when neither command is set", () => {
|
||||||
const task = createMockTaskDetail();
|
const task = createMockTaskDetail();
|
||||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", {
|
const result = buildSpecificationPrompt(task, ".kb/tasks/KB-001/PROMPT.md", {
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
@@ -347,7 +347,7 @@ describe("buildSpecificationPrompt", () => {
|
|||||||
|
|
||||||
it("omits project commands section when settings is undefined", () => {
|
it("omits project commands section when settings is undefined", () => {
|
||||||
const task = createMockTaskDetail();
|
const task = createMockTaskDetail();
|
||||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md");
|
const result = buildSpecificationPrompt(task, ".kb/tasks/KB-001/PROMPT.md");
|
||||||
|
|
||||||
expect(result).not.toContain("## Project Commands");
|
expect(result).not.toContain("## Project Commands");
|
||||||
});
|
});
|
||||||
@@ -357,7 +357,7 @@ describe("buildSpecificationPrompt", () => {
|
|||||||
const attachmentContents: AttachmentContent[] = [
|
const attachmentContents: AttachmentContent[] = [
|
||||||
{ originalName: "error.log", mimeType: "text/plain", text: "ERROR: something broke\nStack trace here" },
|
{ originalName: "error.log", mimeType: "text/plain", text: "ERROR: something broke\nStack trace here" },
|
||||||
];
|
];
|
||||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, attachmentContents);
|
const result = buildSpecificationPrompt(task, ".kb/tasks/KB-001/PROMPT.md", undefined, attachmentContents);
|
||||||
|
|
||||||
expect(result).toContain("## Attachments");
|
expect(result).toContain("## Attachments");
|
||||||
expect(result).toContain("### error.log (text/plain)");
|
expect(result).toContain("### error.log (text/plain)");
|
||||||
@@ -369,7 +369,7 @@ describe("buildSpecificationPrompt", () => {
|
|||||||
const attachmentContents: AttachmentContent[] = [
|
const attachmentContents: AttachmentContent[] = [
|
||||||
{ originalName: "screenshot.png", mimeType: "image/png", text: null },
|
{ originalName: "screenshot.png", mimeType: "image/png", text: null },
|
||||||
];
|
];
|
||||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, attachmentContents);
|
const result = buildSpecificationPrompt(task, ".kb/tasks/KB-001/PROMPT.md", undefined, attachmentContents);
|
||||||
|
|
||||||
expect(result).toContain("## Attachments");
|
expect(result).toContain("## Attachments");
|
||||||
expect(result).toContain("**screenshot.png** (image/png)");
|
expect(result).toContain("**screenshot.png** (image/png)");
|
||||||
@@ -382,7 +382,7 @@ describe("buildSpecificationPrompt", () => {
|
|||||||
{ originalName: "screenshot.png", mimeType: "image/png", text: null },
|
{ originalName: "screenshot.png", mimeType: "image/png", text: null },
|
||||||
{ originalName: "config.json", mimeType: "application/json", text: '{"key": "value"}' },
|
{ originalName: "config.json", mimeType: "application/json", text: '{"key": "value"}' },
|
||||||
];
|
];
|
||||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, attachmentContents);
|
const result = buildSpecificationPrompt(task, ".kb/tasks/KB-001/PROMPT.md", undefined, attachmentContents);
|
||||||
|
|
||||||
expect(result).toContain("**screenshot.png** (image/png)");
|
expect(result).toContain("**screenshot.png** (image/png)");
|
||||||
expect(result).toContain("### config.json (application/json)");
|
expect(result).toContain("### config.json (application/json)");
|
||||||
@@ -391,14 +391,14 @@ describe("buildSpecificationPrompt", () => {
|
|||||||
|
|
||||||
it("omits attachments section when no attachments", () => {
|
it("omits attachments section when no attachments", () => {
|
||||||
const task = createMockTaskDetail();
|
const task = createMockTaskDetail();
|
||||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md", undefined, []);
|
const result = buildSpecificationPrompt(task, ".kb/tasks/KB-001/PROMPT.md", undefined, []);
|
||||||
|
|
||||||
expect(result).not.toContain("## Attachments");
|
expect(result).not.toContain("## Attachments");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits attachments section when attachmentContents is undefined", () => {
|
it("omits attachments section when attachmentContents is undefined", () => {
|
||||||
const task = createMockTaskDetail();
|
const task = createMockTaskDetail();
|
||||||
const result = buildSpecificationPrompt(task, ".hai/tasks/HAI-001/PROMPT.md");
|
const result = buildSpecificationPrompt(task, ".kb/tasks/KB-001/PROMPT.md");
|
||||||
|
|
||||||
expect(result).not.toContain("## Attachments");
|
expect(result).not.toContain("## Attachments");
|
||||||
});
|
});
|
||||||
@@ -412,7 +412,7 @@ function createEnoentError(path = "/fake/path"): NodeJS.ErrnoException {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dummyTask = {
|
const dummyTask = {
|
||||||
id: "HAI-099",
|
id: "KB-099",
|
||||||
title: "Deleted task",
|
title: "Deleted task",
|
||||||
description: "This task was deleted",
|
description: "This task was deleted",
|
||||||
column: "triage" as const,
|
column: "triage" as const,
|
||||||
@@ -485,7 +485,7 @@ describe("TriageProcessor deleted task handling", () => {
|
|||||||
await triage.specifyTask(dummyTask);
|
await triage.specifyTask(dummyTask);
|
||||||
|
|
||||||
// If processing Set was cleaned up, updateTask will be called again for "specifying"
|
// If processing Set was cleaned up, updateTask will be called again for "specifying"
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-099", { status: "specifying" });
|
expect(store.updateTask).toHaveBeenCalledWith("KB-099", { status: "specifying" });
|
||||||
expect(mockedCreateHaiAgent).toHaveBeenCalled();
|
expect(mockedCreateHaiAgent).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -515,7 +515,7 @@ describe("TriageProcessor agent log persistence", () => {
|
|||||||
|
|
||||||
const triage = new TriageProcessor(store, "/tmp/test", {});
|
const triage = new TriageProcessor(store, "/tmp/test", {});
|
||||||
await triage.specifyTask({
|
await triage.specifyTask({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "triage",
|
column: "triage",
|
||||||
@@ -528,7 +528,7 @@ describe("TriageProcessor agent log persistence", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Text buffer is flushed in finally block
|
// Text buffer is flushed in finally block
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-001", "Hello world", "text");
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-001", "Hello world", "text");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs tool invocations to store.appendAgentLog", async () => {
|
it("logs tool invocations to store.appendAgentLog", async () => {
|
||||||
@@ -549,7 +549,7 @@ describe("TriageProcessor agent log persistence", () => {
|
|||||||
|
|
||||||
const triage = new TriageProcessor(store, "/tmp/test", {});
|
const triage = new TriageProcessor(store, "/tmp/test", {});
|
||||||
await triage.specifyTask({
|
await triage.specifyTask({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "triage",
|
column: "triage",
|
||||||
@@ -561,7 +561,7 @@ describe("TriageProcessor agent log persistence", () => {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-001", "Read", "tool", "foo.ts");
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-001", "Read", "tool", "foo.ts");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still fires onAgentText callback alongside logging", async () => {
|
it("still fires onAgentText callback alongside logging", async () => {
|
||||||
@@ -583,7 +583,7 @@ describe("TriageProcessor agent log persistence", () => {
|
|||||||
|
|
||||||
const triage = new TriageProcessor(store, "/tmp/test", { onAgentText });
|
const triage = new TriageProcessor(store, "/tmp/test", { onAgentText });
|
||||||
await triage.specifyTask({
|
await triage.specifyTask({
|
||||||
id: "HAI-001",
|
id: "KB-001",
|
||||||
title: "Test",
|
title: "Test",
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "triage",
|
column: "triage",
|
||||||
@@ -595,7 +595,7 @@ describe("TriageProcessor agent log persistence", () => {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onAgentText).toHaveBeenCalledWith("HAI-001", "hi");
|
expect(onAgentText).toHaveBeenCalledWith("KB-001", "hi");
|
||||||
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-001", "hi", "text");
|
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-001", "hi", "text");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { TaskStore, Task, TaskDetail, TaskAttachment, Settings } from "@hai/core";
|
import type { TaskStore, Task, TaskDetail, TaskAttachment, Settings } from "@kb/core";
|
||||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
import { createHaiAgent } from "./pi.js";
|
import { createKbAgent } from "./pi.js";
|
||||||
import type { AgentSemaphore } from "./concurrency.js";
|
import type { AgentSemaphore } from "./concurrency.js";
|
||||||
import { AgentLogger } from "./agent-logger.js";
|
import { AgentLogger } from "./agent-logger.js";
|
||||||
import { triageLog } from "./logger.js";
|
import { triageLog } from "./logger.js";
|
||||||
|
|
||||||
const TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "hai", an AI-orchestrated task board.
|
const TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "kb", an AI-orchestrated task board.
|
||||||
|
|
||||||
Your job: take a rough task description and produce a fully specified PROMPT.md that another AI agent can execute autonomously in a fresh context with zero memory of this conversation.
|
Your job: take a rough task description and produce a fully specified PROMPT.md that another AI agent can execute autonomously in a fresh context with zero memory of this conversation.
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ Follow this structure exactly:
|
|||||||
### Step {N}: Documentation & Delivery
|
### Step {N}: Documentation & Delivery
|
||||||
|
|
||||||
- [ ] Update relevant documentation
|
- [ ] Update relevant documentation
|
||||||
- [ ] Out-of-scope findings created as new tasks via \`hai task create\`
|
- [ ] Out-of-scope findings created as new tasks via \`kb task create\`
|
||||||
|
|
||||||
## Documentation Requirements
|
## Documentation Requirements
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ export class TriageProcessor {
|
|||||||
await this.store.updateTask(task.id, { status: "specifying" });
|
await this.store.updateTask(task.id, { status: "specifying" });
|
||||||
const detail = await this.store.getTask(task.id);
|
const detail = await this.store.getTask(task.id);
|
||||||
const settings = await this.store.getSettings();
|
const settings = await this.store.getSettings();
|
||||||
const promptPath = `.hai/tasks/${task.id}/PROMPT.md`;
|
const promptPath = `.kb/tasks/${task.id}/PROMPT.md`;
|
||||||
|
|
||||||
const agentWork = async () => {
|
const agentWork = async () => {
|
||||||
const agentLogger = new AgentLogger({
|
const agentLogger = new AgentLogger({
|
||||||
@@ -264,7 +264,7 @@ export class TriageProcessor {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { session } = await createHaiAgent({
|
const { session } = await createKbAgent({
|
||||||
cwd: this.rootDir,
|
cwd: this.rootDir,
|
||||||
systemPrompt: TRIAGE_SYSTEM_PROMPT,
|
systemPrompt: TRIAGE_SYSTEM_PROMPT,
|
||||||
tools: "coding",
|
tools: "coding",
|
||||||
@@ -333,7 +333,7 @@ export class TriageProcessor {
|
|||||||
const store = this.store;
|
const store = this.store;
|
||||||
|
|
||||||
const taskGetParams = Type.Object({
|
const taskGetParams = Type.Object({
|
||||||
id: Type.String({ description: "Task ID (e.g. HAI-001)" }),
|
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const taskList: ToolDefinition = {
|
const taskList: ToolDefinition = {
|
||||||
@@ -431,7 +431,7 @@ export async function readAttachmentContents(
|
|||||||
const { join } = await import("node:path");
|
const { join } = await import("node:path");
|
||||||
|
|
||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
const filePath = join(rootDir, ".hai", "tasks", taskId, "attachments", att.filename);
|
const filePath = join(rootDir, ".kb", "tasks", taskId, "attachments", att.filename);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (IMAGE_MIME_TYPES.has(att.mimeType)) {
|
if (IMAGE_MIME_TYPES.has(att.mimeType)) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe("generateWorktreeName", () => {
|
|||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempDir = mkdtempSync(join(tmpdir(), "hai-wt-test-"));
|
tempDir = mkdtempSync(join(tmpdir(), "kb-wt-test-"));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe("WorktreePool", () => {
|
|||||||
|
|
||||||
describe("prepareForTask", () => {
|
describe("prepareForTask", () => {
|
||||||
it("cleans dirty working tree before checkout", () => {
|
it("cleans dirty working tree before checkout", () => {
|
||||||
pool.prepareForTask("/tmp/wt", "hai/hai-042");
|
pool.prepareForTask("/tmp/wt", "kb/kb-042");
|
||||||
|
|
||||||
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
|
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
|
||||||
expect(calls).toContain("git checkout -- .");
|
expect(calls).toContain("git checkout -- .");
|
||||||
@@ -128,18 +128,18 @@ describe("WorktreePool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates branch from main with force-reset", () => {
|
it("creates branch from main with force-reset", () => {
|
||||||
pool.prepareForTask("/tmp/wt", "hai/hai-042");
|
pool.prepareForTask("/tmp/wt", "kb/kb-042");
|
||||||
|
|
||||||
const checkoutCall = mockedExecSync.mock.calls.find(
|
const checkoutCall = mockedExecSync.mock.calls.find(
|
||||||
(c) => typeof c[0] === "string" && (c[0] as string).includes("checkout -B"),
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("checkout -B"),
|
||||||
);
|
);
|
||||||
expect(checkoutCall).toBeDefined();
|
expect(checkoutCall).toBeDefined();
|
||||||
expect(checkoutCall![0]).toBe('git checkout -B "hai/hai-042" main');
|
expect(checkoutCall![0]).toBe('git checkout -B "kb/kb-042" main');
|
||||||
expect(checkoutCall![1]).toMatchObject({ cwd: "/tmp/wt" });
|
expect(checkoutCall![1]).toMatchObject({ cwd: "/tmp/wt" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs all commands in the correct worktree directory", () => {
|
it("runs all commands in the correct worktree directory", () => {
|
||||||
pool.prepareForTask("/tmp/my-worktree", "hai/hai-099");
|
pool.prepareForTask("/tmp/my-worktree", "kb/kb-099");
|
||||||
|
|
||||||
for (const call of mockedExecSync.mock.calls) {
|
for (const call of mockedExecSync.mock.calls) {
|
||||||
expect(call[1]).toMatchObject({ cwd: "/tmp/my-worktree" });
|
expect(call[1]).toMatchObject({ cwd: "/tmp/my-worktree" });
|
||||||
@@ -153,12 +153,12 @@ describe("WorktreePool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should not throw
|
// Should not throw
|
||||||
expect(() => pool.prepareForTask("/tmp/wt", "hai/hai-001")).not.toThrow();
|
expect(() => pool.prepareForTask("/tmp/wt", "kb/kb-001")).not.toThrow();
|
||||||
|
|
||||||
// Should still run clean and branch creation
|
// Should still run clean and branch creation
|
||||||
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
|
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
|
||||||
expect(calls).toContain("git clean -fd");
|
expect(calls).toContain("git clean -fd");
|
||||||
expect(calls).toContain('git checkout -B "hai/hai-001" main');
|
expect(calls).toContain('git checkout -B "kb/kb-001" main');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export class WorktreePool {
|
|||||||
* 3. `git checkout -B <branchName> main` — create/reset branch from main
|
* 3. `git checkout -B <branchName> main` — create/reset branch from main
|
||||||
*
|
*
|
||||||
* @param worktreePath — Absolute path to the recycled worktree
|
* @param worktreePath — Absolute path to the recycled worktree
|
||||||
* @param branchName — Branch name for the new task (e.g., `hai/hai-042`)
|
* @param branchName — Branch name for the new task (e.g., `kb/kb-042`)
|
||||||
*/
|
*/
|
||||||
prepareForTask(worktreePath: string, branchName: string): void {
|
prepareForTask(worktreePath: string, branchName: string): void {
|
||||||
// Clean tracked modifications
|
// Clean tracked modifications
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -20,13 +20,13 @@ importers:
|
|||||||
|
|
||||||
packages/cli:
|
packages/cli:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hai/core':
|
'@kb/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core
|
version: link:../core
|
||||||
'@hai/dashboard':
|
'@kb/dashboard':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../dashboard
|
version: link:../dashboard
|
||||||
'@hai/engine':
|
'@kb/engine':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../engine
|
version: link:../engine
|
||||||
'@mariozechner/pi-coding-agent':
|
'@mariozechner/pi-coding-agent':
|
||||||
@@ -60,7 +60,7 @@ importers:
|
|||||||
|
|
||||||
packages/dashboard:
|
packages/dashboard:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hai/core':
|
'@kb/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core
|
version: link:../core
|
||||||
'@types/multer':
|
'@types/multer':
|
||||||
@@ -124,7 +124,7 @@ importers:
|
|||||||
|
|
||||||
packages/engine:
|
packages/engine:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hai/core':
|
'@kb/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core
|
version: link:../core
|
||||||
'@mariozechner/pi-ai':
|
'@mariozechner/pi-ai':
|
||||||
|
|||||||
Reference in New Issue
Block a user